Merge remote-tracking branch 'origin/main' into feature/flicker-reduction-rework

This commit is contained in:
Spencer
2026-01-16 20:26:22 +00:00
154 changed files with 4009 additions and 1233 deletions
-2
View File
@@ -1,7 +1,5 @@
name: 'Bug Report'
description: 'Report a bug to help us improve Gemini CLI'
labels:
- 'status/need-triage'
body:
- type: 'markdown'
attributes:
+2 -2
View File
@@ -68,7 +68,7 @@ process_pr_optimized() {
fi
else
echo " ⚠️ No linked issue found for PR #${PR_NUMBER}"
if [[ ",${CURRENT_LABELS}," != ",status/need-issue,"* ]]; then
if [[ ",${CURRENT_LABELS}," != *",status/need-issue,"* ]]; then
echo " Adding status/need-issue label"
LABELS_TO_ADD="status/need-issue"
fi
@@ -82,7 +82,7 @@ process_pr_optimized() {
else
echo " 🔗 Found linked issue #${ISSUE_NUMBER}"
if [[ ",${CURRENT_LABELS}," == ",status/need-issue,"* ]]; then
if [[ ",${CURRENT_LABELS}," == *",status/need-issue,"* ]]; then
echo " Removing status/need-issue label"
LABELS_TO_REMOVE="status/need-issue"
fi
+19 -13
View File
@@ -10,7 +10,7 @@ jobs:
enforce-label:
# Run this job only when restricted labels are changed
if: |-
${{ (github.event.label.name == 'help wanted' || github.event.label.name == 'status/need-triage') &&
${{ (github.event.label.name == 'help wanted' || github.event.label.name == 'status/need-triage' || github.event.label.name == '🔒 maintainer only') &&
(github.repository == 'google-gemini/gemini-cli' || github.repository == 'google-gemini/maintainers-gemini-cli') }}
runs-on: 'ubuntu-latest'
permissions:
@@ -39,28 +39,34 @@ jobs:
const labelName = context.payload.label.name;
// Skip if the change was made by a bot to avoid infinite loops
if (username === 'github-actions[bot]') {
if (username === 'github-actions[bot]' || username === 'gemini-cli[bot]' || username.endsWith('[bot]')) {
core.info('Change made by a bot. Skipping.');
return;
}
try {
// This will succeed with a 204 status if the user is a member,
// and fail with a 404 error if they are not.
await github.rest.teams.getMembershipForUserInOrg ({
org,
team_slug,
// Check repository permission level directly.
// This is more robust than team membership as it doesn't require Org-level read permissions
// and correctly handles Repo Admins/Writers who might not be in the specific team.
const { data: { permission } } = await github.rest.repos.getCollaboratorPermissionLevel({
owner: org,
repo: context.repo.repo,
username,
});
core.info(`${username} is a member of the ${team_slug} team. No action needed.`);
} catch (error) {
// If the error is not 404, rethrow it to fail the action
if (error.status !== 404) {
throw error;
if (permission === 'admin' || permission === 'write') {
core.info(`${username} has '${permission}' permission. Allowed.`);
return;
}
core.info(`${username} is not a member. Reverting '${action}' action for '${labelName}' label.`);
core.info(`${username} has '${permission}' permission (needs 'write' or 'admin'). Reverting '${action}' action for '${labelName}' label.`);
} catch (error) {
core.error(`Failed to check permissions for ${username}: ${error.message}`);
// Fall through to revert logic if we can't verify permissions (fail safe)
}
// If we are here, the user is NOT authorized.
if (true) { // wrapping block to preserve variable scope if needed
if (action === 'labeled') {
// 1. Remove the label if added by a non-maintainer
await github.rest.issues.removeLabel ({
@@ -0,0 +1,94 @@
name: 'Label Workstream Rollup'
on:
issues:
types: ['opened', 'edited', 'reopened']
schedule:
- cron: '0 * * * *'
workflow_dispatch:
jobs:
labeler:
runs-on: 'ubuntu-latest'
permissions:
issues: 'write'
steps:
- name: 'Check for Parent Workstream and Apply Label'
uses: 'actions/github-script@v7'
with:
script: |
const labelToAdd = 'workstream-rollup';
// Allow-list of parent issue URLs
const allowedParentUrls = [
'https://github.com/google-gemini/gemini-cli/issues/15374',
'https://github.com/google-gemini/gemini-cli/issues/15456',
'https://github.com/google-gemini/gemini-cli/issues/15324'
];
async function getIssueParent(owner, repo, number) {
const query = `
query($owner:String!, $repo:String!, $number:Int!) {
repository(owner:$owner, name:$repo) {
issue(number:$number) {
parent {
url
}
}
}
}
`;
try {
const result = await github.graphql(query, { owner, repo, number });
return result.repository.issue.parent ? result.repository.issue.parent.url : null;
} catch (error) {
console.error(`Failed to fetch parent for #${number}:`, error);
return null;
}
}
// Determine which issues to process
let issuesToProcess = [];
if (context.eventName === 'issues') {
// Context payload for 'issues' event already has the issue object
issuesToProcess.push({
number: context.payload.issue.number,
owner: context.repo.owner,
repo: context.repo.repo
});
} else {
// For schedule/dispatch, fetch open issues (lightweight list)
console.log(`Running for event: ${context.eventName}. Fetching open issues...`);
const openIssues = await github.paginate(github.rest.issues.listForRepo, {
owner: context.repo.owner,
repo: context.repo.repo,
state: 'open'
});
issuesToProcess = openIssues.map(i => ({
number: i.number,
owner: context.repo.owner,
repo: context.repo.repo
}));
}
console.log(`Processing ${issuesToProcess.length} issue(s)...`);
for (const issue of issuesToProcess) {
const parentUrl = await getIssueParent(issue.owner, issue.repo, issue.number);
if (parentUrl && allowedParentUrls.includes(parentUrl)) {
console.log(`SUCCESS: Issue #${issue.number} is a direct child of ${parentUrl}. Adding label.`);
await github.rest.issues.addLabels({
owner: issue.owner,
repo: issue.repo,
issue_number: issue.number,
labels: [labelToAdd]
});
} else {
// logging only for single execution to avoid spam
if (context.eventName === 'issues') {
console.log(`Issue #${issue.number} parent is ${parentUrl || 'None'}. No action.`);
}
}
}
+11
View File
@@ -15,6 +15,17 @@ project. While you can run the individual steps (`build`, `test`, `typecheck`,
`lint`) separately, it is highly recommended to use `npm run preflight` to
ensure a comprehensive validation.
## Running Tests in Workspaces\*\*: To run a specific test file within a
workspace, use the command:
`npm test -w <workspace-name> -- <path/to/test-file.test.ts>`. **CRITICAL**: The
`<path/to/test-file.test.ts>` MUST be relative to the workspace directory root,
NOT the project root.
- _Example (Core package)_:
`npm test -w @google/gemini-cli-core -- src/routing/modelRouterService.test.ts`
- _Common workspaces_: `@google/gemini-cli`, `@google/gemini-cli-core`.
## Writing Tests
This project uses **Vitest** as its primary testing framework. When writing
+2 -2
View File
@@ -1230,7 +1230,7 @@ https://github.com/google-gemini/gemini-cli/compare/v0.19.4...v0.20.0
https://github.com/google-gemini/gemini-cli/pull/12863
- feat(hooks): Hook Telemetry Infrastructure by @Edilmo in
https://github.com/google-gemini/gemini-cli/pull/9082
- fix: (some minor improvements to configs and getPackageJson return behaviour)
- fix: (some minor improvements to configs and getPackageJson return behavior)
by @grMLEqomlkkU5Eeinz4brIrOVCUCkJuN in
https://github.com/google-gemini/gemini-cli/pull/12510
- feat(hooks): Hook Event Handling by @Edilmo in
@@ -1364,7 +1364,7 @@ https://github.com/google-gemini/gemini-cli/compare/v0.18.4...v0.19.0
https://github.com/google-gemini/gemini-cli/pull/12863
- feat(hooks): Hook Telemetry Infrastructure by @Edilmo in
https://github.com/google-gemini/gemini-cli/pull/9082
- fix: (some minor improvements to configs and getPackageJson return behaviour)
- fix: (some minor improvements to configs and getPackageJson return behavior)
by @grMLEqomlkkU5Eeinz4brIrOVCUCkJuN in
https://github.com/google-gemini/gemini-cli/pull/12510
- feat(hooks): Hook Event Handling by @Edilmo in
+17
View File
@@ -204,6 +204,23 @@ Slash commands provide meta-level control over the CLI itself.
modify them as desired. Changes to some settings are applied immediately,
while others require a restart.
- [**`/skills`**](./skills.md)
- **Description:** (Experimental) Manage Agent Skills, which provide on-demand
expertise and specialized workflows.
- **Sub-commands:**
- **`list`**:
- **Description:** List all discovered skills and their current status
(enabled/disabled).
- **`enable`**:
- **Description:** Enable a specific skill by name.
- **Usage:** `/skills enable <name>`
- **`disable`**:
- **Description:** Disable a specific skill by name.
- **Usage:** `/skills disable <name>`
- **`reload`**:
- **Description:** Refresh the list of discovered skills from all tiers
(workspace, user, and extensions).
- **`/stats`**
- **Description:** Display detailed statistics for the current Gemini CLI
session, including token usage, cached token savings (when available), and
+9 -9
View File
@@ -50,7 +50,7 @@ Your command definition files must be written in the TOML format and use the
## Handling arguments
Custom commands support two powerful methods for handling arguments. The CLI
automatically chooses the correct method based on the content of your command\'s
automatically chooses the correct method based on the content of your command's
`prompt`.
### 1. Context-aware injection with `{{args}}`
@@ -96,13 +96,13 @@ Search Results:
"""
```
When you run `/grep-code It\'s complicated`:
When you run `/grep-code It's complicated`:
1. The CLI sees `{{args}}` used both outside and inside `!{...}`.
2. Outside: The first `{{args}}` is replaced raw with `It\'s complicated`.
2. Outside: The first `{{args}}` is replaced raw with `It's complicated`.
3. Inside: The second `{{args}}` is replaced with the escaped version (e.g., on
Linux: `"It\'s complicated"`).
4. The command executed is `grep -r "It\'s complicated" .`.
4. The command executed is `grep -r "It's complicated" .`.
5. The CLI prompts you to confirm this exact, secure command before execution.
6. The final prompt is sent.
@@ -129,13 +129,13 @@ format and behavior.
# In: <project>/.gemini/commands/changelog.toml
# Invoked via: /changelog 1.2.0 added "Support for default argument parsing."
description = "Adds a new entry to the project\'s CHANGELOG.md file."
description = "Adds a new entry to the project's CHANGELOG.md file."
prompt = """
# Task: Update Changelog
You are an expert maintainer of this software project. A user has invoked a command to add a new entry to the changelog.
**The user\'s raw command is appended below your instructions.**
**The user's raw command is appended below your instructions.**
Your task is to parse the `<version>`, `<change_type>`, and `<message>` from their input and use the `write_file` tool to correctly update the `CHANGELOG.md` file.
@@ -147,7 +147,7 @@ The command follows this format: `/changelog <version> <type> <message>`
1. Read the `CHANGELOG.md` file.
2. Find the section for the specified `<version>`.
3. Add the `<message>` under the correct `<type>` heading.
4. If the version or type section doesn\'t exist, create it.
4. If the version or type section doesn't exist, create it.
5. Adhere strictly to the "Keep a Changelog" format.
"""
```
@@ -241,7 +241,7 @@ operate on specific files.
**Example (`review.toml`):**
This command injects the content of a _fixed_ best practices file
(`docs/best-practices.md`) and uses the user\'s arguments to provide context for
(`docs/best-practices.md`) and uses the user's arguments to provide context for
the review.
```toml
@@ -293,7 +293,7 @@ practice.
description = "Asks the model to refactor the current context into a pure function."
prompt = """
Please analyze the code I\'ve provided in the current context.
Please analyze the code I've provided in the current context.
Refactor it into a pure function.
Your response should include:
+4 -2
View File
@@ -25,10 +25,12 @@ overview of Gemini CLI, see the [main documentation page](../index.md).
- **[Checkpointing](./checkpointing.md):** Automatically save and restore
snapshots of your session and files.
- **[Enterprise configuration](./enterprise.md):** Deploying and manage Gemini
CLI in an enterprise environment.
- **[Enterprise configuration](./enterprise.md):** Deploy and manage Gemini CLI
in an enterprise environment.
- **[Sandboxing](./sandbox.md):** Isolate tool execution in a secure,
containerized environment.
- **[Agent Skills](./skills.md):** (Experimental) Extend the CLI with
specialized expertise and procedural workflows.
- **[Telemetry](./telemetry.md):** Configure observability to monitor usage and
performance.
- **[Token caching](./token-caching.md):** Optimize API costs by caching tokens.
+25 -40
View File
@@ -8,10 +8,12 @@ available combinations.
#### Basic Controls
| Action | Keys |
| -------------------------------------------- | ------- |
| Confirm the current selection or choice. | `Enter` |
| Dismiss dialogs or cancel the current focus. | `Esc` |
| Action | Keys |
| --------------------------------------------------------------- | ---------- |
| Confirm the current selection or choice. | `Enter` |
| Dismiss dialogs or cancel the current focus. | `Esc` |
| Cancel the current request or quit the CLI when input is empty. | `Ctrl + C` |
| Exit the CLI when the input buffer is empty. | `Ctrl + D` |
#### Cursor Movement
@@ -40,12 +42,6 @@ available combinations.
| Undo the most recent text edit. | `Ctrl + Z (no Shift)` |
| Redo the most recent undone text edit. | `Ctrl + Shift + Z` |
#### Screen Control
| Action | Keys |
| -------------------------------------------- | ---------- |
| Clear the terminal screen and redraw the UI. | `Ctrl + L` |
#### Scrolling
| Action | Keys |
@@ -88,39 +84,28 @@ available combinations.
#### Text Input
| Action | Keys |
| ------------------------------------ | ---------------------------------------------------------------------- |
| Submit the current prompt. | `Enter (no Ctrl, no Shift, no Cmd)` |
| Insert a newline without submitting. | `Ctrl + Enter`<br />`Cmd + Enter`<br />`Shift + Enter`<br />`Ctrl + J` |
#### External Tools
| Action | Keys |
| ---------------------------------------------- | ------------------------- |
| Open the current prompt in an external editor. | `Ctrl + X` |
| Paste from the clipboard. | `Ctrl + V`<br />`Cmd + V` |
| Action | Keys |
| ---------------------------------------------- | ---------------------------------------------------------------------- |
| Submit the current prompt. | `Enter (no Ctrl, no Shift, no Cmd)` |
| Insert a newline without submitting. | `Ctrl + Enter`<br />`Cmd + Enter`<br />`Shift + Enter`<br />`Ctrl + J` |
| Open the current prompt in an external editor. | `Ctrl + X` |
| Paste from the clipboard. | `Ctrl + V`<br />`Cmd + V` |
#### App Controls
| Action | Keys |
| ----------------------------------------------------------------- | ---------------- |
| Toggle detailed error information. | `F12` |
| Toggle the full TODO list. | `Ctrl + T` |
| Show IDE context details. | `Ctrl + G` |
| Toggle Markdown rendering. | `Cmd + M` |
| Toggle copy mode when the terminal is using the alternate buffer. | `Ctrl + S` |
| Toggle YOLO (auto-approval) mode for tool calls. | `Ctrl + Y` |
| Toggle Auto Edit (auto-accept edits) mode. | `Shift + Tab` |
| Expand a height-constrained response to show additional lines. | `Ctrl + S` |
| Focus the shell input from the gemini input. | `Tab (no Shift)` |
| Focus the Gemini input from the shell input. | `Tab` |
#### Session Control
| Action | Keys |
| -------------------------------------------- | ---------- |
| Cancel the current request or quit the CLI. | `Ctrl + C` |
| Exit the CLI when the input buffer is empty. | `Ctrl + D` |
| Action | Keys |
| ------------------------------------------------------------------------------------------------ | ---------------- |
| Toggle detailed error information. | `F12` |
| Toggle the full TODO list. | `Ctrl + T` |
| Show IDE context details. | `Ctrl + G` |
| Toggle Markdown rendering. | `Cmd + M` |
| Toggle copy mode when in alternate buffer mode. | `Ctrl + S` |
| Toggle YOLO (auto-approval) mode for tool calls. | `Ctrl + Y` |
| Toggle Auto Edit (auto-accept edits) mode. | `Shift + Tab` |
| Expand a height-constrained response to show additional lines when not in alternate buffer mode. | `Ctrl + S` |
| Focus the shell input from the gemini input. | `Tab (no Shift)` |
| Focus the Gemini input from the shell input. | `Tab` |
| Clear the terminal screen and redraw the UI. | `Ctrl + L` |
<!-- KEYBINDINGS-AUTOGEN:END -->
+1 -1
View File
@@ -11,7 +11,7 @@ health and automatically routes requests to available models based on defined
policies.
1. **Model failure:** If the currently selected model fails (e.g., due to quota
or server errors), the CLI will iniate the fallback process.
or server errors), the CLI will initiate the fallback process.
2. **User consent:** Depending on the failure and the model's policy, the CLI
may prompt you to switch to a fallback model (by default always prompts
+1 -1
View File
@@ -11,7 +11,7 @@ Before using sandboxing, you need to install and set up the Gemini CLI:
npm install -g @google/gemini-cli
```
To verify the installation
To verify the installation:
```bash
gemini --version
+3 -3
View File
@@ -224,11 +224,11 @@ visualize your telemetry.
This dashboard can be found under **Google Cloud Monitoring Dashboard
Templates** as "**Gemini CLI Monitoring**".
![Gemini CLI Monitoring Dashboard Overview](../assets/monitoring-dashboard-overview.png)
![Gemini CLI Monitoring Dashboard Overview](/docs/assets/monitoring-dashboard-overview.png)
![Gemini CLI Monitoring Dashboard Metrics](../assets/monitoring-dashboard-metrics.png)
![Gemini CLI Monitoring Dashboard Metrics](/docs/assets/monitoring-dashboard-metrics.png)
![Gemini CLI Monitoring Dashboard Logs](../assets/monitoring-dashboard-logs.png)
![Gemini CLI Monitoring Dashboard Logs](/docs/assets/monitoring-dashboard-logs.png)
To learn more, check out this blog post:
[Instant insights: Gemini CLIs new pre-configured monitoring dashboards](https://cloud.google.com/blog/topics/developers-practitioners/instant-insights-gemini-clis-new-pre-configured-monitoring-dashboards/).
+39 -2
View File
@@ -222,9 +222,45 @@ need this for extensions built to expose commands and prompts.
Restart the CLI again. The model will now have the context from your `GEMINI.md`
file in every session where the extension is active.
## Step 6: Releasing your extension
## (Optional) Step 6: Add an Agent Skill
Once you are happy with your extension, you can share it with others. The two
_Note: This is an experimental feature enabled via `experimental.skills`._
[Agent Skills](../cli/skills.md) let you bundle specialized expertise and
procedural workflows. Unlike `GEMINI.md`, which provides persistent context,
skills are activated only when needed, saving context tokens.
1. Create a `skills` directory and a subdirectory for your skill:
```bash
mkdir -p skills/security-audit
```
2. Create a `skills/security-audit/SKILL.md` file:
```markdown
---
name: security-audit
description:
Expertise in auditing code for security vulnerabilities. Use when the user
asks to "check for security issues" or "audit" their changes.
---
# Security Auditor
You are an expert security researcher. When auditing code:
1. Look for common vulnerabilities (OWASP Top 10).
2. Check for hardcoded secrets or API keys.
3. Suggest remediation steps for any findings.
```
Skills bundled with your extension are automatically discovered and can be
activated by the model during a session when it identifies a relevant task.
## Step 7: Release your extension
Once you're happy with your extension, you can share it with others. The two
primary ways of releasing extensions are via a Git repository or through GitHub
Releases. Using a public Git repository is the simplest method.
@@ -239,6 +275,7 @@ You've successfully created a Gemini CLI extension! You learned how to:
- Add custom tools with an MCP server.
- Create convenient custom commands.
- Provide persistent context to the model.
- Bundle specialized Agent Skills.
- Link your extension for local development.
From here, you can explore more advanced features and build powerful new
+38 -4
View File
@@ -2,10 +2,10 @@
_This documentation is up-to-date with the v0.4.0 release._
Gemini CLI extensions package prompts, MCP servers, and custom commands into a
familiar and user-friendly format. With extensions, you can expand the
capabilities of Gemini CLI and share those capabilities with others. They are
designed to be easily installable and shareable.
Gemini CLI extensions package prompts, MCP servers, Agent Skills, and custom
commands into a familiar and user-friendly format. With extensions, you can
expand the capabilities of Gemini CLI and share those capabilities with others.
They're designed to be easily installable and shareable.
To see examples of extensions, you can browse a gallery of
[Gemini CLI extensions](https://geminicli.com/extensions/browse/).
@@ -263,6 +263,40 @@ Would provide these commands:
- `/deploy` - Shows as `[gcp] Custom command from deploy.toml` in help
- `/gcs:sync` - Shows as `[gcp] Custom command from sync.toml` in help
### Agent Skills
_Note: This is an experimental feature enabled via `experimental.skills`._
Extensions can bundle [Agent Skills](../cli/skills.md) to provide on-demand
expertise and specialized workflows. To include skills in your extension, place
them in a `skills/` subdirectory within the extension directory. Each skill must
follow the [Agent Skills structure](../cli/skills.md#folder-structure),
including a `SKILL.md` file.
**Example**
An extension named `security-toolkit` with the following structure:
```
.gemini/extensions/security-toolkit/
├── gemini-extension.json
└── skills/
├── audit/
│ ├── SKILL.md
│ └── scripts/
│ └── scan.py
└── hardening/
└── SKILL.md
```
Upon installation, these skills will be discovered by Gemini CLI and can be
activated during a session when the model identifies a task matching their
descriptions.
Extension skills have the lowest precedence and will be overridden by user or
workspace skills of the same name. They can be viewed and managed (enabled or
disabled) using the [`/skills` command](../cli/skills.md#managing-skills).
### Hooks
Extensions can provide [hooks](../hooks/index.md) to intercept and customize
+4
View File
@@ -1346,6 +1346,10 @@ for that specific session.
- `auto_edit`: Automatically approve edit tools (replace, write_file) while
prompting for others
- `yolo`: Automatically approve all tool calls (equivalent to `--yolo`)
- `plan`: Read-only mode for tool calls (requires experimental planning to
be enabled).
> **Note:** This mode is currently under development and not yet fully
> functional.
- Cannot be used together with `--yolo`. Use `--approval-mode=yolo` instead of
`--yolo` for the new unified approach.
- Example: `gemini --approval-mode auto_edit`
+2
View File
@@ -56,6 +56,8 @@ This documentation is organized into the following sections:
commands with `/model`.
- **[Sandbox](./cli/sandbox.md):** Isolate tool execution in a secure,
containerized environment.
- **[Agent Skills](./cli/skills.md):** (Experimental) Extend the CLI with
specialized expertise and procedural workflows.
- **[Settings](./cli/settings.md):** Configure various aspects of the CLI's
behavior and appearance with `/settings`.
- **[Telemetry](./cli/telemetry.md):** Overview of telemetry in the CLI.
+1 -1
View File
@@ -10,7 +10,7 @@ debug your code by instrumenting interesting events like model calls, tool
scheduler, tool calls, etc.
Dev traces are verbose and are specifically meant for understanding agent
behaviour and debugging issues. They are disabled by default.
behavior and debugging issues. They are disabled by default.
To enable dev traces, set the `GEMINI_DEV_TRACING=true` environment variable
when running Gemini CLI.
+3
View File
@@ -91,5 +91,8 @@ Additionally, these tools incorporate:
- **[MCP servers](./mcp-server.md)**: MCP servers act as a bridge between the
Gemini model and your local environment or other services like APIs.
- **[Agent Skills](../cli/skills.md)**: (Experimental) On-demand expertise
packages that are activated via the `activate_skill` tool to provide
specialized guidance and resources.
- **[Sandboxing](../cli/sandbox.md)**: Sandboxing isolates the model and its
changes from your environment to reduce potential risk.
+12 -1
View File
@@ -28,6 +28,16 @@ topics on:
- **Organizational Users:** Contact your Google Cloud administrator to be
added to your organization's Gemini Code Assist subscription.
- **Error:
`Failed to login. Message: Your current account is not eligible... because it is not currently available in your location.`**
- **Cause:** Gemini CLI does not currently support your location. For a full
list of supported locations, see the following pages:
- Gemini Code Assist for individuals:
[Available locations](https://developers.google.com/gemini-code-assist/resources/available-locations#americas)
- Google AI Pro and Ultra where Gemini Code Assist (and Gemini CLI) is also
available:
[Available locations](https://developers.google.com/gemini-code-assist/resources/locations-pro-ultra)
- **Error: `Failed to login. Message: Request contains an invalid argument`**
- **Cause:** Users with Google Workspace accounts or Google Cloud accounts
associated with their Gmail accounts may not be able to activate the free
@@ -137,7 +147,8 @@ This is especially useful for scripting and automation.
- **Core debugging:**
- Check the server console output for error messages or stack traces.
- Increase log verbosity if configurable.
- Increase log verbosity if configurable. For example, set the `DEBUG_MODE`
environment variable to `true` or `1`.
- Use Node.js debugging tools (e.g., `node --inspect`) if you need to step
through server-side code.
+7
View File
@@ -88,6 +88,13 @@ describe('my_feature', () => {
## Running Evaluations
First, build the bundled Gemini CLI. You must do this after every code change.
```bash
npm run build
npm run bundle
```
### Always Passing Evals
To run the evaluations that are expected to always pass (CI safe):
+64
View File
@@ -0,0 +1,64 @@
/**
* @license
* Copyright 2026 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import { describe } from 'vitest';
import { evalTest } from './test-helper.js';
const AGENT_DEFINITION = `---
name: docs-agent
description: An agent with expertise in updating documentation.
tools:
- read_file
- write_file
---
You are the docs agent. Update the documentation.
`;
const INDEX_TS = 'export const add = (a: number, b: number) => a + b;';
describe('subagent eval test cases', () => {
/**
* Checks whether the outer agent reliably utilizes an expert subagent to
* accomplish a task when one is available.
*
* Note that the test is intentionally crafted to avoid the word "document"
* or "docs". We want to see the outer agent make the connection even when
* the prompt indirectly implies need of expertise.
*
* This tests the system prompt's subagent specific clauses.
*/
evalTest('ALWAYS_PASSES', {
name: 'should delegate to user provided agent with relevant expertise',
params: {
settings: {
experimental: {
enableAgents: true,
},
},
},
prompt: 'Please update README.md with a description of this library.',
files: {
'.gemini/agents/test-agent.md': AGENT_DEFINITION,
'index.ts': INDEX_TS,
'README.md': 'TODO: update the README.',
},
assert: async (rig, _result) => {
await rig.expectToolCallSuccess(
['delegate_to_agent'],
undefined,
(args) => {
try {
const parsed = JSON.parse(args);
return parsed.agent_name === 'docs-agent';
} catch {
return false;
}
},
);
},
});
});
+31 -2
View File
@@ -6,7 +6,10 @@
import { it } from 'vitest';
import fs from 'node:fs';
import path from 'node:path';
import { execSync } from 'node:child_process';
import { TestRig } from '@google/gemini-cli-test-utils';
import { createUnauthorizedToolError } from '@google/gemini-cli-core';
export * from '@google/gemini-cli-test-utils';
@@ -32,8 +35,33 @@ export function evalTest(policy: EvalPolicy, evalCase: EvalCase) {
const fn = async () => {
const rig = new TestRig();
try {
await rig.setup(evalCase.name, evalCase.params);
rig.setup(evalCase.name, evalCase.params);
if (evalCase.files) {
for (const [filePath, content] of Object.entries(evalCase.files)) {
const fullPath = path.join(rig.testDir!, filePath);
fs.mkdirSync(path.dirname(fullPath), { recursive: true });
fs.writeFileSync(fullPath, content);
}
const execOptions = { cwd: rig.testDir!, stdio: 'inherit' as const };
execSync('git init', execOptions);
execSync('git config user.email "test@example.com"', execOptions);
execSync('git config user.name "Test User"', execOptions);
execSync('git add .', execOptions);
execSync('git commit --allow-empty -m "Initial commit"', execOptions);
}
const result = await rig.run({ args: evalCase.prompt });
const unauthorizedErrorPrefix =
createUnauthorizedToolError('').split("'")[0];
if (result.includes(unauthorizedErrorPrefix)) {
throw new Error(
'Test failed due to unauthorized tool call in output: ' + result,
);
}
await evalCase.assert(rig, result);
} finally {
await logToFile(
@@ -44,7 +72,7 @@ export function evalTest(policy: EvalPolicy, evalCase: EvalCase) {
}
};
if (policy === 'USUALLY_PASSES' && !process.env.RUN_EVALS) {
if (policy === 'USUALLY_PASSES' && !process.env['RUN_EVALS']) {
it.skip(evalCase.name, fn);
} else {
it(evalCase.name, fn);
@@ -55,6 +83,7 @@ export interface EvalCase {
name: string;
params?: Record<string, any>;
prompt: string;
files?: Record<string, string>;
assert: (rig: TestRig, result: string) => Promise<void>;
}
+1 -1
View File
@@ -164,7 +164,7 @@ rpc.send({
});
`;
describe('simple-mcp-server', () => {
describe.skip('simple-mcp-server', () => {
let rig: TestRig;
beforeEach(() => {
+34 -22
View File
@@ -1,12 +1,12 @@
{
"name": "@google/gemini-cli",
"version": "0.26.0-nightly.20260114.bb6c57414",
"version": "0.26.0-nightly.20260115.6cb3ae4e0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "@google/gemini-cli",
"version": "0.26.0-nightly.20260114.bb6c57414",
"version": "0.26.0-nightly.20260115.6cb3ae4e0",
"workspaces": [
"packages/*"
],
@@ -2474,6 +2474,7 @@
"integrity": "sha512-t54CUOsFMappY1Jbzb7fetWeO0n6K0k/4+/ZpkS+3Joz8I4VcvY9OiEBFRYISqaI2fq5sCiPtAjRDOzVYG8m+Q==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@octokit/auth-token": "^6.0.0",
"@octokit/graphql": "^9.0.2",
@@ -2654,6 +2655,7 @@
"resolved": "https://registry.npmjs.org/@opentelemetry/api/-/api-1.9.0.tgz",
"integrity": "sha512-3giAOQvZiH5F9bMlMiv8+GSPMeqg0dbaeo58/0SlA9sxSqZhnUtxzX9/2FzyhS9sWQf5S0GJE0AKBrFqjpeYcg==",
"license": "Apache-2.0",
"peer": true,
"engines": {
"node": ">=8.0.0"
}
@@ -2687,6 +2689,7 @@
"resolved": "https://registry.npmjs.org/@opentelemetry/core/-/core-2.0.1.tgz",
"integrity": "sha512-MaZk9SJIDgo1peKevlbhP6+IwIiNPNmswNL4AF0WaQJLbHXjr9SrZMgS12+iqr9ToV4ZVosCcc0f8Rg67LXjxw==",
"license": "Apache-2.0",
"peer": true,
"dependencies": {
"@opentelemetry/semantic-conventions": "^1.29.0"
},
@@ -3055,6 +3058,7 @@
"resolved": "https://registry.npmjs.org/@opentelemetry/resources/-/resources-2.0.1.tgz",
"integrity": "sha512-dZOB3R6zvBwDKnHDTB4X1xtMArB/d324VsbiPkX/Yu0Q8T2xceRthoIVFhJdvgVM2QhGVUyX9tzwiNxGtoBJUw==",
"license": "Apache-2.0",
"peer": true,
"dependencies": {
"@opentelemetry/core": "2.0.1",
"@opentelemetry/semantic-conventions": "^1.29.0"
@@ -3088,6 +3092,7 @@
"resolved": "https://registry.npmjs.org/@opentelemetry/sdk-metrics/-/sdk-metrics-2.0.1.tgz",
"integrity": "sha512-wf8OaJoSnujMAHWR3g+/hGvNcsC16rf9s1So4JlMiFaFHiE4HpIA3oUh+uWZQ7CNuK8gVW/pQSkgoa5HkkOl0g==",
"license": "Apache-2.0",
"peer": true,
"dependencies": {
"@opentelemetry/core": "2.0.1",
"@opentelemetry/resources": "2.0.1"
@@ -3140,6 +3145,7 @@
"resolved": "https://registry.npmjs.org/@opentelemetry/sdk-trace-base/-/sdk-trace-base-2.0.1.tgz",
"integrity": "sha512-xYLlvk/xdScGx1aEqvxLwf6sXQLXCjk3/1SQT9X9AoN5rXRhkdvIFShuNNmtTEPRBqcsMbS4p/gJLNI2wXaDuQ==",
"license": "Apache-2.0",
"peer": true,
"dependencies": {
"@opentelemetry/core": "2.0.1",
"@opentelemetry/resources": "2.0.1",
@@ -4352,6 +4358,7 @@
"integrity": "sha512-6mDvHUFSjyT2B2yeNx2nUgMxh9LtOWvkhIU3uePn2I2oyNymUAX1NIsdgviM4CH+JSrp2D2hsMvJOkxY+0wNRA==",
"devOptional": true,
"license": "MIT",
"peer": true,
"dependencies": {
"csstype": "^3.0.2"
}
@@ -4629,6 +4636,7 @@
"integrity": "sha512-6sMvZePQrnZH2/cJkwRpkT7DxoAWh+g6+GFRK6bV3YQo7ogi3SX5rgF6099r5Q53Ma5qeT7LGmOmuIutF4t3lA==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@typescript-eslint/scope-manager": "8.35.0",
"@typescript-eslint/types": "8.35.0",
@@ -5633,6 +5641,7 @@
"resolved": "https://registry.npmjs.org/acorn/-/acorn-8.15.0.tgz",
"integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==",
"license": "MIT",
"peer": true,
"bin": {
"acorn": "bin/acorn"
},
@@ -6077,8 +6086,7 @@
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/array-flatten/-/array-flatten-1.1.1.tgz",
"integrity": "sha512-PCVAQswWemu6UdxsDFFX/+gVeYqKAod3D3UVm91jHwynguOwAvYPhx8nNlM++NqRcK6CxxpUafjmhIdKiHibqg==",
"license": "MIT",
"peer": true
"license": "MIT"
},
"node_modules/array-includes": {
"version": "3.1.9",
@@ -7362,7 +7370,6 @@
"resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-0.5.4.tgz",
"integrity": "sha512-FveZTNuGw04cxlAiWbzi6zTAL/lhehaWbTtgluJh4/E95DqMwTmha3KZN1aAWA8cFIhHzMZUvLevkw5Rqk+tSQ==",
"license": "MIT",
"peer": true,
"dependencies": {
"safe-buffer": "5.2.1"
},
@@ -8682,6 +8689,7 @@
"integrity": "sha512-GsGizj2Y1rCWDu6XoEekL3RLilp0voSePurjZIkxL3wlm5o5EC9VpgaP7lrCvjnkuLvzFBQWB3vWB3K5KQTveQ==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@eslint-community/eslint-utils": "^4.2.0",
"@eslint-community/regexpp": "^4.12.1",
@@ -9284,7 +9292,6 @@
"resolved": "https://registry.npmjs.org/cookie/-/cookie-0.7.1.tgz",
"integrity": "sha512-6DnInpx7SJ2AK3+CTUE/ZM0vWTUboZCegxhC2xiIydHR9jNuTAASBrfEpHhiGOZw/nX51bHt6YQl8jsGo4y/0w==",
"license": "MIT",
"peer": true,
"engines": {
"node": ">= 0.6"
}
@@ -9294,7 +9301,6 @@
"resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz",
"integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==",
"license": "MIT",
"peer": true,
"dependencies": {
"ms": "2.0.0"
}
@@ -9304,7 +9310,6 @@
"resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.1.tgz",
"integrity": "sha512-RwNA9Z/7PrK06rYLIzFMlaF+l73iwpzsqRIFgbMLbTcLD6cOao82TaWefPXQvB2fOC4AjuYSEndS7N/mTCbkdQ==",
"license": "MIT",
"peer": true,
"engines": {
"node": ">= 0.8"
}
@@ -9558,7 +9563,6 @@
"resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-1.3.1.tgz",
"integrity": "sha512-6BN9trH7bp3qvnrRyzsBz+g3lZxTNZTbVO2EV1CS0WIcDbawYVdYvGflME/9QP0h0pYlCDBCTjYa9nZzMDpyxQ==",
"license": "MIT",
"peer": true,
"dependencies": {
"debug": "2.6.9",
"encodeurl": "~2.0.0",
@@ -9577,7 +9581,6 @@
"resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz",
"integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==",
"license": "MIT",
"peer": true,
"dependencies": {
"ms": "2.0.0"
}
@@ -9586,15 +9589,13 @@
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz",
"integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==",
"license": "MIT",
"peer": true
"license": "MIT"
},
"node_modules/finalhandler/node_modules/statuses": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.1.tgz",
"integrity": "sha512-RwNA9Z/7PrK06rYLIzFMlaF+l73iwpzsqRIFgbMLbTcLD6cOao82TaWefPXQvB2fOC4AjuYSEndS7N/mTCbkdQ==",
"license": "MIT",
"peer": true,
"engines": {
"node": ">= 0.8"
}
@@ -10877,6 +10878,7 @@
"resolved": "https://registry.npmjs.org/@jrichman/ink/-/ink-6.4.7.tgz",
"integrity": "sha512-QHyxhNF5VonF5cRmdAJD/UPucB9nRx3FozWMjQrDGfBxfAL9lpyu72/MlFPgloS1TMTGsOt7YN6dTPPA6mh0Aw==",
"license": "MIT",
"peer": true,
"dependencies": {
"@alcalzone/ansi-tokenize": "^0.2.1",
"ansi-escapes": "^7.0.0",
@@ -14061,8 +14063,7 @@
"version": "0.1.12",
"resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-0.1.12.tgz",
"integrity": "sha512-RA1GjUVMnvYFxuqovrEqZoxxW5NUZqbwKtYz/Tt7nXerk0LbLblQmrsgdeOxV5SFHf0UDggjS/bSeOZwt1pmEQ==",
"license": "MIT",
"peer": true
"license": "MIT"
},
"node_modules/path-type": {
"version": "3.0.0",
@@ -14639,6 +14640,7 @@
"resolved": "https://registry.npmjs.org/react/-/react-19.2.0.tgz",
"integrity": "sha512-tmbWg6W31tQLeB5cdIBOicJDJRR2KzXsV7uSK9iNfLWQ5bIZfxuPEHp7M8wiHyHnn0DD1i7w3Zmin0FtkrwoCQ==",
"license": "MIT",
"peer": true,
"engines": {
"node": ">=0.10.0"
}
@@ -14649,6 +14651,7 @@
"integrity": "sha512-ePrwPfxAnB+7hgnEr8vpKxL9cmnp7F322t8oqcPshbIQQhDKgFDW4tjhF2wjVbdXF9O/nyuy3sQWd9JGpiLPvA==",
"devOptional": true,
"license": "MIT",
"peer": true,
"dependencies": {
"shell-quote": "^1.6.1",
"ws": "^7"
@@ -16908,6 +16911,7 @@
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz",
"integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
"license": "MIT",
"peer": true,
"engines": {
"node": ">=12"
},
@@ -17131,7 +17135,8 @@
"resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz",
"integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==",
"dev": true,
"license": "0BSD"
"license": "0BSD",
"peer": true
},
"node_modules/tsx": {
"version": "4.20.3",
@@ -17139,6 +17144,7 @@
"integrity": "sha512-qjbnuR9Tr+FJOMBqJCW5ehvIo/buZq7vH7qD7JziU98h6l3qGy0a/yPFjwO+y0/T7GFpNgNAvEcPPVfyT8rrPQ==",
"devOptional": true,
"license": "MIT",
"peer": true,
"dependencies": {
"esbuild": "~0.25.0",
"get-tsconfig": "^4.7.5"
@@ -17322,6 +17328,7 @@
"integrity": "sha512-p1diW6TqL9L07nNxvRMM7hMMw4c5XOo/1ibL4aAIGmSAt9slTE1Xgw5KWuof2uTOvCg9BY7ZRi+GaF+7sfgPeQ==",
"devOptional": true,
"license": "Apache-2.0",
"peer": true,
"bin": {
"tsc": "bin/tsc",
"tsserver": "bin/tsserver"
@@ -17484,7 +17491,6 @@
"resolved": "https://registry.npmjs.org/utils-merge/-/utils-merge-1.0.1.tgz",
"integrity": "sha512-pMZTvIkT1d+TFGvDOqodOclx0QWkkgi6Tdoa8gC8ffGAAqz9pzPTZWAybbsHHoED/ztMtkv/VoYTYyShUn81hA==",
"license": "MIT",
"peer": true,
"engines": {
"node": ">= 0.4.0"
}
@@ -17539,6 +17545,7 @@
"resolved": "https://registry.npmjs.org/vite/-/vite-7.2.2.tgz",
"integrity": "sha512-BxAKBWmIbrDgrokdGZH1IgkIk/5mMHDreLDmCJ0qpyJaAteP8NvMhkwr/ZCQNqNH97bw/dANTE9PDzqwJghfMQ==",
"license": "MIT",
"peer": true,
"dependencies": {
"esbuild": "^0.25.0",
"fdir": "^6.5.0",
@@ -17652,6 +17659,7 @@
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz",
"integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
"license": "MIT",
"peer": true,
"engines": {
"node": ">=12"
},
@@ -17664,6 +17672,7 @@
"resolved": "https://registry.npmjs.org/vitest/-/vitest-3.2.4.tgz",
"integrity": "sha512-LUCP5ev3GURDysTWiP47wRRUpLKMOfPh+yKTx3kVIEiu5KOMeqzpnYNsKyOoVrULivR8tLcks4+lga33Whn90A==",
"license": "MIT",
"peer": true,
"dependencies": {
"@types/chai": "^5.2.2",
"@vitest/expect": "3.2.4",
@@ -18368,6 +18377,7 @@
"resolved": "https://registry.npmjs.org/zod/-/zod-3.25.76.tgz",
"integrity": "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==",
"license": "MIT",
"peer": true,
"funding": {
"url": "https://github.com/sponsors/colinhacks"
}
@@ -18383,7 +18393,7 @@
},
"packages/a2a-server": {
"name": "@google/gemini-cli-a2a-server",
"version": "0.26.0-nightly.20260114.bb6c57414",
"version": "0.26.0-nightly.20260115.6cb3ae4e0",
"dependencies": {
"@a2a-js/sdk": "^0.3.7",
"@google-cloud/storage": "^7.16.0",
@@ -18693,7 +18703,7 @@
},
"packages/cli": {
"name": "@google/gemini-cli",
"version": "0.26.0-nightly.20260114.bb6c57414",
"version": "0.26.0-nightly.20260115.6cb3ae4e0",
"license": "Apache-2.0",
"dependencies": {
"@agentclientprotocol/sdk": "^0.12.0",
@@ -18704,6 +18714,7 @@
"@types/update-notifier": "^6.0.8",
"ansi-regex": "^6.2.2",
"clipboardy": "^5.0.0",
"color-convert": "^2.0.1",
"command-exists": "^1.2.9",
"comment-json": "^4.2.5",
"diff": "^7.0.0",
@@ -18796,7 +18807,7 @@
},
"packages/core": {
"name": "@google/gemini-cli-core",
"version": "0.26.0-nightly.20260114.bb6c57414",
"version": "0.26.0-nightly.20260115.6cb3ae4e0",
"license": "Apache-2.0",
"dependencies": {
"@a2a-js/sdk": "^0.3.7",
@@ -18933,6 +18944,7 @@
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz",
"integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
"license": "MIT",
"peer": true,
"engines": {
"node": ">=12"
},
@@ -18955,7 +18967,7 @@
},
"packages/test-utils": {
"name": "@google/gemini-cli-test-utils",
"version": "0.26.0-nightly.20260114.bb6c57414",
"version": "0.26.0-nightly.20260115.6cb3ae4e0",
"license": "Apache-2.0",
"dependencies": {
"@google/gemini-cli-core": "file:../core",
@@ -18972,7 +18984,7 @@
},
"packages/vscode-ide-companion": {
"name": "gemini-cli-vscode-ide-companion",
"version": "0.26.0-nightly.20260114.bb6c57414",
"version": "0.26.0-nightly.20260115.6cb3ae4e0",
"license": "LICENSE",
"dependencies": {
"@modelcontextprotocol/sdk": "^1.23.0",
+2 -2
View File
@@ -1,6 +1,6 @@
{
"name": "@google/gemini-cli",
"version": "0.26.0-nightly.20260114.bb6c57414",
"version": "0.26.0-nightly.20260115.6cb3ae4e0",
"engines": {
"node": ">=20.0.0"
},
@@ -14,7 +14,7 @@
"url": "git+https://github.com/google-gemini/gemini-cli.git"
},
"config": {
"sandboxImageUri": "us-docker.pkg.dev/gemini-code-dev/gemini-cli/sandbox:0.26.0-nightly.20260114.bb6c57414"
"sandboxImageUri": "us-docker.pkg.dev/gemini-code-dev/gemini-cli/sandbox:0.26.0-nightly.20260115.6cb3ae4e0"
},
"scripts": {
"start": "cross-env NODE_ENV=development node scripts/start.js",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@google/gemini-cli-a2a-server",
"version": "0.26.0-nightly.20260114.bb6c57414",
"version": "0.26.0-nightly.20260115.6cb3ae4e0",
"description": "Gemini CLI A2A Server",
"repository": {
"type": "git",
+41 -2
View File
@@ -14,7 +14,7 @@ import type {
TaskStatusUpdateEvent,
SendStreamingMessageSuccessResponse,
} from '@a2a-js/sdk';
import type express from 'express';
import express from 'express';
import type { Server } from 'node:http';
import request from 'supertest';
import {
@@ -27,7 +27,7 @@ import {
it,
vi,
} from 'vitest';
import { createApp } from './app.js';
import { createApp, main } from './app.js';
import { commandRegistry } from '../commands/command-registry.js';
import {
assertUniqueFinalEventIsLast,
@@ -1176,4 +1176,43 @@ describe('E2E Tests', () => {
});
});
});
describe('main', () => {
it('should listen on localhost only', async () => {
const listenSpy = vi
.spyOn(express.application, 'listen')
.mockImplementation((...args: unknown[]) => {
// Trigger the callback passed to listen
const callback = args.find(
(arg): arg is () => void => typeof arg === 'function',
);
if (callback) {
callback();
}
return {
address: () => ({ port: 1234 }),
on: vi.fn(),
once: vi.fn(),
emit: vi.fn(),
} as unknown as Server;
});
// Avoid process.exit if possible, or mock it if main might fail
const exitSpy = vi
.spyOn(process, 'exit')
.mockImplementation(() => undefined as never);
await main();
expect(listenSpy).toHaveBeenCalledWith(
expect.any(Number),
'localhost',
expect.any(Function),
);
listenSpy.mockRestore();
exitSpy.mockRestore();
});
});
});
+2 -2
View File
@@ -326,9 +326,9 @@ export async function createApp() {
export async function main() {
try {
const expressApp = await createApp();
const port = process.env['CODER_AGENT_PORT'] || 0;
const port = Number(process.env['CODER_AGENT_PORT'] || 0);
const server = expressApp.listen(port, () => {
const server = expressApp.listen(port, 'localhost', () => {
const address = server.address();
let actualPort;
if (process.env['CODER_AGENT_PORT']) {
+3 -2
View File
@@ -1,6 +1,6 @@
{
"name": "@google/gemini-cli",
"version": "0.26.0-nightly.20260114.bb6c57414",
"version": "0.26.0-nightly.20260115.6cb3ae4e0",
"description": "Gemini CLI",
"license": "Apache-2.0",
"repository": {
@@ -26,7 +26,7 @@
"dist"
],
"config": {
"sandboxImageUri": "us-docker.pkg.dev/gemini-code-dev/gemini-cli/sandbox:0.26.0-nightly.20260114.bb6c57414"
"sandboxImageUri": "us-docker.pkg.dev/gemini-code-dev/gemini-cli/sandbox:0.26.0-nightly.20260115.6cb3ae4e0"
},
"dependencies": {
"@agentclientprotocol/sdk": "^0.12.0",
@@ -37,6 +37,7 @@
"@types/update-notifier": "^6.0.8",
"ansi-regex": "^6.2.2",
"clipboardy": "^5.0.0",
"color-convert": "^2.0.1",
"command-exists": "^1.2.9",
"comment-json": "^4.2.5",
"diff": "^7.0.0",
+1 -2
View File
@@ -230,8 +230,7 @@ export async function handleMigrateFromClaude() {
const settings = loadSettings(workingDir);
// Merge migrated hooks with existing hooks
const existingHooks =
(settings.merged.hooks as Record<string, unknown>) || {};
const existingHooks = settings.merged.hooks as Record<string, unknown>;
const mergedHooks = { ...existingHooks, ...migratedHooks };
// Update settings (setValue automatically saves)
+32 -11
View File
@@ -6,15 +6,20 @@
import { vi, describe, it, expect, beforeEach, type Mock } from 'vitest';
import { listMcpServers } from './list.js';
import { loadSettings } from '../../config/settings.js';
import { loadSettings, mergeSettings } from '../../config/settings.js';
import { createTransport, debugLogger } from '@google/gemini-cli-core';
import { Client } from '@modelcontextprotocol/sdk/client/index.js';
import { ExtensionStorage } from '../../config/extensions/storage.js';
import { ExtensionManager } from '../../config/extension-manager.js';
vi.mock('../../config/settings.js', () => ({
loadSettings: vi.fn(),
}));
vi.mock('../../config/settings.js', async (importOriginal) => {
const actual =
await importOriginal<typeof import('../../config/settings.js')>();
return {
...actual,
loadSettings: vi.fn(),
};
});
vi.mock('../../config/extensions/storage.js', () => ({
ExtensionStorage: {
getUserExtensionsDir: vi.fn(),
@@ -32,11 +37,16 @@ vi.mock('@google/gemini-cli-core', async (importOriginal) => {
CONNECTING: 'CONNECTING',
DISCONNECTED: 'DISCONNECTED',
},
Storage: vi.fn().mockImplementation((_cwd: string) => ({
getGlobalSettingsPath: () => '/tmp/gemini/settings.json',
getWorkspaceSettingsPath: () => '/tmp/gemini/workspace-settings.json',
getProjectTempDir: () => '/test/home/.gemini/tmp/mocked_hash',
})),
Storage: Object.assign(
vi.fn().mockImplementation((_cwd: string) => ({
getGlobalSettingsPath: () => '/tmp/gemini/settings.json',
getWorkspaceSettingsPath: () => '/tmp/gemini/workspace-settings.json',
getProjectTempDir: () => '/test/home/.gemini/tmp/mocked_hash',
})),
{
getGlobalSettingsPath: () => '/tmp/gemini/settings.json',
},
),
GEMINI_DIR: '.gemini',
getErrorMessage: (e: unknown) =>
e instanceof Error ? e.message : String(e),
@@ -96,7 +106,10 @@ describe('mcp list command', () => {
});
it('should display message when no servers configured', async () => {
mockedLoadSettings.mockReturnValue({ merged: { mcpServers: {} } });
const defaultMergedSettings = mergeSettings({}, {}, {}, {}, true);
mockedLoadSettings.mockReturnValue({
merged: { ...defaultMergedSettings, mcpServers: {} },
});
await listMcpServers();
@@ -104,8 +117,10 @@ describe('mcp list command', () => {
});
it('should display different server types with connected status', async () => {
const defaultMergedSettings = mergeSettings({}, {}, {}, {}, true);
mockedLoadSettings.mockReturnValue({
merged: {
...defaultMergedSettings,
mcpServers: {
'stdio-server': { command: '/path/to/server', args: ['arg1'] },
'sse-server': { url: 'https://example.com/sse' },
@@ -138,8 +153,10 @@ describe('mcp list command', () => {
});
it('should display disconnected status when connection fails', async () => {
const defaultMergedSettings = mergeSettings({}, {}, {}, {}, true);
mockedLoadSettings.mockReturnValue({
merged: {
...defaultMergedSettings,
mcpServers: {
'test-server': { command: '/test/server' },
},
@@ -158,9 +175,13 @@ describe('mcp list command', () => {
});
it('should merge extension servers with config servers', async () => {
const defaultMergedSettings = mergeSettings({}, {}, {}, {}, true);
mockedLoadSettings.mockReturnValue({
merged: {
mcpServers: { 'config-server': { command: '/config/server' } },
...defaultMergedSettings,
mcpServers: {
'config-server': { command: '/config/server' },
},
},
});
+2 -3
View File
@@ -35,7 +35,7 @@ async function getMcpServersFromConfig(): Promise<
requestSetting: promptForSetting,
});
const extensions = await extensionManager.loadExtensions();
const mcpServers = { ...(settings.merged.mcpServers || {}) };
const mcpServers = { ...settings.merged.mcpServers };
for (const extension of extensions) {
Object.entries(extension.mcpServers || {}).forEach(([key, server]) => {
if (mcpServers[key]) {
@@ -63,8 +63,7 @@ async function testMCPConnection(
const sanitizationConfig = {
enableEnvironmentVariableRedaction: true,
allowedEnvironmentVariables: [],
blockedEnvironmentVariables:
settings.merged.advanced?.excludedEnvVars || [],
blockedEnvironmentVariables: settings.merged.advanced.excludedEnvVars,
};
let transport;
@@ -22,8 +22,9 @@ import {
Config,
DEFAULT_FILE_FILTERING_OPTIONS,
} from '@google/gemini-cli-core';
import type { Settings } from './settingsSchema.js';
import { createTestMergedSettings } from './settings.js';
import { http, HttpResponse } from 'msw';
import { setupServer } from 'msw/node';
export const server = setupServer();
@@ -212,7 +213,7 @@ describe('Configuration Integration Tests', () => {
const originalArgv = process.argv;
try {
process.argv = argv;
const parsedArgs = await parseArguments({} as Settings);
const parsedArgs = await parseArguments(createTestMergedSettings());
expect(parsedArgs.approvalMode).toBe(expected.approvalMode);
expect(parsedArgs.prompt).toBe(expected.prompt);
expect(parsedArgs.yolo).toBe(expected.yolo);
@@ -235,7 +236,9 @@ describe('Configuration Integration Tests', () => {
const originalArgv = process.argv;
try {
process.argv = argv;
await expect(parseArguments({} as Settings)).rejects.toThrow();
await expect(
parseArguments(createTestMergedSettings()),
).rejects.toThrow();
} finally {
process.argv = originalArgv;
}
File diff suppressed because it is too large Load Diff
+40 -23
View File
@@ -38,8 +38,12 @@ import {
type OutputFormat,
GEMINI_MODEL_ALIAS_AUTO,
} from '@google/gemini-cli-core';
import type { Settings } from './settings.js';
import { saveModelChange, loadSettings } from './settings.js';
import {
type Settings,
type MergedSettings,
saveModelChange,
loadSettings,
} from './settings.js';
import { loadSandboxConfig } from './sandboxConfig.js';
import { resolvePath } from '../utils/resolvePath.js';
@@ -54,7 +58,6 @@ import { requestConsentNonInteractive } from './extensions/consent.js';
import { promptForSetting } from './extensions/extensionSettings.js';
import type { EventEmitter } from 'node:stream';
import { runExitCleanup } from '../utils/cleanup.js';
import { getEnableHooks, getEnableHooksUI } from './settingsSchema.js';
export interface CliArgs {
query: string | undefined;
@@ -82,7 +85,9 @@ export interface CliArgs {
recordResponses: string | undefined;
}
export async function parseArguments(settings: Settings): Promise<CliArgs> {
export async function parseArguments(
settings: MergedSettings,
): Promise<CliArgs> {
const rawArgv = hideBin(process.argv);
const yargsInstance = yargs(rawArgv)
.locale('en')
@@ -138,9 +143,9 @@ export async function parseArguments(settings: Settings): Promise<CliArgs> {
.option('approval-mode', {
type: 'string',
nargs: 1,
choices: ['default', 'auto_edit', 'yolo'],
choices: ['default', 'auto_edit', 'yolo', 'plan'],
description:
'Set the approval mode: default (prompt for approval), auto_edit (auto-approve edit tools), yolo (auto-approve all tools)',
'Set the approval mode: default (prompt for approval), auto_edit (auto-approve edit tools), yolo (auto-approve all tools), plan (read-only mode)',
})
.option('experimental-acp', {
type: 'boolean',
@@ -280,16 +285,16 @@ export async function parseArguments(settings: Settings): Promise<CliArgs> {
return true;
});
if (settings?.experimental?.extensionManagement ?? true) {
if (settings.experimental.extensionManagement) {
yargsInstance.command(extensionsCommand);
}
if (settings?.experimental?.skills ?? false) {
if (settings.experimental.skills) {
yargsInstance.command(skillsCommand);
}
// Register hooks command if hooks are enabled
if (getEnableHooksUI(settings)) {
if (settings.tools.enableHooks) {
yargsInstance.command(hooksCommand);
}
@@ -392,7 +397,7 @@ export interface LoadCliConfigOptions {
}
export async function loadCliConfig(
settings: Settings,
settings: MergedSettings,
sessionId: string,
argv: CliArgs,
options: LoadCliConfigOptions = {},
@@ -487,12 +492,20 @@ export async function loadCliConfig(
case 'auto_edit':
approvalMode = ApprovalMode.AUTO_EDIT;
break;
case 'plan':
if (!(settings.experimental?.plan ?? false)) {
throw new Error(
'Approval mode "plan" is only available when experimental.plan is enabled.',
);
}
approvalMode = ApprovalMode.PLAN;
break;
case 'default':
approvalMode = ApprovalMode.DEFAULT;
break;
default:
throw new Error(
`Invalid approval mode: ${argv.approvalMode}. Valid values are: yolo, auto_edit, default`,
`Invalid approval mode: ${argv.approvalMode}. Valid values are: yolo, auto_edit, plan, default`,
);
}
} else {
@@ -573,6 +586,11 @@ export async function loadCliConfig(
);
switch (approvalMode) {
case ApprovalMode.PLAN:
// In plan non-interactive mode, all tools that require approval are excluded.
// TODO(#16625): Replace this default exclusion logic with specific rules for plan mode.
extraExcludes.push(...defaultExcludes.filter(toolExclusionFilter));
break;
case ApprovalMode.DEFAULT:
// In default non-interactive mode, all tools that require approval are excluded.
extraExcludes.push(...defaultExcludes.filter(toolExclusionFilter));
@@ -590,10 +608,7 @@ export async function loadCliConfig(
}
}
const excludeTools = mergeExcludeTools(
settings,
extraExcludes.length > 0 ? extraExcludes : undefined,
);
const excludeTools = mergeExcludeTools(settings, extraExcludes);
// Create a settings object that includes CLI overrides for policy generation
const effectiveSettings: Settings = {
@@ -742,15 +757,17 @@ export async function loadCliConfig(
disableLLMCorrection: settings.tools?.disableLLMCorrection,
modelConfigServiceConfig: settings.modelConfigs,
// TODO: loading of hooks based on workspace trust
enableHooks: getEnableHooks(settings),
enableHooksUI: getEnableHooksUI(settings),
enableHooks:
(settings.tools?.enableHooks ?? true) &&
(settings.hooks?.enabled ?? false),
enableHooksUI: settings.tools?.enableHooks ?? true,
hooks: settings.hooks || {},
projectHooks: projectHooks || {},
onModelChange: (model: string) => saveModelChange(loadedSettings, model),
onReload: async () => {
const refreshedSettings = loadSettings(cwd);
return {
disabledSkills: refreshedSettings.merged.skills?.disabled,
disabledSkills: refreshedSettings.merged.skills.disabled,
agents: refreshedSettings.merged.agents,
};
},
@@ -758,12 +775,12 @@ export async function loadCliConfig(
}
function mergeExcludeTools(
settings: Settings,
extraExcludes?: string[] | undefined,
settings: MergedSettings,
extraExcludes: string[] = [],
): string[] {
const allExcludeTools = new Set([
...(settings.tools?.exclude || []),
...(extraExcludes || []),
...(settings.tools.exclude || []),
...extraExcludes,
]);
return [...allExcludeTools];
return Array.from(allExcludeTools);
}
@@ -10,7 +10,7 @@ import * as path from 'node:path';
import * as os from 'node:os';
import { ExtensionManager } from './extension-manager.js';
import { debugLogger } from '@google/gemini-cli-core';
import { type Settings } from './settings.js';
import { createTestMergedSettings } from './settings.js';
import { createExtension } from '../test-utils/createExtension.js';
import { EXTENSIONS_DIRECTORY_NAME } from './extensions/variables.js';
@@ -52,10 +52,9 @@ describe('ExtensionManager agents loading', () => {
fs.mkdirSync(extensionsDir, { recursive: true });
extensionManager = new ExtensionManager({
settings: {
settings: createTestMergedSettings({
telemetry: { enabled: false },
trustedFolders: [tempDir],
} as unknown as Settings,
}),
requestConsent: vi.fn().mockResolvedValue(true),
requestSetting: vi.fn(),
workspaceDir: tempDir,
@@ -9,7 +9,7 @@ import * as fs from 'node:fs';
import * as path from 'node:path';
import * as os from 'node:os';
import { ExtensionManager } from './extension-manager.js';
import type { Settings } from './settings.js';
import { createTestMergedSettings } from './settings.js';
import {
loadAgentsFromDirectory,
loadSkillsFromDir,
@@ -105,14 +105,10 @@ describe('ExtensionManager Settings Scope', () => {
workspaceDir: tempWorkspace,
requestConsent: async () => true,
requestSetting: async () => '',
settings: {
telemetry: {
enabled: false,
},
experimental: {
extensionConfig: true,
},
} as Settings,
settings: createTestMergedSettings({
telemetry: { enabled: false },
experimental: { extensionConfig: true },
}),
});
const extensions = await extensionManager.loadExtensions();
@@ -147,14 +143,10 @@ describe('ExtensionManager Settings Scope', () => {
workspaceDir: tempWorkspace,
requestConsent: async () => true,
requestSetting: async () => '',
settings: {
telemetry: {
enabled: false,
},
experimental: {
extensionConfig: true,
},
} as Settings,
settings: createTestMergedSettings({
telemetry: { enabled: false },
experimental: { extensionConfig: true },
}),
});
const extensions = await extensionManager.loadExtensions();
@@ -187,14 +179,10 @@ describe('ExtensionManager Settings Scope', () => {
workspaceDir: tempWorkspace,
requestConsent: async () => true,
requestSetting: async () => '',
settings: {
telemetry: {
enabled: false,
},
experimental: {
extensionConfig: true,
},
} as Settings,
settings: createTestMergedSettings({
telemetry: { enabled: false },
experimental: { extensionConfig: true },
}),
});
const extensions = await extensionManager.loadExtensions();
@@ -10,7 +10,7 @@ import * as path from 'node:path';
import * as os from 'node:os';
import { ExtensionManager } from './extension-manager.js';
import { debugLogger, coreEvents } from '@google/gemini-cli-core';
import { type Settings } from './settings.js';
import { createTestMergedSettings } from './settings.js';
import { createExtension } from '../test-utils/createExtension.js';
import { EXTENSIONS_DIRECTORY_NAME } from './extensions/variables.js';
@@ -58,10 +58,9 @@ describe('ExtensionManager skills validation', () => {
fs.mkdirSync(extensionsDir, { recursive: true });
extensionManager = new ExtensionManager({
settings: {
settings: createTestMergedSettings({
telemetry: { enabled: false },
trustedFolders: [tempDir],
} as unknown as Settings,
}),
requestConsent: vi.fn().mockResolvedValue(true),
requestSetting: vi.fn(),
workspaceDir: tempDir,
@@ -134,10 +133,9 @@ describe('ExtensionManager skills validation', () => {
// 3. Create a fresh ExtensionManager to force loading from disk
const newExtensionManager = new ExtensionManager({
settings: {
settings: createTestMergedSettings({
telemetry: { enabled: false },
trustedFolders: [tempDir],
} as unknown as Settings,
}),
requestConsent: vi.fn().mockResolvedValue(true),
requestSetting: vi.fn(),
workspaceDir: tempDir,
+18 -26
View File
@@ -9,7 +9,7 @@ import * as path from 'node:path';
import { stat } from 'node:fs/promises';
import chalk from 'chalk';
import { ExtensionEnablementManager } from './extensions/extensionEnablement.js';
import { type Settings, SettingScope } from './settings.js';
import { type MergedSettings, SettingScope } from './settings.js';
import { createHash, randomUUID } from 'node:crypto';
import { loadInstallMetadata, type ExtensionConfig } from './extension.js';
import {
@@ -68,11 +68,10 @@ import {
ExtensionSettingScope,
} from './extensions/extensionSettings.js';
import type { EventEmitter } from 'node:stream';
import { getEnableHooks } from './settingsSchema.js';
interface ExtensionManagerParams {
enabledExtensionOverrides?: string[];
settings: Settings;
settings: MergedSettings;
requestConsent: (consent: string) => Promise<boolean>;
requestSetting: ((setting: ExtensionSetting) => Promise<string>) | null;
workspaceDir: string;
@@ -86,7 +85,7 @@ interface ExtensionManagerParams {
*/
export class ExtensionManager extends ExtensionLoader {
private extensionEnablementManager: ExtensionEnablementManager;
private settings: Settings;
private settings: MergedSettings;
private requestConsent: (consent: string) => Promise<boolean>;
private requestSetting:
| ((setting: ExtensionSetting) => Promise<string>)
@@ -143,7 +142,7 @@ export class ExtensionManager extends ExtensionLoader {
if (
(installMetadata.type === 'git' ||
installMetadata.type === 'github-release') &&
this.settings.security?.blockGitExtensions
this.settings.security.blockGitExtensions
) {
throw new Error(
'Installing extensions from remote sources is disallowed by your current settings.',
@@ -287,10 +286,7 @@ Would you like to attempt to install via "git clone" instead?`,
}
await fs.promises.mkdir(destinationPath, { recursive: true });
if (
this.requestSetting &&
(this.settings.experimental?.extensionConfig ?? false)
) {
if (this.requestSetting && this.settings.experimental.extensionConfig) {
if (isUpdate) {
await maybePromptForSettings(
newExtensionConfig,
@@ -308,14 +304,13 @@ Would you like to attempt to install via "git clone" instead?`,
}
}
const missingSettings =
(this.settings.experimental?.extensionConfig ?? false)
? await getMissingSettings(
newExtensionConfig,
extensionId,
this.workspaceDir,
)
: [];
const missingSettings = this.settings.experimental.extensionConfig
? await getMissingSettings(
newExtensionConfig,
extensionId,
this.workspaceDir,
)
: [];
if (missingSettings.length > 0) {
const message = `Extension "${newExtensionConfig.name}" has missing settings: ${missingSettings
.map((s) => s.name)
@@ -478,7 +473,7 @@ Would you like to attempt to install via "git clone" instead?`,
throw new Error('Extensions already loaded, only load extensions once.');
}
if (this.settings.admin?.extensions?.enabled === false) {
if (this.settings.admin.extensions.enabled === false) {
this.loadedExtensions = [];
return this.loadedExtensions;
}
@@ -511,7 +506,7 @@ Would you like to attempt to install via "git clone" instead?`,
if (
(installMetadata?.type === 'git' ||
installMetadata?.type === 'github-release') &&
this.settings.security?.blockGitExtensions
this.settings.security.blockGitExtensions
) {
return null;
}
@@ -535,7 +530,7 @@ Would you like to attempt to install via "git clone" instead?`,
let userSettings: Record<string, string> = {};
let workspaceSettings: Record<string, string> = {};
if (this.settings.experimental?.extensionConfig ?? false) {
if (this.settings.experimental.extensionConfig) {
userSettings = await getScopedEnvContents(
config,
extensionId,
@@ -553,10 +548,7 @@ Would you like to attempt to install via "git clone" instead?`,
config = resolveEnvVarsInObject(config, customEnv);
const resolvedSettings: ResolvedExtensionSetting[] = [];
if (
config.settings &&
(this.settings.experimental?.extensionConfig ?? false)
) {
if (config.settings && this.settings.experimental.extensionConfig) {
for (const setting of config.settings) {
const value = customEnv[setting.envVar];
let scope: 'user' | 'workspace' | undefined;
@@ -600,7 +592,7 @@ Would you like to attempt to install via "git clone" instead?`,
}
if (config.mcpServers) {
if (this.settings.admin?.mcp?.enabled === false) {
if (this.settings.admin.mcp.enabled === false) {
config.mcpServers = undefined;
} else {
config.mcpServers = Object.fromEntries(
@@ -619,7 +611,7 @@ Would you like to attempt to install via "git clone" instead?`,
.filter((contextFilePath) => fs.existsSync(contextFilePath));
let hooks: { [K in HookEventName]?: HookDefinition[] } | undefined;
if (getEnableHooks(this.settings)) {
if (this.settings.tools.enableHooks && this.settings.hooks.enabled) {
hooks = await this.loadExtensionHooks(effectiveExtensionPath, {
extensionPath: effectiveExtensionPath,
workspacePath: this.workspaceDir,
+12 -17
View File
@@ -26,7 +26,11 @@ import {
loadAgentsFromDirectory,
loadSkillsFromDir,
} from '@google/gemini-cli-core';
import { loadSettings, SettingScope } from './settings.js';
import {
loadSettings,
createTestMergedSettings,
SettingScope,
} from './settings.js';
import {
isWorkspaceTrusted,
resetTrustedFoldersForTesting,
@@ -201,7 +205,7 @@ describe('extension tests', () => {
});
vi.spyOn(process, 'cwd').mockReturnValue(tempWorkspaceDir);
const settings = loadSettings(tempWorkspaceDir).merged;
(settings.experimental ??= {}).extensionConfig = true;
settings.experimental.extensionConfig = true;
extensionManager = new ExtensionManager({
workspaceDir: tempWorkspaceDir,
requestConsent: mockRequestConsent,
@@ -628,11 +632,9 @@ describe('extension tests', () => {
},
});
const blockGitExtensionsSetting = {
security: {
blockGitExtensions: true,
},
};
const blockGitExtensionsSetting = createTestMergedSettings({
security: { blockGitExtensions: true },
});
extensionManager = new ExtensionManager({
workspaceDir: tempWorkspaceDir,
requestConsent: mockRequestConsent,
@@ -652,7 +654,6 @@ describe('extension tests', () => {
version: '1.0.0',
});
const loadedSettings = loadSettings(tempWorkspaceDir).merged;
(loadedSettings.admin ??= {}).extensions ??= {};
loadedSettings.admin.extensions.enabled = false;
extensionManager = new ExtensionManager({
@@ -676,7 +677,6 @@ describe('extension tests', () => {
},
});
const loadedSettings = loadSettings(tempWorkspaceDir).merged;
(loadedSettings.admin ??= {}).mcp ??= {};
loadedSettings.admin.mcp.enabled = false;
extensionManager = new ExtensionManager({
@@ -701,7 +701,6 @@ describe('extension tests', () => {
},
});
const loadedSettings = loadSettings(tempWorkspaceDir).merged;
(loadedSettings.admin ??= {}).mcp ??= {};
loadedSettings.admin.mcp.enabled = true;
extensionManager = new ExtensionManager({
@@ -837,7 +836,6 @@ describe('extension tests', () => {
);
const settings = loadSettings(tempWorkspaceDir).merged;
if (!settings.hooks) settings.hooks = {};
settings.hooks.enabled = true;
extensionManager = new ExtensionManager({
@@ -873,7 +871,6 @@ describe('extension tests', () => {
);
const settings = loadSettings(tempWorkspaceDir).merged;
if (!settings.hooks) settings.hooks = {};
settings.hooks.enabled = false;
extensionManager = new ExtensionManager({
@@ -1098,11 +1095,9 @@ describe('extension tests', () => {
it('should not install a github extension if blockGitExtensions is set', async () => {
const gitUrl = 'https://somehost.com/somerepo.git';
const blockGitExtensionsSetting = {
security: {
blockGitExtensions: true,
},
};
const blockGitExtensionsSetting = createTestMergedSettings({
security: { blockGitExtensions: true },
});
extensionManager = new ExtensionManager({
workspaceDir: tempWorkspaceDir,
requestConsent: mockRequestConsent,
@@ -11,7 +11,6 @@ import * as fs from 'node:fs';
import { getMissingSettings } from './extensionSettings.js';
import type { ExtensionConfig } from '../extension.js';
import { ExtensionStorage } from './storage.js';
import type { Settings } from '../settings.js';
import {
KeychainTokenStorage,
debugLogger,
@@ -21,6 +20,7 @@ import {
} from '@google/gemini-cli-core';
import { EXTENSION_SETTINGS_FILENAME } from './variables.js';
import { ExtensionManager } from '../extension-manager.js';
import { createTestMergedSettings } from '../settings.js';
vi.mock('node:fs', async (importOriginal) => {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
@@ -247,12 +247,10 @@ describe('extensionUpdates', () => {
const manager = new ExtensionManager({
workspaceDir: tempWorkspaceDir,
settings: {
telemetry: {
enabled: false,
},
settings: createTestMergedSettings({
telemetry: { enabled: false },
experimental: { extensionConfig: true },
} as unknown as Settings,
}),
requestConsent: vi.fn().mockResolvedValue(true),
requestSetting: null, // Simulate non-interactive
});
+137 -141
View File
@@ -8,87 +8,79 @@
* Command enum for all available keyboard shortcuts
*/
export enum Command {
// Basic bindings
RETURN = 'return',
ESCAPE = 'escape',
// Basic Controls
RETURN = 'basic.confirm',
ESCAPE = 'basic.cancel',
QUIT = 'basic.quit',
EXIT = 'basic.exit',
// Cursor movement
HOME = 'home',
END = 'end',
// Cursor Movement
HOME = 'cursor.home',
END = 'cursor.end',
MOVE_UP = 'cursor.up',
MOVE_DOWN = 'cursor.down',
MOVE_LEFT = 'cursor.left',
MOVE_RIGHT = 'cursor.right',
MOVE_WORD_LEFT = 'cursor.wordLeft',
MOVE_WORD_RIGHT = 'cursor.wordRight',
// Text deletion
KILL_LINE_RIGHT = 'killLineRight',
KILL_LINE_LEFT = 'killLineLeft',
CLEAR_INPUT = 'clearInput',
DELETE_WORD_BACKWARD = 'deleteWordBackward',
// Screen control
CLEAR_SCREEN = 'clearScreen',
// Editing
KILL_LINE_RIGHT = 'edit.deleteRightAll',
KILL_LINE_LEFT = 'edit.deleteLeftAll',
CLEAR_INPUT = 'edit.clear',
DELETE_WORD_BACKWARD = 'edit.deleteWordLeft',
DELETE_WORD_FORWARD = 'edit.deleteWordRight',
DELETE_CHAR_LEFT = 'edit.deleteLeft',
DELETE_CHAR_RIGHT = 'edit.deleteRight',
UNDO = 'edit.undo',
REDO = 'edit.redo',
// Scrolling
SCROLL_UP = 'scrollUp',
SCROLL_DOWN = 'scrollDown',
SCROLL_HOME = 'scrollHome',
SCROLL_END = 'scrollEnd',
PAGE_UP = 'pageUp',
PAGE_DOWN = 'pageDown',
SCROLL_UP = 'scroll.up',
SCROLL_DOWN = 'scroll.down',
SCROLL_HOME = 'scroll.home',
SCROLL_END = 'scroll.end',
PAGE_UP = 'scroll.pageUp',
PAGE_DOWN = 'scroll.pageDown',
// History navigation
HISTORY_UP = 'historyUp',
HISTORY_DOWN = 'historyDown',
NAVIGATION_UP = 'navigationUp',
NAVIGATION_DOWN = 'navigationDown',
// History & Search
HISTORY_UP = 'history.previous',
HISTORY_DOWN = 'history.next',
REVERSE_SEARCH = 'history.search.start',
SUBMIT_REVERSE_SEARCH = 'history.search.submit',
ACCEPT_SUGGESTION_REVERSE_SEARCH = 'history.search.accept',
// Dialog navigation
DIALOG_NAVIGATION_UP = 'dialogNavigationUp',
DIALOG_NAVIGATION_DOWN = 'dialogNavigationDown',
// Navigation
NAVIGATION_UP = 'nav.up',
NAVIGATION_DOWN = 'nav.down',
DIALOG_NAVIGATION_UP = 'nav.dialog.up',
DIALOG_NAVIGATION_DOWN = 'nav.dialog.down',
// Auto-completion
ACCEPT_SUGGESTION = 'acceptSuggestion',
COMPLETION_UP = 'completionUp',
COMPLETION_DOWN = 'completionDown',
// Suggestions & Completions
ACCEPT_SUGGESTION = 'suggest.accept',
COMPLETION_UP = 'suggest.focusPrevious',
COMPLETION_DOWN = 'suggest.focusNext',
EXPAND_SUGGESTION = 'suggest.expand',
COLLAPSE_SUGGESTION = 'suggest.collapse',
// Text input
SUBMIT = 'submit',
NEWLINE = 'newline',
// Text Input
SUBMIT = 'input.submit',
NEWLINE = 'input.newline',
OPEN_EXTERNAL_EDITOR = 'input.openExternalEditor',
PASTE_CLIPBOARD = 'input.paste',
// External tools
OPEN_EXTERNAL_EDITOR = 'openExternalEditor',
PASTE_CLIPBOARD = 'pasteClipboard',
// App level bindings
SHOW_ERROR_DETAILS = 'showErrorDetails',
SHOW_FULL_TODOS = 'showFullTodos',
SHOW_IDE_CONTEXT_DETAIL = 'showIDEContextDetail',
TOGGLE_MARKDOWN = 'toggleMarkdown',
TOGGLE_COPY_MODE = 'toggleCopyMode',
TOGGLE_YOLO = 'toggleYolo',
TOGGLE_AUTO_EDIT = 'toggleAutoEdit',
UNDO = 'undo',
REDO = 'redo',
MOVE_UP = 'moveUp',
MOVE_DOWN = 'moveDown',
MOVE_LEFT = 'moveLeft',
MOVE_RIGHT = 'moveRight',
MOVE_WORD_LEFT = 'moveWordLeft',
MOVE_WORD_RIGHT = 'moveWordRight',
DELETE_CHAR_LEFT = 'deleteCharLeft',
DELETE_CHAR_RIGHT = 'deleteCharRight',
DELETE_WORD_FORWARD = 'deleteWordForward',
QUIT = 'quit',
EXIT = 'exit',
SHOW_MORE_LINES = 'showMoreLines',
// Shell commands
REVERSE_SEARCH = 'reverseSearch',
SUBMIT_REVERSE_SEARCH = 'submitReverseSearch',
ACCEPT_SUGGESTION_REVERSE_SEARCH = 'acceptSuggestionReverseSearch',
FOCUS_SHELL_INPUT = 'focusShellInput',
UNFOCUS_SHELL_INPUT = 'unfocusShellInput',
// Suggestion expansion
EXPAND_SUGGESTION = 'expandSuggestion',
COLLAPSE_SUGGESTION = 'collapseSuggestion',
// App Controls
SHOW_ERROR_DETAILS = 'app.showErrorDetails',
SHOW_FULL_TODOS = 'app.showFullTodos',
SHOW_IDE_CONTEXT_DETAIL = 'app.showIdeContextDetail',
TOGGLE_MARKDOWN = 'app.toggleMarkdown',
TOGGLE_COPY_MODE = 'app.toggleCopyMode',
TOGGLE_YOLO = 'app.toggleYolo',
TOGGLE_AUTO_EDIT = 'app.toggleAutoEdit',
SHOW_MORE_LINES = 'app.showMoreLines',
FOCUS_SHELL_INPUT = 'app.focusShellInput',
UNFOCUS_SHELL_INPUT = 'app.unfocusShellInput',
CLEAR_SCREEN = 'app.clearScreen',
}
/**
@@ -117,24 +109,17 @@ export type KeyBindingConfig = {
* Matches the original hard-coded logic exactly
*/
export const defaultKeyBindings: KeyBindingConfig = {
// Basic bindings
// Basic Controls
[Command.RETURN]: [{ key: 'return' }],
[Command.ESCAPE]: [{ key: 'escape' }],
[Command.QUIT]: [{ key: 'c', ctrl: true }],
[Command.EXIT]: [{ key: 'd', ctrl: true }],
// Cursor movement
// Cursor Movement
[Command.HOME]: [{ key: 'a', ctrl: true }, { key: 'home' }],
[Command.END]: [{ key: 'e', ctrl: true }, { key: 'end' }],
// Text deletion
[Command.KILL_LINE_RIGHT]: [{ key: 'k', ctrl: true }],
[Command.KILL_LINE_LEFT]: [{ key: 'u', ctrl: true }],
[Command.CLEAR_INPUT]: [{ key: 'c', ctrl: true }],
// Added command (meta/alt/option) for mac compatibility
[Command.DELETE_WORD_BACKWARD]: [
{ key: 'backspace', ctrl: true },
{ key: 'backspace', command: true },
{ key: 'w', ctrl: true },
],
[Command.MOVE_UP]: [{ key: 'up', ctrl: false, command: false }],
[Command.MOVE_DOWN]: [{ key: 'down', ctrl: false, command: false }],
[Command.MOVE_LEFT]: [
{ key: 'left', ctrl: false, command: false },
{ key: 'b', ctrl: true },
@@ -143,8 +128,6 @@ export const defaultKeyBindings: KeyBindingConfig = {
{ key: 'right', ctrl: false, command: false },
{ key: 'f', ctrl: true },
],
[Command.MOVE_UP]: [{ key: 'up', ctrl: false, command: false }],
[Command.MOVE_DOWN]: [{ key: 'down', ctrl: false, command: false }],
[Command.MOVE_WORD_LEFT]: [
{ key: 'left', ctrl: true },
{ key: 'left', command: true },
@@ -155,15 +138,25 @@ export const defaultKeyBindings: KeyBindingConfig = {
{ key: 'right', command: true },
{ key: 'f', command: true },
],
[Command.DELETE_CHAR_LEFT]: [{ key: 'backspace' }, { key: 'h', ctrl: true }],
[Command.DELETE_CHAR_RIGHT]: [{ key: 'delete' }, { key: 'd', ctrl: true }],
// Editing
[Command.KILL_LINE_RIGHT]: [{ key: 'k', ctrl: true }],
[Command.KILL_LINE_LEFT]: [{ key: 'u', ctrl: true }],
[Command.CLEAR_INPUT]: [{ key: 'c', ctrl: true }],
// Added command (meta/alt/option) for mac compatibility
[Command.DELETE_WORD_BACKWARD]: [
{ key: 'backspace', ctrl: true },
{ key: 'backspace', command: true },
{ key: 'w', ctrl: true },
],
[Command.DELETE_WORD_FORWARD]: [
{ key: 'delete', ctrl: true },
{ key: 'delete', command: true },
],
// Screen control
[Command.CLEAR_SCREEN]: [{ key: 'l', ctrl: true }],
[Command.DELETE_CHAR_LEFT]: [{ key: 'backspace' }, { key: 'h', ctrl: true }],
[Command.DELETE_CHAR_RIGHT]: [{ key: 'delete' }, { key: 'd', ctrl: true }],
[Command.UNDO]: [{ key: 'z', ctrl: true, shift: false }],
[Command.REDO]: [{ key: 'z', ctrl: true, shift: true }],
// Scrolling
[Command.SCROLL_UP]: [{ key: 'up', shift: true }],
@@ -173,13 +166,17 @@ export const defaultKeyBindings: KeyBindingConfig = {
[Command.PAGE_UP]: [{ key: 'pageup' }],
[Command.PAGE_DOWN]: [{ key: 'pagedown' }],
// History navigation
// History & Search
[Command.HISTORY_UP]: [{ key: 'p', ctrl: true, shift: false }],
[Command.HISTORY_DOWN]: [{ key: 'n', ctrl: true, shift: false }],
[Command.REVERSE_SEARCH]: [{ key: 'r', ctrl: true }],
// Note: original logic ONLY checked ctrl=false, ignored meta/shift/paste
[Command.SUBMIT_REVERSE_SEARCH]: [{ key: 'return', ctrl: false }],
[Command.ACCEPT_SUGGESTION_REVERSE_SEARCH]: [{ key: 'tab' }],
// Navigation
[Command.NAVIGATION_UP]: [{ key: 'up', shift: false }],
[Command.NAVIGATION_DOWN]: [{ key: 'down', shift: false }],
// Dialog navigation
// Navigation shortcuts appropriate for dialogs where we do not need to accept
// text input.
[Command.DIALOG_NAVIGATION_UP]: [
@@ -191,7 +188,7 @@ export const defaultKeyBindings: KeyBindingConfig = {
{ key: 'j', shift: false },
],
// Auto-completion
// Suggestions & Completions
[Command.ACCEPT_SUGGESTION]: [{ key: 'tab' }, { key: 'return', ctrl: false }],
// Completion navigation (arrow or Ctrl+P/N)
[Command.COMPLETION_UP]: [
@@ -202,8 +199,10 @@ export const defaultKeyBindings: KeyBindingConfig = {
{ key: 'down', shift: false },
{ key: 'n', ctrl: true, shift: false },
],
[Command.EXPAND_SUGGESTION]: [{ key: 'right' }],
[Command.COLLAPSE_SUGGESTION]: [{ key: 'left' }],
// Text input
// Text Input
// Must also exclude shift to allow shift+enter for newline
[Command.SUBMIT]: [
{
@@ -221,15 +220,13 @@ export const defaultKeyBindings: KeyBindingConfig = {
{ key: 'return', shift: true },
{ key: 'j', ctrl: true },
],
// External tools
[Command.OPEN_EXTERNAL_EDITOR]: [{ key: 'x', ctrl: true }],
[Command.PASTE_CLIPBOARD]: [
{ key: 'v', ctrl: true },
{ key: 'v', command: true },
],
// App level bindings
// App Controls
[Command.SHOW_ERROR_DETAILS]: [{ key: 'f12' }],
[Command.SHOW_FULL_TODOS]: [{ key: 't', ctrl: true }],
[Command.SHOW_IDE_CONTEXT_DETAIL]: [{ key: 'g', ctrl: true }],
@@ -237,22 +234,10 @@ export const defaultKeyBindings: KeyBindingConfig = {
[Command.TOGGLE_COPY_MODE]: [{ key: 's', ctrl: true }],
[Command.TOGGLE_YOLO]: [{ key: 'y', ctrl: true }],
[Command.TOGGLE_AUTO_EDIT]: [{ key: 'tab', shift: true }],
[Command.UNDO]: [{ key: 'z', ctrl: true, shift: false }],
[Command.REDO]: [{ key: 'z', ctrl: true, shift: true }],
[Command.QUIT]: [{ key: 'c', ctrl: true }],
[Command.EXIT]: [{ key: 'd', ctrl: true }],
[Command.SHOW_MORE_LINES]: [{ key: 's', ctrl: true }],
// Shell commands
[Command.REVERSE_SEARCH]: [{ key: 'r', ctrl: true }],
// Note: original logic ONLY checked ctrl=false, ignored meta/shift/paste
[Command.SUBMIT_REVERSE_SEARCH]: [{ key: 'return', ctrl: false }],
[Command.ACCEPT_SUGGESTION_REVERSE_SEARCH]: [{ key: 'tab' }],
[Command.FOCUS_SHELL_INPUT]: [{ key: 'tab', shift: false }],
[Command.UNFOCUS_SHELL_INPUT]: [{ key: 'tab' }],
// Suggestion expansion
[Command.EXPAND_SUGGESTION]: [{ key: 'right' }],
[Command.COLLAPSE_SUGGESTION]: [{ key: 'left' }],
[Command.CLEAR_SCREEN]: [{ key: 'l', ctrl: true }],
};
interface CommandCategory {
@@ -266,7 +251,7 @@ interface CommandCategory {
export const commandCategories: readonly CommandCategory[] = [
{
title: 'Basic Controls',
commands: [Command.RETURN, Command.ESCAPE],
commands: [Command.RETURN, Command.ESCAPE, Command.QUIT, Command.EXIT],
},
{
title: 'Cursor Movement',
@@ -295,10 +280,6 @@ export const commandCategories: readonly CommandCategory[] = [
Command.REDO,
],
},
{
title: 'Screen Control',
commands: [Command.CLEAR_SCREEN],
},
{
title: 'Scrolling',
commands: [
@@ -341,11 +322,12 @@ export const commandCategories: readonly CommandCategory[] = [
},
{
title: 'Text Input',
commands: [Command.SUBMIT, Command.NEWLINE],
},
{
title: 'External Tools',
commands: [Command.OPEN_EXTERNAL_EDITOR, Command.PASTE_CLIPBOARD],
commands: [
Command.SUBMIT,
Command.NEWLINE,
Command.OPEN_EXTERNAL_EDITOR,
Command.PASTE_CLIPBOARD,
],
},
{
title: 'App Controls',
@@ -360,28 +342,33 @@ export const commandCategories: readonly CommandCategory[] = [
Command.SHOW_MORE_LINES,
Command.FOCUS_SHELL_INPUT,
Command.UNFOCUS_SHELL_INPUT,
Command.CLEAR_SCREEN,
],
},
{
title: 'Session Control',
commands: [Command.QUIT, Command.EXIT],
},
];
/**
* Human-readable descriptions for each command, used in docs/tooling.
*/
export const commandDescriptions: Readonly<Record<Command, string>> = {
// Basic Controls
[Command.RETURN]: 'Confirm the current selection or choice.',
[Command.ESCAPE]: 'Dismiss dialogs or cancel the current focus.',
[Command.QUIT]:
'Cancel the current request or quit the CLI when input is empty.',
[Command.EXIT]: 'Exit the CLI when the input buffer is empty.',
// Cursor Movement
[Command.HOME]: 'Move the cursor to the start of the line.',
[Command.END]: 'Move the cursor to the end of the line.',
[Command.MOVE_LEFT]: 'Move the cursor one character to the left.',
[Command.MOVE_RIGHT]: 'Move the cursor one character to the right.',
[Command.MOVE_UP]: 'Move the cursor up one line.',
[Command.MOVE_DOWN]: 'Move the cursor down one line.',
[Command.MOVE_LEFT]: 'Move the cursor one character to the left.',
[Command.MOVE_RIGHT]: 'Move the cursor one character to the right.',
[Command.MOVE_WORD_LEFT]: 'Move the cursor one word to the left.',
[Command.MOVE_WORD_RIGHT]: 'Move the cursor one word to the right.',
// Editing
[Command.KILL_LINE_RIGHT]: 'Delete from the cursor to the end of the line.',
[Command.KILL_LINE_LEFT]: 'Delete from the cursor to the start of the line.',
[Command.CLEAR_INPUT]: 'Clear all text in the input field.',
@@ -391,45 +378,54 @@ export const commandDescriptions: Readonly<Record<Command, string>> = {
[Command.DELETE_CHAR_RIGHT]: 'Delete the character to the right.',
[Command.UNDO]: 'Undo the most recent text edit.',
[Command.REDO]: 'Redo the most recent undone text edit.',
[Command.CLEAR_SCREEN]: 'Clear the terminal screen and redraw the UI.',
// Scrolling
[Command.SCROLL_UP]: 'Scroll content up.',
[Command.SCROLL_DOWN]: 'Scroll content down.',
[Command.SCROLL_HOME]: 'Scroll to the top.',
[Command.SCROLL_END]: 'Scroll to the bottom.',
[Command.PAGE_UP]: 'Scroll up by one page.',
[Command.PAGE_DOWN]: 'Scroll down by one page.',
// History & Search
[Command.HISTORY_UP]: 'Show the previous entry in history.',
[Command.HISTORY_DOWN]: 'Show the next entry in history.',
[Command.REVERSE_SEARCH]: 'Start reverse search through history.',
[Command.SUBMIT_REVERSE_SEARCH]: 'Submit the selected reverse-search match.',
[Command.ACCEPT_SUGGESTION_REVERSE_SEARCH]:
'Accept a suggestion while reverse searching.',
// Navigation
[Command.NAVIGATION_UP]: 'Move selection up in lists.',
[Command.NAVIGATION_DOWN]: 'Move selection down in lists.',
[Command.DIALOG_NAVIGATION_UP]: 'Move up within dialog options.',
[Command.DIALOG_NAVIGATION_DOWN]: 'Move down within dialog options.',
// Suggestions & Completions
[Command.ACCEPT_SUGGESTION]: 'Accept the inline suggestion.',
[Command.COMPLETION_UP]: 'Move to the previous completion option.',
[Command.COMPLETION_DOWN]: 'Move to the next completion option.',
[Command.EXPAND_SUGGESTION]: 'Expand an inline suggestion.',
[Command.COLLAPSE_SUGGESTION]: 'Collapse an inline suggestion.',
// Text Input
[Command.SUBMIT]: 'Submit the current prompt.',
[Command.NEWLINE]: 'Insert a newline without submitting.',
[Command.OPEN_EXTERNAL_EDITOR]:
'Open the current prompt in an external editor.',
[Command.PASTE_CLIPBOARD]: 'Paste from the clipboard.',
// App Controls
[Command.SHOW_ERROR_DETAILS]: 'Toggle detailed error information.',
[Command.SHOW_FULL_TODOS]: 'Toggle the full TODO list.',
[Command.SHOW_IDE_CONTEXT_DETAIL]: 'Show IDE context details.',
[Command.TOGGLE_MARKDOWN]: 'Toggle Markdown rendering.',
[Command.TOGGLE_COPY_MODE]:
'Toggle copy mode when the terminal is using the alternate buffer.',
[Command.TOGGLE_COPY_MODE]: 'Toggle copy mode when in alternate buffer mode.',
[Command.TOGGLE_YOLO]: 'Toggle YOLO (auto-approval) mode for tool calls.',
[Command.TOGGLE_AUTO_EDIT]: 'Toggle Auto Edit (auto-accept edits) mode.',
[Command.QUIT]: 'Cancel the current request or quit the CLI.',
[Command.EXIT]: 'Exit the CLI when the input buffer is empty.',
[Command.SHOW_MORE_LINES]:
'Expand a height-constrained response to show additional lines.',
[Command.REVERSE_SEARCH]: 'Start reverse search through history.',
[Command.SUBMIT_REVERSE_SEARCH]: 'Submit the selected reverse-search match.',
[Command.ACCEPT_SUGGESTION_REVERSE_SEARCH]:
'Accept a suggestion while reverse searching.',
'Expand a height-constrained response to show additional lines when not in alternate buffer mode.',
[Command.FOCUS_SHELL_INPUT]: 'Focus the shell input from the gemini input.',
[Command.UNFOCUS_SHELL_INPUT]: 'Focus the Gemini input from the shell input.',
[Command.EXPAND_SUGGESTION]: 'Expand an inline suggestion.',
[Command.COLLAPSE_SUGGESTION]: 'Collapse an inline suggestion.',
[Command.CLEAR_SCREEN]: 'Clear the terminal screen and redraw the UI.',
};
@@ -287,6 +287,43 @@ describe('Policy Engine Integration Tests', () => {
).toBe(PolicyDecision.ASK_USER);
});
it('should handle Plan mode correctly', async () => {
const settings: Settings = {};
const config = await createPolicyEngineConfig(
settings,
ApprovalMode.PLAN,
);
const engine = new PolicyEngine(config);
// Read and search tools should be allowed
expect(
(await engine.check({ name: 'read_file' }, undefined)).decision,
).toBe(PolicyDecision.ALLOW);
expect(
(await engine.check({ name: 'google_web_search' }, undefined)).decision,
).toBe(PolicyDecision.ALLOW);
expect(
(await engine.check({ name: 'list_directory' }, undefined)).decision,
).toBe(PolicyDecision.ALLOW);
// Other tools should be denied via catch all
expect(
(await engine.check({ name: 'replace' }, undefined)).decision,
).toBe(PolicyDecision.DENY);
expect(
(await engine.check({ name: 'write_file' }, undefined)).decision,
).toBe(PolicyDecision.DENY);
expect(
(await engine.check({ name: 'run_shell_command' }, undefined)).decision,
).toBe(PolicyDecision.DENY);
// Unknown tools should be denied via catch-all
expect(
(await engine.check({ name: 'unknown_tool' }, undefined)).decision,
).toBe(PolicyDecision.DENY);
});
it('should verify priority ordering works correctly in practice', async () => {
const settings: Settings = {
tools: {
+37 -13
View File
@@ -24,12 +24,24 @@ import { DefaultDark } from '../ui/themes/default.js';
import { isWorkspaceTrusted } from './trustedFolders.js';
import {
type Settings,
type MergedSettings,
type MemoryImportFormat,
type MergeStrategy,
type SettingsSchema,
type SettingDefinition,
getSettingsSchema,
} from './settingsSchema.js';
export {
type Settings,
type MergedSettings,
type MemoryImportFormat,
type MergeStrategy,
type SettingsSchema,
type SettingDefinition,
getSettingsSchema,
};
import { resolveEnvVarsInObject } from '../utils/envVarResolver.js';
import { customDeepMerge } from '../utils/deepMerge.js';
import { updateSettingsFilePreservingFormat } from '../utils/commentJson.js';
@@ -59,8 +71,6 @@ function getMergeStrategyForPath(path: string[]): MergeStrategy | undefined {
return current?.mergeStrategy;
}
export type { Settings, MemoryImportFormat };
export const USER_SETTINGS_PATH = Storage.getGlobalSettingsPath();
export const USER_SETTINGS_DIR = path.dirname(USER_SETTINGS_PATH);
export const DEFAULT_EXCLUDED_ENV_VARS = ['DEBUG', 'DEBUG_MODE'];
@@ -201,10 +211,7 @@ export function getDefaultsFromSchema(
for (const key in schema) {
const definition = schema[key];
if (definition.properties) {
const childDefaults = getDefaultsFromSchema(definition.properties);
if (Object.keys(childDefaults).length > 0) {
defaults[key] = childDefaults;
}
defaults[key] = getDefaultsFromSchema(definition.properties);
} else if (definition.default !== undefined) {
defaults[key] = definition.default;
}
@@ -212,13 +219,13 @@ export function getDefaultsFromSchema(
return defaults as Settings;
}
function mergeSettings(
export function mergeSettings(
system: Settings,
systemDefaults: Settings,
user: Settings,
workspace: Settings,
isTrusted: boolean,
): Settings {
): MergedSettings {
const safeWorkspace = isTrusted ? workspace : ({} as Settings);
const schemaDefaults = getDefaultsFromSchema();
@@ -236,7 +243,24 @@ function mergeSettings(
user,
safeWorkspace,
system,
) as Settings;
) as MergedSettings;
}
/**
* Creates a fully populated MergedSettings object for testing purposes.
* It merges the provided overrides with the default settings from the schema.
*
* @param overrides Partial settings to override the defaults.
* @returns A complete MergedSettings object.
*/
export function createTestMergedSettings(
overrides: Partial<Settings> = {},
): MergedSettings {
return customDeepMerge(
getMergeStrategyForPath,
getDefaultsFromSchema(),
overrides,
) as MergedSettings;
}
export class LoadedSettings {
@@ -264,14 +288,14 @@ export class LoadedSettings {
readonly isTrusted: boolean;
readonly errors: SettingsError[];
private _merged: Settings;
private _merged: MergedSettings;
private _remoteAdminSettings: Partial<Settings> | undefined;
get merged(): Settings {
get merged(): MergedSettings {
return this._merged;
}
private computeMergedSettings(): Settings {
private computeMergedSettings(): MergedSettings {
const merged = mergeSettings(
this.system.settings,
this.systemDefaults.settings,
@@ -293,7 +317,7 @@ export class LoadedSettings {
(path: string[]) => getMergeStrategyForPath(['admin', ...path]),
adminDefaults,
this._remoteAdminSettings?.admin ?? {},
) as Settings['admin'];
) as MergedSettings['admin'];
}
return merged;
}
+19 -9
View File
@@ -14,6 +14,7 @@ import type {
BugCommandSettings,
TelemetrySettings,
AuthType,
AgentOverride,
} from '@google/gemini-cli-core';
import {
DEFAULT_TRUNCATE_TOOL_OUTPUT_LINES,
@@ -799,7 +800,7 @@ const SETTINGS_SCHEMA = {
label: 'Agent Overrides',
category: 'Advanced',
requiresRestart: true,
default: {},
default: {} as Record<string, AgentOverride>,
description:
'Override settings for specific agents, e.g. to disable the agent, set a custom model config, or run config.',
showInDialog: false,
@@ -2092,6 +2093,10 @@ export const SETTINGS_SCHEMA_DEFINITIONS: Record<
},
},
},
enabled: {
type: 'boolean',
description: 'Whether to enable the agent.',
},
disabled: {
type: 'boolean',
description: 'Whether to disable the agent.',
@@ -2262,12 +2267,17 @@ type InferSettings<T extends SettingsSchema> = {
: T[K]['default'];
};
type InferMergedSettings<T extends SettingsSchema> = {
-readonly [K in keyof T]-?: T[K] extends { properties: SettingsSchema }
? InferMergedSettings<T[K]['properties']>
: T[K]['type'] extends 'enum'
? T[K]['options'] extends readonly SettingEnumOption[]
? T[K]['options'][number]['value']
: T[K]['default']
: T[K]['default'] extends boolean
? boolean
: T[K]['default'];
};
export type Settings = InferSettings<SettingsSchemaType>;
export function getEnableHooksUI(settings: Settings): boolean {
return settings.tools?.enableHooks ?? true;
}
export function getEnableHooks(settings: Settings): boolean {
return getEnableHooksUI(settings) && (settings.hooks?.enabled ?? false);
}
export type MergedSettings = InferMergedSettings<SettingsSchemaType>;
+1 -1
View File
@@ -127,7 +127,7 @@ describe('initializer', () => {
});
it('should handle undefined auth type', async () => {
mockSettings.merged.security!.auth!.selectedType = undefined;
mockSettings.merged.security.auth.selectedType = undefined;
const result = await initializeApp(
mockConfig as unknown as Config,
mockSettings,
+2 -2
View File
@@ -39,13 +39,13 @@ export async function initializeApp(
const authHandle = startupProfiler.start('authenticate');
const authError = await performInitialAuth(
config,
settings.merged.security?.auth?.selectedType,
settings.merged.security.auth.selectedType,
);
authHandle?.end();
const themeError = validateTheme(settings);
const shouldOpenAuthDialog =
settings.merged.security?.auth?.selectedType === undefined || !!authError;
settings.merged.security.auth.selectedType === undefined || !!authError;
logCliConfiguration(
config,
+1 -1
View File
@@ -46,7 +46,7 @@ describe('theme', () => {
});
it('should return null if theme is undefined', () => {
mockSettings.merged.ui!.theme = undefined;
mockSettings.merged.ui.theme = undefined;
const result = validateTheme(mockSettings);
expect(result).toBeNull();
expect(themeManager.findThemeByName).not.toHaveBeenCalled();
+1 -1
View File
@@ -13,7 +13,7 @@ import { type LoadedSettings } from '../config/settings.js';
* @returns An error message if the theme is not found, otherwise null.
*/
export function validateTheme(settings: LoadedSettings): string | null {
const effectiveTheme = settings.merged.ui?.theme;
const effectiveTheme = settings.merged.ui.theme;
if (effectiveTheme && !themeManager.findThemeByName(effectiveTheme)) {
return `Theme "${effectiveTheme}" not found.`;
}
+127 -108
View File
@@ -23,8 +23,30 @@ import {
import os from 'node:os';
import v8 from 'node:v8';
import { type CliArgs } from './config/config.js';
import { type LoadedSettings } from './config/settings.js';
import {
type LoadedSettings,
type Settings,
createTestMergedSettings,
} from './config/settings.js';
import { appEvents, AppEvent } from './utils/events.js';
function createMockSettings(
overrides: Record<string, unknown> = {},
): LoadedSettings {
const merged = createTestMergedSettings(
(overrides['merged'] as Partial<Settings>) || {},
);
return {
system: { settings: {} },
systemDefaults: { settings: {} },
user: { settings: {} },
workspace: { settings: {} },
errors: [],
...overrides,
merged,
} as unknown as LoadedSettings;
}
import {
type Config,
type ResumedSessionData,
@@ -108,26 +130,19 @@ class MockProcessExitError extends Error {
}
// Mock dependencies
vi.mock('./config/settings.js', () => ({
loadSettings: vi.fn().mockReturnValue({
merged: {
advanced: {},
security: { auth: {} },
ui: {},
},
workspace: { settings: {} },
setValue: vi.fn(),
forScope: () => ({ settings: {}, originalSettings: {}, path: '' }),
errors: [],
}),
migrateDeprecatedSettings: vi.fn(),
SettingScope: {
User: 'user',
Workspace: 'workspace',
System: 'system',
SystemDefaults: 'system-defaults',
},
}));
vi.mock('./config/settings.js', async (importOriginal) => {
const actual = await importOriginal<typeof import('./config/settings.js')>();
return {
...actual,
loadSettings: vi.fn().mockImplementation(() => ({
merged: actual.getDefaultsFromSchema(),
workspace: { settings: {} },
errors: [],
})),
saveModelChange: vi.fn(),
getDefaultsFromSchema: actual.getDefaultsFromSchema,
};
});
vi.mock('./ui/utils/terminalCapabilityManager.js', () => ({
terminalCapabilityManager: {
@@ -443,17 +458,15 @@ describe('gemini.tsx main function kitty protocol', () => {
getProjectTempDir: vi.fn().mockReturnValue('/tmp/gemini-test'),
},
} as unknown as Config);
vi.mocked(loadSettings).mockReturnValue({
errors: [],
merged: {
advanced: {},
security: { auth: {} },
ui: {},
},
workspace: { settings: {} },
setValue: vi.fn(),
forScope: () => ({ settings: {}, originalSettings: {}, path: '' }),
} as never);
vi.mocked(loadSettings).mockReturnValue(
createMockSettings({
merged: {
advanced: {},
security: { auth: {} },
ui: {},
},
}),
);
vi.mocked(parseArguments).mockResolvedValue({
model: undefined,
sandbox: undefined,
@@ -505,17 +518,18 @@ describe('gemini.tsx main function kitty protocol', () => {
throw new MockProcessExitError(code);
});
vi.mocked(loadSettings).mockReturnValue({
merged: {
advanced: {},
security: { auth: {} },
ui: {},
},
workspace: { settings: {} },
setValue: vi.fn(),
forScope: () => ({ settings: {}, originalSettings: {}, path: '' }),
errors: [],
} as any); // eslint-disable-line @typescript-eslint/no-explicit-any
vi.mocked(loadSettings).mockReturnValue(
createMockSettings({
merged: {
advanced: {},
security: { auth: {} },
ui: {},
},
workspace: { settings: {} },
setValue: vi.fn(),
forScope: () => ({ settings: {}, originalSettings: {}, path: '' }),
}),
);
vi.mocked(parseArguments).mockResolvedValue({
promptInteractive: false,
@@ -594,17 +608,18 @@ describe('gemini.tsx main function kitty protocol', () => {
promptInteractive: false,
} as any); // eslint-disable-line @typescript-eslint/no-explicit-any
vi.mocked(loadSettings).mockReturnValue({
merged: {
advanced: {},
security: { auth: {} },
ui: {},
},
workspace: { settings: {} },
setValue: vi.fn(),
forScope: () => ({ settings: {}, originalSettings: {}, path: '' }),
errors: [],
} as any); // eslint-disable-line @typescript-eslint/no-explicit-any
vi.mocked(loadSettings).mockReturnValue(
createMockSettings({
merged: {
advanced: {},
security: { auth: {} },
ui: {},
},
workspace: { settings: {} },
setValue: vi.fn(),
forScope: () => ({ settings: {}, originalSettings: {}, path: '' }),
}),
);
const mockConfig = {
isInteractive: () => false,
@@ -665,17 +680,18 @@ describe('gemini.tsx main function kitty protocol', () => {
throw new MockProcessExitError(code);
});
vi.mocked(loadSettings).mockReturnValue({
merged: {
advanced: {},
security: { auth: {} },
ui: { theme: 'non-existent-theme' },
},
workspace: { settings: {} },
setValue: vi.fn(),
forScope: () => ({ settings: {}, originalSettings: {}, path: '' }),
errors: [],
} as any); // eslint-disable-line @typescript-eslint/no-explicit-any
vi.mocked(loadSettings).mockReturnValue(
createMockSettings({
merged: {
advanced: {},
security: { auth: {} },
ui: { theme: 'non-existent-theme' },
},
workspace: { settings: {} },
setValue: vi.fn(),
forScope: () => ({ settings: {}, originalSettings: {}, path: '' }),
}),
);
vi.mocked(parseArguments).mockResolvedValue({
promptInteractive: false,
@@ -753,13 +769,14 @@ describe('gemini.tsx main function kitty protocol', () => {
});
const emitFeedbackSpy = vi.spyOn(coreEvents, 'emitFeedback');
vi.mocked(loadSettings).mockReturnValue({
merged: { advanced: {}, security: { auth: {} }, ui: { theme: 'test' } },
workspace: { settings: {} },
setValue: vi.fn(),
forScope: () => ({ settings: {}, originalSettings: {}, path: '' }),
errors: [],
} as any); // eslint-disable-line @typescript-eslint/no-explicit-any
vi.mocked(loadSettings).mockReturnValue(
createMockSettings({
merged: { advanced: {}, security: { auth: {} }, ui: { theme: 'test' } },
workspace: { settings: {} },
setValue: vi.fn(),
forScope: () => ({ settings: {}, originalSettings: {}, path: '' }),
}),
);
vi.mocked(parseArguments).mockResolvedValue({
promptInteractive: false,
@@ -839,13 +856,14 @@ describe('gemini.tsx main function kitty protocol', () => {
throw new MockProcessExitError(code);
});
vi.mocked(loadSettings).mockReturnValue({
merged: { advanced: {}, security: { auth: {} }, ui: {} },
workspace: { settings: {} },
setValue: vi.fn(),
forScope: () => ({ settings: {}, originalSettings: {}, path: '' }),
errors: [],
} as any); // eslint-disable-line @typescript-eslint/no-explicit-any
vi.mocked(loadSettings).mockReturnValue(
createMockSettings({
merged: { advanced: {}, security: { auth: {} }, ui: {} },
workspace: { settings: {} },
setValue: vi.fn(),
forScope: () => ({ settings: {}, originalSettings: {}, path: '' }),
}),
);
vi.mocked(parseArguments).mockResolvedValue({
promptInteractive: false,
@@ -918,13 +936,14 @@ describe('gemini.tsx main function kitty protocol', () => {
throw new MockProcessExitError(code);
});
vi.mocked(loadSettings).mockReturnValue({
merged: { advanced: {}, security: { auth: {} }, ui: {} },
workspace: { settings: {} },
setValue: vi.fn(),
forScope: () => ({ settings: {}, originalSettings: {}, path: '' }),
errors: [],
} as any); // eslint-disable-line @typescript-eslint/no-explicit-any
vi.mocked(loadSettings).mockReturnValue(
createMockSettings({
merged: { advanced: {}, security: { auth: {} }, ui: {} },
workspace: { settings: {} },
setValue: vi.fn(),
forScope: () => ({ settings: {}, originalSettings: {}, path: '' }),
}),
);
vi.mocked(parseArguments).mockResolvedValue({
promptInteractive: false,
@@ -1034,10 +1053,11 @@ describe('gemini.tsx main function exit codes', () => {
);
const { loadSettings } = await import('./config/settings.js');
vi.mocked(loadCliConfig).mockResolvedValue({} as Config);
vi.mocked(loadSettings).mockReturnValue({
merged: { security: { auth: {} }, ui: {} },
errors: [],
} as never);
vi.mocked(loadSettings).mockReturnValue(
createMockSettings({
merged: { security: { auth: {} }, ui: {} },
}),
);
vi.mocked(parseArguments).mockResolvedValue({
promptInteractive: true,
} as unknown as CliArgs);
@@ -1066,14 +1086,13 @@ describe('gemini.tsx main function exit codes', () => {
vi.mocked(loadCliConfig).mockResolvedValue({
refreshAuth: vi.fn().mockRejectedValue(new Error('Auth failed')),
} as unknown as Config);
vi.mocked(loadSettings).mockReturnValue({
merged: {
security: { auth: { selectedType: 'google', useExternal: false } },
ui: {},
},
workspace: { settings: {} },
errors: [],
} as never);
vi.mocked(loadSettings).mockReturnValue(
createMockSettings({
merged: {
security: { auth: { selectedType: 'google', useExternal: false } },
},
}),
);
vi.mocked(parseArguments).mockResolvedValue({} as unknown as CliArgs);
vi.mock('./config/auth.js', () => ({
validateAuthMethod: vi.fn().mockReturnValue(null),
@@ -1131,11 +1150,11 @@ describe('gemini.tsx main function exit codes', () => {
getProjectTempDir: vi.fn().mockReturnValue('/tmp/gemini-test'),
},
} as unknown as Config);
vi.mocked(loadSettings).mockReturnValue({
merged: { security: { auth: {} }, ui: {} },
workspace: { settings: {} },
errors: [],
} as never);
vi.mocked(loadSettings).mockReturnValue(
createMockSettings({
merged: { security: { auth: {} }, ui: {} },
}),
);
vi.mocked(parseArguments).mockResolvedValue({
resume: 'invalid-session',
} as unknown as CliArgs);
@@ -1200,11 +1219,11 @@ describe('gemini.tsx main function exit codes', () => {
},
getRemoteAdminSettings: () => undefined,
} as unknown as Config);
vi.mocked(loadSettings).mockReturnValue({
merged: { security: { auth: {} }, ui: {} },
workspace: { settings: {} },
errors: [],
} as never);
vi.mocked(loadSettings).mockReturnValue(
createMockSettings({
merged: { security: { auth: {} }, ui: {} },
}),
);
vi.mocked(parseArguments).mockResolvedValue({} as unknown as CliArgs);
Object.defineProperty(process.stdin, 'isTTY', {
value: true, // Simulate TTY so it doesn't try to read stdin
+18 -19
View File
@@ -213,12 +213,12 @@ export async function startInteractiveUI(
<SettingsContext.Provider value={settings}>
<KeypressProvider
config={config}
debugKeystrokeLogging={settings.merged.general?.debugKeystrokeLogging}
debugKeystrokeLogging={settings.merged.general.debugKeystrokeLogging}
>
<MouseProvider
mouseEventsEnabled={mouseEventsEnabled}
debugKeystrokeLogging={
settings.merged.general?.debugKeystrokeLogging
settings.merged.general.debugKeystrokeLogging
}
>
<ScrollProvider>
@@ -263,8 +263,7 @@ export async function startInteractiveUI(
patchConsole: false,
alternateBuffer: useAlternateBuffer,
incrementalRendering:
settings.merged.ui?.incrementalRendering !== false &&
useAlternateBuffer,
settings.merged.ui.incrementalRendering !== false && useAlternateBuffer,
},
);
@@ -336,13 +335,13 @@ export async function main() {
registerCleanup(consolePatcher.cleanup);
dns.setDefaultResultOrder(
validateDnsResolutionOrder(settings.merged.advanced?.dnsResolutionOrder),
validateDnsResolutionOrder(settings.merged.advanced.dnsResolutionOrder),
);
// Set a default auth type if one isn't set or is set to a legacy type
if (
!settings.merged.security?.auth?.selectedType ||
settings.merged.security?.auth?.selectedType === AuthType.LEGACY_CLOUD_SHELL
!settings.merged.security.auth.selectedType ||
settings.merged.security.auth.selectedType === AuthType.LEGACY_CLOUD_SHELL
) {
if (
process.env['CLOUD_SHELL'] === 'true' ||
@@ -364,8 +363,8 @@ export async function main() {
// the sandbox because the sandbox will interfere with the Oauth2 web
// redirect.
if (
settings.merged.security?.auth?.selectedType &&
!settings.merged.security?.auth?.useExternal
settings.merged.security.auth.selectedType &&
!settings.merged.security.auth.useExternal
) {
try {
if (partialConfig.isInteractive()) {
@@ -381,8 +380,8 @@ export async function main() {
);
} else {
const authType = await validateNonInteractiveAuth(
settings.merged.security?.auth?.selectedType,
settings.merged.security?.auth?.useExternal,
settings.merged.security.auth.selectedType,
settings.merged.security.auth.useExternal,
partialConfig,
settings,
);
@@ -403,7 +402,7 @@ export async function main() {
// hop into sandbox if we are outside and sandboxing is enabled
if (!process.env['SANDBOX']) {
const memoryArgs = settings.merged.advanced?.autoConfigureMemory
const memoryArgs = settings.merged.advanced.autoConfigureMemory
? getNodeMemoryArgs(isDebugMode)
: [];
const sandboxConfig = await loadSandboxConfig(settings.merged, argv);
@@ -506,7 +505,7 @@ export async function main() {
// Handle --list-sessions flag
if (config.getListSessions()) {
// Attempt auth for summary generation (gracefully skips if not configured)
const authType = settings.merged.security?.auth?.selectedType;
const authType = settings.merged.security.auth.selectedType;
if (authType) {
try {
await config.refreshAuth(authType);
@@ -566,7 +565,7 @@ export async function main() {
initAppHandle?.end();
if (
settings.merged.security?.auth?.selectedType ===
settings.merged.security.auth.selectedType ===
AuthType.LOGIN_WITH_GOOGLE &&
config.isBrowserLaunchSuppressed()
) {
@@ -678,8 +677,8 @@ export async function main() {
);
const authType = await validateNonInteractiveAuth(
settings.merged.security?.auth?.selectedType,
settings.merged.security?.auth?.useExternal,
settings.merged.security.auth.selectedType,
settings.merged.security.auth.useExternal,
config,
settings,
);
@@ -705,14 +704,14 @@ export async function main() {
}
function setWindowTitle(title: string, settings: LoadedSettings) {
if (!settings.merged.ui?.hideWindowTitle) {
if (!settings.merged.ui.hideWindowTitle) {
// Initial state before React loop starts
const windowTitle = computeTerminalTitle({
streamingState: StreamingState.Idle,
isConfirming: false,
folderName: title,
showThoughts: !!settings.merged.ui?.showStatusInTitle,
useDynamicTitle: settings.merged.ui?.dynamicWindowTitle ?? true,
showThoughts: !!settings.merged.ui.showStatusInTitle,
useDynamicTitle: settings.merged.ui.dynamicWindowTitle,
});
writeToStdout(`\x1b]0;${windowTitle}\x07`);
@@ -7,6 +7,7 @@
import { vi } from 'vitest';
import type { CommandContext } from '../ui/commands/types.js';
import type { LoadedSettings } from '../config/settings.js';
import { mergeSettings } from '../config/settings.js';
import type { GitService } from '@google/gemini-cli-core';
import type { SessionStatsState } from '../ui/contexts/SessionContext.js';
@@ -27,6 +28,8 @@ type DeepPartial<T> = T extends object
export const createMockCommandContext = (
overrides: DeepPartial<CommandContext> = {},
): CommandContext => {
const defaultMergedSettings = mergeSettings({}, {}, {}, {}, true);
const defaultMocks: CommandContext = {
invocation: {
raw: '',
@@ -35,7 +38,11 @@ export const createMockCommandContext = (
},
services: {
config: null,
settings: { merged: {} } as LoadedSettings,
settings: {
merged: defaultMergedSettings,
setValue: vi.fn(),
forScope: vi.fn().mockReturnValue({ settings: {} }),
} as unknown as LoadedSettings,
git: undefined as GitService | undefined,
logger: {
log: vi.fn(),
+2 -2
View File
@@ -151,7 +151,7 @@ describe('App', () => {
pendingHistoryItems: [{ type: 'user', text: 'pending item' }],
} as UIState;
mockLoadedSettings.merged.ui = { useAlternateBuffer: true };
mockLoadedSettings.merged.ui.useAlternateBuffer = true;
const { lastFrame } = renderWithProviders(<App />, quittingUIState);
@@ -159,7 +159,7 @@ describe('App', () => {
expect(lastFrame()).toContain('Quitting...');
// Reset settings
mockLoadedSettings.merged.ui = { useAlternateBuffer: false };
mockLoadedSettings.merged.ui.useAlternateBuffer = false;
});
it('should render dialog manager when dialogs are visible', () => {
+33 -18
View File
@@ -82,7 +82,7 @@ vi.mock('@google/gemini-cli-core', async (importOriginal) => {
};
});
import ansiEscapes from 'ansi-escapes';
import type { LoadedSettings } from '../config/settings.js';
import { type LoadedSettings, mergeSettings } from '../config/settings.js';
import type { InitializationResult } from '../core/initializer.js';
import { useQuotaAndFallback } from './hooks/useQuotaAndFallback.js';
import { UIStateContext, type UIState } from './contexts/UIStateContext.js';
@@ -380,14 +380,17 @@ describe('AppContainer State Management', () => {
);
// Mock LoadedSettings
const defaultMergedSettings = mergeSettings({}, {}, {}, {}, true);
mockSettings = {
merged: {
...defaultMergedSettings,
hideBanner: false,
hideFooter: false,
hideTips: false,
showMemoryUsage: false,
theme: 'default',
ui: {
...defaultMergedSettings.ui,
showStatusInTitle: false,
hideWindowTitle: false,
},
@@ -507,8 +510,10 @@ describe('AppContainer State Management', () => {
describe('Settings Integration', () => {
it('handles settings with all display options disabled', async () => {
const defaultMergedSettings = mergeSettings({}, {}, {}, {}, true);
const settingsAllHidden = {
merged: {
...defaultMergedSettings,
hideBanner: true,
hideFooter: true,
hideTips: true,
@@ -526,8 +531,10 @@ describe('AppContainer State Management', () => {
});
it('handles settings with memory usage enabled', async () => {
const defaultMergedSettings = mergeSettings({}, {}, {}, {}, true);
const settingsWithMemory = {
merged: {
...defaultMergedSettings,
hideBanner: false,
hideFooter: false,
hideTips: false,
@@ -574,7 +581,7 @@ describe('AppContainer State Management', () => {
it('handles undefined settings gracefully', async () => {
const undefinedSettings = {
merged: {},
merged: mergeSettings({}, {}, {}, {}, true),
} as LoadedSettings;
let unmount: () => void;
@@ -991,12 +998,13 @@ describe('AppContainer State Management', () => {
it('should update terminal title with Working… when showStatusInTitle is false', () => {
// Arrange: Set up mock settings with showStatusInTitle disabled
const defaultMergedSettings = mergeSettings({}, {}, {}, {}, true);
const mockSettingsWithShowStatusFalse = {
...mockSettings,
merged: {
...mockSettings.merged,
...defaultMergedSettings,
ui: {
...mockSettings.merged.ui,
...defaultMergedSettings.ui,
showStatusInTitle: false,
hideWindowTitle: false,
},
@@ -1073,12 +1081,13 @@ describe('AppContainer State Management', () => {
it('should not update terminal title when hideWindowTitle is true', () => {
// Arrange: Set up mock settings with hideWindowTitle enabled
const defaultMergedSettings = mergeSettings({}, {}, {}, {}, true);
const mockSettingsWithHideTitleTrue = {
...mockSettings,
merged: {
...mockSettings.merged,
...defaultMergedSettings,
ui: {
...mockSettings.merged.ui,
...defaultMergedSettings.ui,
showStatusInTitle: true,
hideWindowTitle: true,
},
@@ -1101,12 +1110,13 @@ describe('AppContainer State Management', () => {
it('should update terminal title with thought subject when in active state', () => {
// Arrange: Set up mock settings with showStatusInTitle enabled
const defaultMergedSettings = mergeSettings({}, {}, {}, {}, true);
const mockSettingsWithTitleEnabled = {
...mockSettings,
merged: {
...mockSettings.merged,
...defaultMergedSettings,
ui: {
...mockSettings.merged.ui,
...defaultMergedSettings.ui,
showStatusInTitle: true,
hideWindowTitle: false,
},
@@ -1143,12 +1153,13 @@ describe('AppContainer State Management', () => {
it('should update terminal title with default text when in Idle state and no thought subject', () => {
// Arrange: Set up mock settings with showStatusInTitle enabled
const defaultMergedSettings = mergeSettings({}, {}, {}, {}, true);
const mockSettingsWithTitleEnabled = {
...mockSettings,
merged: {
...mockSettings.merged,
...defaultMergedSettings,
ui: {
...mockSettings.merged.ui,
...defaultMergedSettings.ui,
showStatusInTitle: true,
hideWindowTitle: false,
},
@@ -1184,12 +1195,13 @@ describe('AppContainer State Management', () => {
it('should update terminal title when in WaitingForConfirmation state with thought subject', async () => {
// Arrange: Set up mock settings with showStatusInTitle enabled
const defaultMergedSettings = mergeSettings({}, {}, {}, {}, true);
const mockSettingsWithTitleEnabled = {
...mockSettings,
merged: {
...mockSettings.merged,
...defaultMergedSettings,
ui: {
...mockSettings.merged.ui,
...defaultMergedSettings.ui,
showStatusInTitle: true,
hideWindowTitle: false,
},
@@ -1392,12 +1404,13 @@ describe('AppContainer State Management', () => {
it('should pad title to exactly 80 characters', () => {
// Arrange: Set up mock settings with showStatusInTitle enabled
const defaultMergedSettings = mergeSettings({}, {}, {}, {}, true);
const mockSettingsWithTitleEnabled = {
...mockSettings,
merged: {
...mockSettings.merged,
...defaultMergedSettings,
ui: {
...mockSettings.merged.ui,
...defaultMergedSettings.ui,
showStatusInTitle: true,
hideWindowTitle: false,
},
@@ -1435,12 +1448,13 @@ describe('AppContainer State Management', () => {
it('should use correct ANSI escape code format', () => {
// Arrange: Set up mock settings with showStatusInTitle enabled
const defaultMergedSettings = mergeSettings({}, {}, {}, {}, true);
const mockSettingsWithTitleEnabled = {
...mockSettings,
merged: {
...mockSettings.merged,
...defaultMergedSettings,
ui: {
...mockSettings.merged.ui,
...defaultMergedSettings.ui,
showStatusInTitle: true,
hideWindowTitle: false,
},
@@ -1802,12 +1816,13 @@ describe('AppContainer State Management', () => {
const setupCopyModeTest = async (isAlternateMode = false) => {
// Update settings for this test run
const defaultMergedSettings = mergeSettings({}, {}, {}, {}, true);
const testSettings = {
...mockSettings,
merged: {
...mockSettings.merged,
...defaultMergedSettings,
ui: {
...mockSettings.merged.ui,
...defaultMergedSettings.ui,
useAlternateBuffer: isAlternateMode,
},
},
+27 -27
View File
@@ -392,8 +392,8 @@ export const AppContainer = (props: AppContainerProps) => {
}, []);
const getPreferredEditor = useCallback(
() => settings.merged.general?.preferredEditor as EditorType,
[settings.merged.general?.preferredEditor],
() => settings.merged.general.preferredEditor as EditorType,
[settings.merged.general.preferredEditor],
);
const buffer = useTextBuffer({
@@ -443,7 +443,7 @@ export const AppContainer = (props: AppContainerProps) => {
useEffect(() => {
if (
!(settings.merged.ui?.hideBanner || config.getScreenReader()) &&
!(settings.merged.ui.hideBanner || config.getScreenReader()) &&
bannerVisible &&
bannerText
) {
@@ -603,17 +603,17 @@ Logging in with Google... Restarting Gemini CLI to continue.
// Check for enforced auth type mismatch
useEffect(() => {
if (
settings.merged.security?.auth?.enforcedType &&
settings.merged.security?.auth.selectedType &&
settings.merged.security?.auth.enforcedType !==
settings.merged.security?.auth.selectedType
settings.merged.security.auth.enforcedType &&
settings.merged.security.auth.selectedType &&
settings.merged.security.auth.enforcedType !==
settings.merged.security.auth.selectedType
) {
onAuthError(
`Authentication is enforced to be ${settings.merged.security?.auth.enforcedType}, but you are currently using ${settings.merged.security?.auth.selectedType}.`,
`Authentication is enforced to be ${settings.merged.security.auth.enforcedType}, but you are currently using ${settings.merged.security.auth.selectedType}.`,
);
} else if (
settings.merged.security?.auth?.selectedType &&
!settings.merged.security?.auth?.useExternal
settings.merged.security.auth.selectedType &&
!settings.merged.security.auth.useExternal
) {
// We skip validation for Gemini API key here because it might be stored
// in the keychain, which we can't check synchronously.
@@ -630,9 +630,9 @@ Logging in with Google... Restarting Gemini CLI to continue.
}
}
}, [
settings.merged.security?.auth?.selectedType,
settings.merged.security?.auth?.enforcedType,
settings.merged.security?.auth?.useExternal,
settings.merged.security.auth.selectedType,
settings.merged.security.auth.enforcedType,
settings.merged.security.auth.useExternal,
onAuthError,
]);
@@ -951,8 +951,8 @@ Logging in with Google... Restarting Gemini CLI to continue.
Math.floor(availableTerminalHeight - SHELL_HEIGHT_PADDING),
1,
),
pager: settings.merged.tools?.shell?.pager,
showColor: settings.merged.tools?.shell?.showColor,
pager: settings.merged.tools.shell.pager,
showColor: settings.merged.tools.shell.showColor,
sanitizationConfig: config.sanitizationConfig,
});
@@ -960,13 +960,13 @@ Logging in with Google... Restarting Gemini CLI to continue.
// Context file names computation
const contextFileNames = useMemo(() => {
const fromSettings = settings.merged.context?.fileName;
const fromSettings = settings.merged.context.fileName;
return fromSettings
? Array.isArray(fromSettings)
? fromSettings
: [fromSettings]
: getAllGeminiMdFilenames();
}, [settings.merged.context?.fileName]);
}, [settings.merged.context.fileName]);
// Initial prompt handling
const initialPrompt = useMemo(() => config.getQuestion(), [config]);
const initialPromptSubmitted = useRef(false);
@@ -1040,7 +1040,7 @@ Logging in with Google... Restarting Gemini CLI to continue.
const shouldShowIdePrompt = Boolean(
currentIDE &&
!config.getIdeMode() &&
!settings.merged.ide?.hasSeenNudge &&
!settings.merged.ide.hasSeenNudge &&
!idePromptAnswered,
);
@@ -1221,7 +1221,7 @@ Logging in with Google... Restarting Gemini CLI to continue.
const { elapsedTime, currentLoadingPhrase } = useLoadingIndicator(
streamingState,
settings.merged.ui?.customWittyPhrases,
settings.merged.ui.customWittyPhrases,
!!activePtyId && !embeddedShellFocused,
lastOutputTime,
retryStatus,
@@ -1237,7 +1237,7 @@ Logging in with Google... Restarting Gemini CLI to continue.
}
// Debug log keystrokes if enabled
if (settings.merged.general?.debugKeystrokeLogging) {
if (settings.merged.general.debugKeystrokeLogging) {
debugLogger.log('[DEBUG] Keystroke:', JSON.stringify(key));
}
@@ -1337,7 +1337,7 @@ Logging in with Google... Restarting Gemini CLI to continue.
cancelOngoingRequest,
activePtyId,
embeddedShellFocused,
settings.merged.general?.debugKeystrokeLogging,
settings.merged.general.debugKeystrokeLogging,
refreshStatic,
setCopyModeEnabled,
copyModeEnabled,
@@ -1351,7 +1351,7 @@ Logging in with Google... Restarting Gemini CLI to continue.
// Update terminal title with Gemini CLI status and thoughts
useEffect(() => {
// Respect hideWindowTitle settings
if (settings.merged.ui?.hideWindowTitle) return;
if (settings.merged.ui.hideWindowTitle) return;
const paddedTitle = computeTerminalTitle({
streamingState,
@@ -1361,8 +1361,8 @@ Logging in with Google... Restarting Gemini CLI to continue.
!!confirmationRequest ||
showShellActionRequired,
folderName: basename(config.getTargetDir()),
showThoughts: !!settings.merged.ui?.showStatusInTitle,
useDynamicTitle: settings.merged.ui?.dynamicWindowTitle ?? true,
showThoughts: !!settings.merged.ui.showStatusInTitle,
useDynamicTitle: settings.merged.ui.dynamicWindowTitle,
});
// Only update the title if it's different from the last value we set
@@ -1377,9 +1377,9 @@ Logging in with Google... Restarting Gemini CLI to continue.
shellConfirmationRequest,
confirmationRequest,
showShellActionRequired,
settings.merged.ui?.showStatusInTitle,
settings.merged.ui?.dynamicWindowTitle,
settings.merged.ui?.hideWindowTitle,
settings.merged.ui.showStatusInTitle,
settings.merged.ui.dynamicWindowTitle,
settings.merged.ui.hideWindowTitle,
config,
stdout,
]);
+7 -7
View File
@@ -152,7 +152,7 @@ describe('AuthDialog', () => {
});
it('filters auth types when enforcedType is set', () => {
props.settings.merged.security!.auth!.enforcedType = AuthType.USE_GEMINI;
props.settings.merged.security.auth.enforcedType = AuthType.USE_GEMINI;
renderWithProviders(<AuthDialog {...props} />);
const items = mockedRadioButtonSelect.mock.calls[0][0].items;
expect(items).toHaveLength(1);
@@ -160,7 +160,7 @@ describe('AuthDialog', () => {
});
it('sets initial index to 0 when enforcedType is set', () => {
props.settings.merged.security!.auth!.enforcedType = AuthType.USE_GEMINI;
props.settings.merged.security.auth.enforcedType = AuthType.USE_GEMINI;
renderWithProviders(<AuthDialog {...props} />);
const { initialIndex } = mockedRadioButtonSelect.mock.calls[0][0];
expect(initialIndex).toBe(0);
@@ -170,7 +170,7 @@ describe('AuthDialog', () => {
it.each([
{
setup: () => {
props.settings.merged.security!.auth!.selectedType =
props.settings.merged.security.auth.selectedType =
AuthType.USE_VERTEX_AI;
},
expected: AuthType.USE_VERTEX_AI,
@@ -290,7 +290,7 @@ describe('AuthDialog', () => {
mockedValidateAuthMethod.mockReturnValue(null);
process.env['GEMINI_API_KEY'] = 'test-key-from-env';
// Simulate that the user has already authenticated once
props.settings.merged.security!.auth!.selectedType =
props.settings.merged.security.auth.selectedType =
AuthType.LOGIN_WITH_GOOGLE;
renderWithProviders(<AuthDialog {...props} />);
@@ -349,7 +349,7 @@ describe('AuthDialog', () => {
{
desc: 'calls onAuthError on escape if no auth method is set',
setup: () => {
props.settings.merged.security!.auth!.selectedType = undefined;
props.settings.merged.security.auth.selectedType = undefined;
},
expectations: (p: typeof props) => {
expect(p.onAuthError).toHaveBeenCalledWith(
@@ -360,7 +360,7 @@ describe('AuthDialog', () => {
{
desc: 'calls setAuthState(Unauthenticated) on escape if auth method is set',
setup: () => {
props.settings.merged.security!.auth!.selectedType =
props.settings.merged.security.auth.selectedType =
AuthType.USE_GEMINI;
},
expectations: (p: typeof props) => {
@@ -392,7 +392,7 @@ describe('AuthDialog', () => {
});
it('renders correctly with enforced auth type', () => {
props.settings.merged.security!.auth!.enforcedType = AuthType.USE_GEMINI;
props.settings.merged.security.auth.enforcedType = AuthType.USE_GEMINI;
const { lastFrame } = renderWithProviders(<AuthDialog {...props} />);
expect(lastFrame()).toMatchSnapshot();
});
+5 -5
View File
@@ -78,9 +78,9 @@ export function AuthDialog({
},
];
if (settings.merged.security?.auth?.enforcedType) {
if (settings.merged.security.auth.enforcedType) {
items = items.filter(
(item) => item.value === settings.merged.security?.auth?.enforcedType,
(item) => item.value === settings.merged.security.auth.enforcedType,
);
}
@@ -94,7 +94,7 @@ export function AuthDialog({
}
let initialAuthIndex = items.findIndex((item) => {
if (settings.merged.security?.auth?.selectedType) {
if (settings.merged.security.auth.selectedType) {
return item.value === settings.merged.security.auth.selectedType;
}
@@ -108,7 +108,7 @@ export function AuthDialog({
return item.value === AuthType.LOGIN_WITH_GOOGLE;
});
if (settings.merged.security?.auth?.enforcedType) {
if (settings.merged.security.auth.enforcedType) {
initialAuthIndex = 0;
}
@@ -171,7 +171,7 @@ export function AuthDialog({
if (authError) {
return;
}
if (settings.merged.security?.auth?.selectedType === undefined) {
if (settings.merged.security.auth.selectedType === undefined) {
// Prevent exiting if no auth method is set
onAuthError(
'You must select an auth method to proceed. Press Ctrl+C twice to exit.',
+3 -3
View File
@@ -20,11 +20,11 @@ export function validateAuthMethodWithSettings(
authType: AuthType,
settings: LoadedSettings,
): string | null {
const enforcedType = settings.merged.security?.auth?.enforcedType;
const enforcedType = settings.merged.security.auth.enforcedType;
if (enforcedType && enforcedType !== authType) {
return `Authentication is enforced to be ${enforcedType}, but you are currently using ${authType}.`;
}
if (settings.merged.security?.auth?.useExternal) {
if (settings.merged.security.auth.useExternal) {
return null;
}
// If using Gemini API key, we don't validate it here as we might need to prompt for it.
@@ -80,7 +80,7 @@ export const useAuthCommand = (settings: LoadedSettings, config: Config) => {
return;
}
const authType = settings.merged.security?.auth?.selectedType;
const authType = settings.merged.security.auth.selectedType;
if (!authType) {
if (process.env['GEMINI_API_KEY']) {
onAuthError(
+1 -1
View File
@@ -33,7 +33,7 @@ export const aboutCommand: SlashCommand = {
const modelVersion = context.services.config?.getModel() || 'Unknown';
const cliVersion = await getVersion();
const selectedAuthType =
context.services.settings.merged.security?.auth?.selectedType || '';
context.services.settings.merged.security.auth.selectedType || '';
const gcpProject = process.env['GOOGLE_CLOUD_PROJECT'] || '';
const ideClient = await getIdeClientName(context);
@@ -7,7 +7,7 @@
import { vi, describe, it, expect, beforeEach } from 'vitest';
import { agentsCommand } from './agentsCommand.js';
import { createMockCommandContext } from '../../test-utils/mockCommandContext.js';
import type { Config, AgentOverride } from '@google/gemini-cli-core';
import type { Config } from '@google/gemini-cli-core';
import type { LoadedSettings } from '../../config/settings.js';
import { MessageType } from '../types.js';
import { enableAgent, disableAgent } from '../../utils/agentSettings.js';
@@ -148,12 +148,9 @@ describe('agentsCommand', () => {
reload: reloadSpy,
});
// Add agent to disabled overrides so validation passes
(
mockContext.services.settings.merged.agents!.overrides as Record<
string,
AgentOverride
>
)['test-agent'] = { disabled: true };
mockContext.services.settings.merged.agents.overrides['test-agent'] = {
disabled: true,
};
vi.mocked(enableAgent).mockReturnValue({
status: 'success',
@@ -266,12 +263,9 @@ describe('agentsCommand', () => {
it('should show info message if agent is already disabled', async () => {
mockConfig.getAgentRegistry().getAllAgentNames.mockReturnValue([]);
(
mockContext.services.settings.merged.agents!.overrides as Record<
string,
AgentOverride
>
)['test-agent'] = { disabled: true };
mockContext.services.settings.merged.agents.overrides['test-agent'] = {
disabled: true,
};
const disableCommand = agentsCommand.subCommands?.find(
(cmd) => cmd.name === 'disable',
+3 -13
View File
@@ -12,7 +12,6 @@ import type {
import { CommandKind } from './types.js';
import { MessageType, type HistoryItemAgentsList } from '../types.js';
import { SettingScope } from '../../config/settings.js';
import type { AgentOverride } from '@google/gemini-cli-core';
import { disableAgent, enableAgent } from '../../utils/agentSettings.js';
import { renderAgentActionFeedback } from '../../utils/agentUtils.js';
@@ -84,10 +83,7 @@ async function enableAction(
}
const allAgents = agentRegistry.getAllAgentNames();
const overrides = (settings.merged.agents?.overrides ?? {}) as Record<
string,
AgentOverride
>;
const overrides = settings.merged.agents.overrides;
const disabledAgents = Object.keys(overrides).filter(
(name) => overrides[name]?.disabled === true,
);
@@ -157,10 +153,7 @@ async function disableAction(
}
const allAgents = agentRegistry.getAllAgentNames();
const overrides = (settings.merged.agents?.overrides ?? {}) as Record<
string,
AgentOverride
>;
const overrides = settings.merged.agents.overrides;
const disabledAgents = Object.keys(overrides).filter(
(name) => overrides[name]?.disabled === true,
);
@@ -211,10 +204,7 @@ function completeAgentsToEnable(context: CommandContext, partialArg: string) {
const { config, settings } = context.services;
if (!config) return [];
const overrides = (settings.merged.agents?.overrides ?? {}) as Record<
string,
AgentOverride
>;
const overrides = settings.merged.agents.overrides;
const disabledAgents = Object.entries(overrides)
.filter(([_, override]) => override?.disabled === true)
.map(([name]) => name);
@@ -271,9 +271,10 @@ describe('hooksCommand', () => {
it('should enable a hook and update settings', async () => {
// Update the context's settings with disabled hooks
mockContext.services.settings.merged.hooks = {
disabled: ['test-hook', 'other-hook'],
};
mockContext.services.settings.merged.hooks.disabled = [
'test-hook',
'other-hook',
];
const enableCmd = hooksCommand.subCommands!.find(
(cmd) => cmd.name === 'enable',
@@ -401,9 +402,7 @@ describe('hooksCommand', () => {
});
it('should disable a hook and update settings', async () => {
mockContext.services.settings.merged.hooks = {
disabled: [],
};
mockContext.services.settings.merged.hooks.disabled = [];
const disableCmd = hooksCommand.subCommands!.find(
(cmd) => cmd.name === 'disable',
@@ -432,9 +431,7 @@ describe('hooksCommand', () => {
it('should return info when hook is already disabled', async () => {
// Update the context's settings with the hook already disabled
mockContext.services.settings.merged.hooks = {
disabled: ['test-hook'],
};
mockContext.services.settings.merged.hooks.disabled = ['test-hook'];
const disableCmd = hooksCommand.subCommands!.find(
(cmd) => cmd.name === 'disable',
@@ -455,9 +452,7 @@ describe('hooksCommand', () => {
});
it('should handle error when disabling hook fails', async () => {
mockContext.services.settings.merged.hooks = {
disabled: [],
};
mockContext.services.settings.merged.hooks.disabled = [];
mockSettings.setValue.mockImplementationOnce(() => {
throw new Error('Failed to save settings');
});
+2 -4
View File
@@ -76,8 +76,7 @@ async function enableAction(
// Get current disabled hooks from settings
const settings = context.services.settings;
const disabledHooks = settings.merged.hooks?.disabled || ([] as string[]);
const disabledHooks = settings.merged.hooks.disabled;
// Remove from disabled list if present
const newDisabledHooks = disabledHooks.filter(
(name: string) => name !== hookName,
@@ -143,8 +142,7 @@ async function disableAction(
// Get current disabled hooks from settings
const settings = context.services.settings;
const disabledHooks = settings.merged.hooks?.disabled || ([] as string[]);
const disabledHooks = settings.merged.hooks.disabled;
// Add to disabled list if not already present
if (!disabledHooks.includes(hookName)) {
const newDisabledHooks = [...disabledHooks, hookName];
@@ -72,6 +72,7 @@ describe('policiesCommand', () => {
{
decision: PolicyDecision.ALLOW,
argsPattern: /safe/,
source: 'test.toml',
},
{
decision: PolicyDecision.ASK_USER,
@@ -101,7 +102,9 @@ describe('policiesCommand', () => {
expect(content).toContain(
'1. **DENY** tool: `dangerousTool` [Priority: 10]',
);
expect(content).toContain('2. **ALLOW** all tools (args match: `safe`)');
expect(content).toContain(
'2. **ALLOW** all tools (args match: `safe`) [Source: `test.toml`]',
);
expect(content).toContain('3. **ASK_USER** all tools');
});
});
@@ -53,6 +53,9 @@ const listPoliciesCommand: SlashCommand = {
if (rule.priority !== undefined) {
content += ` [Priority: ${rule.priority}]`;
}
if (rule.source) {
content += ` [Source: \`${rule.source}\`]`;
}
content += '\n';
});
+2 -2
View File
@@ -26,7 +26,7 @@ export const AppHeader = ({ version }: AppHeaderProps) => {
return (
<Box flexDirection="column">
{!(settings.merged.ui?.hideBanner || config.getScreenReader()) && (
{!(settings.merged.ui.hideBanner || config.getScreenReader()) && (
<>
<Header version={version} nightly={nightly} />
{bannerVisible && bannerText && (
@@ -38,7 +38,7 @@ export const AppHeader = ({ version }: AppHeaderProps) => {
)}
</>
)}
{!(settings.merged.ui?.hideTips || config.getScreenReader()) && (
{!(settings.merged.ui.hideTips || config.getScreenReader()) && (
<Tips config={config} />
)}
</Box>
@@ -24,6 +24,7 @@ vi.mock('../contexts/VimModeContext.js', () => ({
}));
import { ApprovalMode } from '@google/gemini-cli-core';
import { StreamingState } from '../types.js';
import { mergeSettings } from '../../config/settings.js';
// Mock child components
vi.mock('./LoadingIndicator.js', () => ({
@@ -163,13 +164,20 @@ const createMockConfig = (overrides = {}) => ({
...overrides,
});
const createMockSettings = (merged = {}) => ({
merged: {
hideFooter: false,
showMemoryUsage: false,
...merged,
},
});
const createMockSettings = (merged = {}) => {
const defaultMergedSettings = mergeSettings({}, {}, {}, {}, true);
return {
merged: {
...defaultMergedSettings,
ui: {
...defaultMergedSettings.ui,
hideFooter: false,
showMemoryUsage: false,
...merged,
},
},
};
};
/* eslint-disable @typescript-eslint/no-explicit-any */
const renderComposer = (
+2 -4
View File
@@ -82,9 +82,7 @@ export const Composer = () => {
<Box
marginTop={1}
justifyContent={
settings.merged.ui?.hideContextSummary
? 'flex-start'
: 'space-between'
settings.merged.ui.hideContextSummary ? 'flex-start' : 'space-between'
}
width="100%"
flexDirection={isNarrow ? 'column' : 'row'}
@@ -153,7 +151,7 @@ export const Composer = () => {
/>
)}
{!settings.merged.ui?.hideFooter && !isScreenReaderEnabled && <Footer />}
{!settings.merged.ui.hideFooter && !isScreenReaderEnabled && <Footer />}
</Box>
);
};
@@ -124,12 +124,12 @@ export function EditorSettingsDialog({
let mergedEditorName = 'None';
if (
settings.merged.general?.preferredEditor &&
isEditorAvailable(settings.merged.general?.preferredEditor)
settings.merged.general.preferredEditor &&
isEditorAvailable(settings.merged.general.preferredEditor)
) {
mergedEditorName =
EDITOR_DISPLAY_NAMES[
settings.merged.general?.preferredEditor as EditorType
settings.merged.general.preferredEditor as EditorType
];
}
+5 -6
View File
@@ -59,12 +59,11 @@ export const Footer: React.FC = () => {
};
const showMemoryUsage =
config.getDebugMode() || settings.merged.ui?.showMemoryUsage || false;
const hideCWD = settings.merged.ui?.footer?.hideCWD;
const hideSandboxStatus = settings.merged.ui?.footer?.hideSandboxStatus;
const hideModelInfo = settings.merged.ui?.footer?.hideModelInfo;
const hideContextPercentage =
settings.merged.ui?.footer?.hideContextPercentage;
config.getDebugMode() || settings.merged.ui.showMemoryUsage;
const hideCWD = settings.merged.ui.footer.hideCWD;
const hideSandboxStatus = settings.merged.ui.footer.hideSandboxStatus;
const hideModelInfo = settings.merged.ui.footer.hideModelInfo;
const hideContextPercentage = settings.merged.ui.footer.hideContextPercentage;
const pathLength = Math.max(20, Math.floor(mainAreaWidth * 0.25));
const displayPath = shortenPath(tildeifyPath(targetDir), pathLength);
@@ -52,14 +52,11 @@ export const StatusDisplay: React.FC<StatusDisplayProps> = ({
return <Text color={theme.status.error}>{uiState.queueErrorMessage}</Text>;
}
if (
uiState.activeHooks.length > 0 &&
(settings.merged.hooks?.notifications ?? true)
) {
if (uiState.activeHooks.length > 0 && settings.merged.hooks.notifications) {
return <HookStatusDisplay activeHooks={uiState.activeHooks} />;
}
if (!settings.merged.ui?.hideContextSummary && !hideContextSummary) {
if (!settings.merged.ui.hideContextSummary && !hideContextSummary) {
return (
<ContextSummaryDisplay
ideContext={uiState.ideContextState}
@@ -95,7 +95,7 @@ export function ThemeDialog({
const [highlightedThemeName, setHighlightedThemeName] = useState<string>(
() => {
// If a theme is already set, use it.
if (settings.merged.ui?.theme) {
if (settings.merged.ui.theme) {
return settings.merged.ui.theme;
}
@@ -113,7 +113,7 @@ export function ThemeDialog({
const customThemes =
selectedScope === SettingScope.User
? settings.user.settings.ui?.customThemes || {}
: settings.merged.ui?.customThemes || {};
: settings.merged.ui.customThemes;
const builtInThemes = themeManager
.getAvailableThemes()
.filter((theme) => theme.type !== 'custom');
@@ -42,7 +42,7 @@ export const ToolConfirmationMessage: React.FC<
const settings = useSettings();
const allowPermanentApproval =
settings.merged.security?.enablePermanentToolApproval ?? false;
settings.merged.security.enablePermanentToolApproval;
const [ideClient, setIdeClient] = useState<IdeClient | null>(null);
const [isDiffingEnabled, setIsDiffingEnabled] = useState(false);
@@ -0,0 +1,93 @@
/**
* @license
* Copyright 2026 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import { describe, it, expect, afterEach, vi } from 'vitest';
import { act } from 'react';
import { renderHook } from '../../../test-utils/render.js';
import { useTextBuffer } from './text-buffer.js';
import { parseInputForHighlighting } from '../../utils/highlight.js';
describe('text-buffer performance', () => {
afterEach(() => {
vi.restoreAllMocks();
});
it('should handle pasting large amounts of text efficiently', () => {
const viewport = { width: 80, height: 24 };
const { result } = renderHook(() =>
useTextBuffer({
viewport,
isValidPath: () => false,
}),
);
const lines = 5000;
const largeText = Array.from(
{ length: lines },
(_, i) =>
`Line ${i}: some sample text with many @path/to/image${i}.png and maybe some more @path/to/another/image.png references to trigger regex. This line is much longer than the previous one to test wrapping.`,
).join('\n');
const start = Date.now();
act(() => {
result.current.insert(largeText, { paste: true });
});
const end = Date.now();
const duration = end - start;
expect(duration).toBeLessThan(5000);
});
it('should handle character-by-character insertion in a large buffer efficiently', () => {
const lines = 5000;
const initialText = Array.from(
{ length: lines },
(_, i) => `Line ${i}: some sample text with @path/to/image.png`,
).join('\n');
const viewport = { width: 80, height: 24 };
const { result } = renderHook(() =>
useTextBuffer({
initialText,
viewport,
isValidPath: () => false,
}),
);
const start = Date.now();
const charsToInsert = 100;
for (let i = 0; i < charsToInsert; i++) {
act(() => {
result.current.insert('a');
});
}
const end = Date.now();
const duration = end - start;
expect(duration).toBeLessThan(5000);
});
it('should highlight many lines efficiently', () => {
const lines = 5000;
const sampleLines = Array.from(
{ length: lines },
(_, i) =>
`Line ${i}: some sample text with @path/to/image${i}.png /command and more @file.txt`,
);
const start = Date.now();
for (let i = 0; i < 100; i++) {
// Simulate 100 renders
for (const line of sampleLines.slice(0, 20)) {
// 20 visible lines
parseInputForHighlighting(line, 1, []);
}
}
const end = Date.now();
const duration = end - start;
expect(duration).toBeLessThan(500);
});
});
@@ -4,10 +4,13 @@
* SPDX-License-Identifier: Apache-2.0
*/
import { describe, it, expect, beforeEach } from 'vitest';
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
import stripAnsi from 'strip-ansi';
import { act } from 'react';
import { renderHook } from '../../../test-utils/render.js';
import {
renderHook,
renderHookWithProviders,
} from '../../../test-utils/render.js';
import type {
Viewport,
TextBuffer,
@@ -55,6 +58,10 @@ const initialState: TextBufferState = {
};
describe('textBufferReducer', () => {
afterEach(() => {
vi.restoreAllMocks();
});
it('should return the initial state if state is undefined', () => {
const action = { type: 'unknown_action' } as unknown as TextBufferAction;
const state = textBufferReducer(initialState, action);
@@ -381,6 +388,10 @@ describe('useTextBuffer', () => {
viewport = { width: 10, height: 3 }; // Default viewport for tests
});
afterEach(() => {
vi.restoreAllMocks();
});
describe('Initialization', () => {
it('should initialize with empty text and cursor at (0,0) by default', () => {
const { result } = renderHook(() =>
@@ -2371,6 +2382,10 @@ describe('Unicode helper functions', () => {
});
describe('Transformation Utilities', () => {
afterEach(() => {
vi.restoreAllMocks();
});
describe('getTransformedImagePath', () => {
it('should transform a simple image path', () => {
expect(getTransformedImagePath('@test.png')).toBe('[Image test.png]');
@@ -2547,4 +2562,125 @@ describe('Transformation Utilities', () => {
expect(result.transformedToLogMap).toEqual([0]); // Just the trailing position
});
});
describe('Layout Caching and Invalidation', () => {
it.each([
{
desc: 'via setText',
actFn: (result: { current: TextBuffer }) =>
result.current.setText('changed line'),
expected: 'changed line',
},
{
desc: 'via replaceRange',
actFn: (result: { current: TextBuffer }) =>
result.current.replaceRange(0, 0, 0, 13, 'changed line'),
expected: 'changed line',
},
])(
'should invalidate cache when line content changes $desc',
({ actFn, expected }) => {
const viewport = { width: 80, height: 24 };
const { result } = renderHookWithProviders(() =>
useTextBuffer({
initialText: 'original line',
viewport,
isValidPath: () => true,
}),
);
const originalLayout = result.current.visualLayout;
act(() => {
actFn(result);
});
expect(result.current.visualLayout).not.toBe(originalLayout);
expect(result.current.allVisualLines[0]).toBe(expected);
},
);
it('should invalidate cache when viewport width changes', () => {
const viewport = { width: 80, height: 24 };
const { result, rerender } = renderHookWithProviders(
({ vp }) =>
useTextBuffer({
initialText:
'a very long line that will wrap when the viewport is small',
viewport: vp,
isValidPath: () => true,
}),
{ initialProps: { vp: viewport } },
);
const originalLayout = result.current.visualLayout;
// Shrink viewport to force wrapping change
rerender({ vp: { width: 10, height: 24 } });
expect(result.current.visualLayout).not.toBe(originalLayout);
expect(result.current.allVisualLines.length).toBeGreaterThan(1);
});
it('should correctly handle cursor expansion/collapse in cached layout', () => {
const viewport = { width: 80, height: 24 };
const text = 'Check @image.png here';
const { result } = renderHookWithProviders(() =>
useTextBuffer({
initialText: text,
viewport,
isValidPath: () => true,
}),
);
// Cursor at start (collapsed)
act(() => {
result.current.moveToOffset(0);
});
expect(result.current.allVisualLines[0]).toContain('[Image image.png]');
// Move cursor onto the @path (expanded)
act(() => {
result.current.moveToOffset(7); // onto @
});
expect(result.current.allVisualLines[0]).toContain('@image.png');
expect(result.current.allVisualLines[0]).not.toContain(
'[Image image.png]',
);
// Move cursor away (collapsed again)
act(() => {
result.current.moveToOffset(0);
});
expect(result.current.allVisualLines[0]).toContain('[Image image.png]');
});
it('should reuse cache for unchanged lines during editing', () => {
const viewport = { width: 80, height: 24 };
const initialText = 'line 1\nline 2\nline 3';
const { result } = renderHookWithProviders(() =>
useTextBuffer({
initialText,
viewport,
isValidPath: () => true,
}),
);
const layout1 = result.current.visualLayout;
// Edit line 1
act(() => {
result.current.moveToOffset(0);
result.current.insert('X');
});
const layout2 = result.current.visualLayout;
expect(layout2).not.toBe(layout1);
// Verify that visual lines for line 2 and 3 (indices 1 and 2 in visualLines)
// are identical in content if not in object reference (the arrays are rebuilt, but contents are cached)
expect(result.current.allVisualLines[1]).toBe('line 2');
expect(result.current.allVisualLines[2]).toBe('line 3');
});
});
});
@@ -18,6 +18,7 @@ import {
type EditorType,
getEditorCommand,
isGuiEditor,
LruCache,
} from '@google/gemini-cli-core';
import {
toCodePoints,
@@ -31,6 +32,7 @@ import type { Key } from '../../contexts/KeypressContext.js';
import { keyMatchers, Command } from '../../keyMatchers.js';
import type { VimAction } from './vim-buffer-actions.js';
import { handleVimAction } from './vim-buffer-actions.js';
import { LRU_BUFFER_PERF_CACHE_LIMIT } from '../../constants.js';
export type Direction =
| 'left'
@@ -726,9 +728,18 @@ export function getTransformedImagePath(filePath: string): string {
return `[Image ${truncatedBase}${extension}]`;
}
const transformationsCache = new LruCache<string, Transformation[]>(
LRU_BUFFER_PERF_CACHE_LIMIT,
);
export function calculateTransformationsForLine(
line: string,
): Transformation[] {
const cached = transformationsCache.get(line);
if (cached) {
return cached;
}
const transformations: Transformation[] = [];
let match: RegExpExecArray | null;
@@ -748,6 +759,8 @@ export function calculateTransformationsForLine(
});
}
transformationsCache.set(line, transformations);
return transformations;
}
@@ -845,6 +858,7 @@ export function calculateTransformedLine(
return { transformedLine, transformedToLogMap };
}
export interface VisualLayout {
visualLines: string[];
// For each logical line, an array of [visualLineIndex, startColInLogical]
@@ -858,6 +872,33 @@ export interface VisualLayout {
visualToTransformedMap: number[];
}
// Caches for layout calculation
interface LineLayoutResult {
visualLines: string[];
logicalToVisualMap: Array<[number, number]>;
visualToLogicalMap: Array<[number, number]>;
transformedToLogMap: number[];
visualToTransformedMap: number[];
}
const lineLayoutCache = new LruCache<string, LineLayoutResult>(
LRU_BUFFER_PERF_CACHE_LIMIT,
);
function getLineLayoutCacheKey(
line: string,
viewportWidth: number,
isCursorOnLine: boolean,
cursorCol: number,
): string {
// Most lines (99.9% in a large buffer) are not cursor lines.
// We use a simpler key for them to reduce string allocation overhead.
if (!isCursorOnLine) {
return `${viewportWidth}:N:${line}`;
}
return `${viewportWidth}:C:${cursorCol}:${line}`;
}
// Calculates the visual wrapping of lines and the mapping between logical and visual coordinates.
// This is an expensive operation and should be memoized.
function calculateLayout(
@@ -873,6 +914,34 @@ function calculateLayout(
logicalLines.forEach((logLine, logIndex) => {
logicalToVisualMap[logIndex] = [];
const isCursorOnLine = logIndex === logicalCursor[0];
const cacheKey = getLineLayoutCacheKey(
logLine,
viewportWidth,
isCursorOnLine,
logicalCursor[1],
);
const cached = lineLayoutCache.get(cacheKey);
if (cached) {
const visualLineOffset = visualLines.length;
visualLines.push(...cached.visualLines);
cached.logicalToVisualMap.forEach(([relVisualIdx, logCol]) => {
logicalToVisualMap[logIndex].push([
visualLineOffset + relVisualIdx,
logCol,
]);
});
cached.visualToLogicalMap.forEach(([, logCol]) => {
visualToLogicalMap.push([logIndex, logCol]);
});
transformedToLogicalMaps[logIndex] = cached.transformedToLogMap;
visualToTransformedMap.push(...cached.visualToTransformedMap);
return;
}
// Not in cache, calculate
const transformations = calculateTransformationsForLine(logLine);
const { transformedLine, transformedToLogMap } = calculateTransformedLine(
logLine,
@@ -880,13 +949,18 @@ function calculateLayout(
logicalCursor,
transformations,
);
transformedToLogicalMaps[logIndex] = transformedToLogMap;
const lineVisualLines: string[] = [];
const lineLogicalToVisualMap: Array<[number, number]> = [];
const lineVisualToLogicalMap: Array<[number, number]> = [];
const lineVisualToTransformedMap: number[] = [];
if (transformedLine.length === 0) {
// Handle empty logical line
logicalToVisualMap[logIndex].push([visualLines.length, 0]);
visualToLogicalMap.push([logIndex, 0]);
visualToTransformedMap.push(0);
visualLines.push('');
lineLogicalToVisualMap.push([0, 0]);
lineVisualToLogicalMap.push([logIndex, 0]);
lineVisualToTransformedMap.push(0);
lineVisualLines.push('');
} else {
// Non-empty logical line
let currentPosInLogLine = 0; // Tracks position within the current logical line (code point index)
@@ -929,15 +1003,6 @@ function calculateLayout(
// Single character is wider than viewport, take it anyway
currentChunk = char;
numCodePointsInChunk = 1;
} else if (
numCodePointsInChunk === 0 &&
charVisualWidth <= viewportWidth
) {
// This case should ideally be caught by the next iteration if the char fits.
// If it doesn't fit (because currentChunkVisualWidth was already > 0 from a previous char that filled the line),
// then numCodePointsInChunk would not be 0.
// This branch means the current char *itself* doesn't fit an empty line, which is handled by the above.
// If we are here, it means the loop should break and the current chunk (which is empty) is finalized.
}
}
break; // Break from inner loop to finalize this chunk
@@ -955,55 +1020,57 @@ function calculateLayout(
}
}
// If the inner loop completed without breaking (i.e., remaining text fits)
// or if the loop broke but numCodePointsInChunk is still 0 (e.g. first char too wide for empty line)
if (
numCodePointsInChunk === 0 &&
currentPosInLogLine < codePointsInLogLine.length
) {
// This can happen if the very first character considered for a new visual line is wider than the viewport.
// In this case, we take that single character.
const firstChar = codePointsInLogLine[currentPosInLogLine];
currentChunk = firstChar;
numCodePointsInChunk = 1; // Ensure we advance
}
// If after everything, numCodePointsInChunk is still 0 but we haven't processed the whole logical line,
// it implies an issue, like viewportWidth being 0 or less. Avoid infinite loop.
if (
numCodePointsInChunk === 0 &&
currentPosInLogLine < codePointsInLogLine.length
) {
// Force advance by one character to prevent infinite loop if something went wrong
currentChunk = codePointsInLogLine[currentPosInLogLine];
numCodePointsInChunk = 1;
}
const logicalStartCol = transformedToLogMap[currentPosInLogLine] ?? 0;
logicalToVisualMap[logIndex].push([
visualLines.length,
logicalStartCol,
]);
visualToLogicalMap.push([logIndex, logicalStartCol]);
visualToTransformedMap.push(currentPosInLogLine);
visualLines.push(currentChunk);
lineLogicalToVisualMap.push([lineVisualLines.length, logicalStartCol]);
lineVisualToLogicalMap.push([logIndex, logicalStartCol]);
lineVisualToTransformedMap.push(currentPosInLogLine);
lineVisualLines.push(currentChunk);
const logicalStartOfThisChunk = currentPosInLogLine;
currentPosInLogLine += numCodePointsInChunk;
// If the chunk processed did not consume the entire logical line,
// and the character immediately following the chunk is a space,
// advance past this space as it acted as a delimiter for word wrapping.
if (
logicalStartOfThisChunk + numCodePointsInChunk <
codePointsInLogLine.length &&
currentPosInLogLine < codePointsInLogLine.length && // Redundant if previous is true, but safe
currentPosInLogLine < codePointsInLogLine.length &&
codePointsInLogLine[currentPosInLogLine] === ' '
) {
currentPosInLogLine++;
}
}
}
// Cache the result for this line
lineLayoutCache.set(cacheKey, {
visualLines: lineVisualLines,
logicalToVisualMap: lineLogicalToVisualMap,
visualToLogicalMap: lineVisualToLogicalMap,
transformedToLogMap,
visualToTransformedMap: lineVisualToTransformedMap,
});
const visualLineOffset = visualLines.length;
visualLines.push(...lineVisualLines);
lineLogicalToVisualMap.forEach(([relVisualIdx, logCol]) => {
logicalToVisualMap[logIndex].push([
visualLineOffset + relVisualIdx,
logCol,
]);
});
lineVisualToLogicalMap.forEach(([, logCol]) => {
visualToLogicalMap.push([logIndex, logCol]);
});
transformedToLogicalMaps[logIndex] = transformedToLogMap;
visualToTransformedMap.push(...lineVisualToTransformedMap);
});
// If the entire logical text was empty, ensure there's one empty visual line.
@@ -2378,6 +2445,7 @@ export function useTextBuffer({
transformedToLogicalMaps,
visualToTransformedMap,
transformationsByLine,
visualLayout,
setText,
insert,
newline,
@@ -2447,6 +2515,7 @@ export function useTextBuffer({
transformedToLogicalMaps,
visualToTransformedMap,
transformationsByLine,
visualLayout,
setText,
insert,
newline,
@@ -2540,6 +2609,7 @@ export interface TextBuffer {
visualToTransformedMap: number[];
/** Cached transformations per logical line */
transformationsByLine: Transformation[][];
visualLayout: VisualLayout;
// Actions
+1
View File
@@ -32,3 +32,4 @@ export const MAX_MCP_RESOURCES_TO_SHOW = 10;
export const WARNING_PROMPT_DURATION_MS = 1000;
export const QUEUE_ERROR_DISPLAY_DURATION_MS = 3000;
export const SHELL_ACTION_REQUIRED_TITLE_DELAY_MS = 30000;
export const LRU_BUFFER_PERF_CACHE_LIMIT = 20000;
@@ -32,7 +32,7 @@ export const VimModeProvider = ({
children: React.ReactNode;
settings: LoadedSettings;
}) => {
const initialVimEnabled = settings.merged.general?.vimMode ?? false;
const initialVimEnabled = settings.merged.general.vimMode;
const [vimEnabled, setVimEnabled] = useState(initialVimEnabled);
const [vimMode, setVimMode] = useState<VimMode>(
initialVimEnabled ? 'NORMAL' : 'INSERT',
@@ -40,13 +40,13 @@ export const VimModeProvider = ({
useEffect(() => {
// Initialize vimEnabled from settings on mount
const enabled = settings.merged.general?.vimMode ?? false;
const enabled = settings.merged.general.vimMode;
setVimEnabled(enabled);
// When vim mode is enabled, always start in NORMAL mode
if (enabled) {
setVimMode('NORMAL');
}
}, [settings.merged.general?.vimMode]);
}, [settings.merged.general.vimMode]);
const toggleVimEnabled = useCallback(async () => {
const newValue = !vimEnabled;
@@ -28,6 +28,9 @@ import {
ToolConfirmationOutcome,
Storage,
IdeClient,
addMCPStatusChangeListener,
removeMCPStatusChangeListener,
MCPDiscoveryState,
} from '@google/gemini-cli-core';
import { useSessionStats } from '../contexts/SessionContext.js';
import type {
@@ -269,6 +272,10 @@ export const useSlashCommandProcessor = (
ideClient.addStatusChangeListener(listener);
})();
// Listen for MCP server status changes (e.g. connection, discovery completion)
// to reload slash commands (since they may include MCP prompts).
addMCPStatusChangeListener(listener);
// TODO: Ideally this would happen more directly inside the ExtensionLoader,
// but the CommandService today is not conducive to that since it isn't a
// long lived service but instead gets fully re-created based on reload
@@ -289,6 +296,7 @@ export const useSlashCommandProcessor = (
const ideClient = await IdeClient.getInstance();
ideClient.removeStatusChangeListener(listener);
})();
removeMCPStatusChangeListener(listener);
appEvents.off('extensionsStarting', extensionEventListener);
appEvents.off('extensionsStopping', extensionEventListener);
};
@@ -572,9 +580,16 @@ export const useSlashCommandProcessor = (
}
}
const isMcpLoading =
config?.getMcpClientManager()?.getDiscoveryState() ===
MCPDiscoveryState.IN_PROGRESS;
const errorMessage = isMcpLoading
? `Unknown command: ${trimmed}. Command might have been from an MCP server but MCP servers are not done loading.`
: `Unknown command: ${trimmed}`;
addMessage({
type: MessageType.ERROR,
content: `Unknown command: ${trimmed}`,
content: errorMessage,
timestamp: new Date(),
});
@@ -8,7 +8,7 @@ import { useSettings } from '../contexts/SettingsContext.js';
import type { LoadedSettings } from '../../config/settings.js';
export const isAlternateBufferEnabled = (settings: LoadedSettings): boolean =>
settings.merged.ui?.useAlternateBuffer === true;
settings.merged.ui.useAlternateBuffer === true;
export const useAlternateBuffer = (): boolean => {
const settings = useSettings();
@@ -5,6 +5,7 @@
*/
import { useEffect, useReducer, useRef } from 'react';
import { setTimeout as setTimeoutPromise } from 'node:timers/promises';
import type { Config, FileSearch } from '@google/gemini-cli-core';
import { FileSearchFactory, escapePath } from '@google/gemini-cli-core';
import type { Suggestion } from '../components/SuggestionsDisplay.js';
@@ -12,6 +13,8 @@ import { MAX_SUGGESTIONS_TO_SHOW } from '../components/SuggestionsDisplay.js';
import { CommandKind } from '../commands/types.js';
import { AsyncFzf } from 'fzf';
const DEFAULT_SEARCH_TIMEOUT_MS = 5000;
export enum AtCompletionStatus {
IDLE = 'idle',
INITIALIZING = 'initializing',
@@ -257,6 +260,7 @@ export function useAtCompletion(props: UseAtCompletionProps): void {
config?.getEnableRecursiveFileSearch() ?? true,
disableFuzzySearch:
config?.getFileFilteringDisableFuzzySearch() ?? false,
maxFiles: config?.getFileFilteringOptions()?.maxFileCount,
});
await searcher.initialize();
fileSearch.current = searcher;
@@ -285,6 +289,22 @@ export function useAtCompletion(props: UseAtCompletionProps): void {
dispatch({ type: 'SET_LOADING', payload: true });
}, 200);
const timeoutMs =
config?.getFileFilteringOptions()?.searchTimeout ??
DEFAULT_SEARCH_TIMEOUT_MS;
// eslint-disable-next-line @typescript-eslint/no-floating-promises
(async () => {
try {
await setTimeoutPromise(timeoutMs, undefined, {
signal: controller.signal,
});
controller.abort();
} catch {
// ignore
}
})();
try {
const results = await fileSearch.current.search(state.pattern, {
signal: controller.signal,
@@ -332,6 +352,8 @@ export function useAtCompletion(props: UseAtCompletionProps): void {
if (!(error instanceof Error && error.name === 'AbortError')) {
dispatch({ type: 'ERROR' });
}
} finally {
controller.abort();
}
};
+1 -1
View File
@@ -27,7 +27,7 @@ export const useFolderTrust = (
const [isRestarting, setIsRestarting] = useState(false);
const startupMessageSent = useRef(false);
const folderTrust = settings.merged.security?.folderTrust?.enabled;
const folderTrust = settings.merged.security.folderTrust.enabled;
useEffect(() => {
const { isTrusted: trusted } = isWorkspaceTrusted(settings.merged);
@@ -36,6 +36,7 @@ import {
debugLogger,
coreEvents,
CoreEvent,
MCPDiscoveryState,
} from '@google/gemini-cli-core';
import type { Part, PartListUnion } from '@google/genai';
import type { UseHistoryManagerReturn } from './useHistoryManager.js';
@@ -178,6 +179,11 @@ describe('useGeminiStream', () => {
return clientInstance;
});
const mockMcpClientManager = {
getDiscoveryState: vi.fn().mockReturnValue(MCPDiscoveryState.COMPLETED),
getMcpServerCount: vi.fn().mockReturnValue(0),
};
const contentGeneratorConfig = {
model: 'test-model',
apiKey: 'test-key',
@@ -211,6 +217,7 @@ describe('useGeminiStream', () => {
getProjectRoot: vi.fn(() => '/test/dir'),
getCheckpointingEnabled: vi.fn(() => false),
getGeminiClient: mockGetGeminiClient,
getMcpClientManager: () => mockMcpClientManager as any,
getApprovalMode: () => ApprovalMode.DEFAULT,
getUsageStatisticsEnabled: () => true,
getDebugMode: () => false,
@@ -254,6 +261,7 @@ describe('useGeminiStream', () => {
.mockClear()
.mockReturnValue((async function* () {})());
handleAtCommandSpy = vi.spyOn(atCommandProcessor, 'handleAtCommand');
vi.spyOn(coreEvents, 'emitFeedback');
});
const mockLoadedSettings: LoadedSettings = {
@@ -1954,6 +1962,73 @@ describe('useGeminiStream', () => {
});
});
describe('MCP Discovery State', () => {
it('should block non-slash command queries when discovery is in progress and servers exist', async () => {
const mockMcpClientManager = {
getDiscoveryState: vi
.fn()
.mockReturnValue(MCPDiscoveryState.IN_PROGRESS),
getMcpServerCount: vi.fn().mockReturnValue(1),
};
mockConfig.getMcpClientManager = () => mockMcpClientManager as any;
const { result } = renderTestHook();
await act(async () => {
await result.current.submitQuery('test query');
});
expect(coreEvents.emitFeedback).toHaveBeenCalledWith(
'info',
'Waiting for MCP servers to initialize... Slash commands are still available.',
);
expect(mockSendMessageStream).not.toHaveBeenCalled();
});
it('should NOT block queries when discovery is NOT_STARTED but there are no servers', async () => {
const mockMcpClientManager = {
getDiscoveryState: vi
.fn()
.mockReturnValue(MCPDiscoveryState.NOT_STARTED),
getMcpServerCount: vi.fn().mockReturnValue(0),
};
mockConfig.getMcpClientManager = () => mockMcpClientManager as any;
const { result } = renderTestHook();
await act(async () => {
await result.current.submitQuery('test query');
});
expect(coreEvents.emitFeedback).not.toHaveBeenCalledWith(
'info',
'Waiting for MCP servers to initialize... Slash commands are still available.',
);
expect(mockSendMessageStream).toHaveBeenCalled();
});
it('should NOT block slash commands even when discovery is in progress', async () => {
const mockMcpClientManager = {
getDiscoveryState: vi
.fn()
.mockReturnValue(MCPDiscoveryState.IN_PROGRESS),
getMcpServerCount: vi.fn().mockReturnValue(1),
};
mockConfig.getMcpClientManager = () => mockMcpClientManager as any;
const { result } = renderTestHook();
await act(async () => {
await result.current.submitQuery('/help');
});
expect(coreEvents.emitFeedback).not.toHaveBeenCalledWith(
'info',
'Waiting for MCP servers to initialize... Slash commands are still available.',
);
});
});
describe('handleFinishedEvent', () => {
it('should add info message for MAX_TOKENS finish reason', async () => {
// Setup mock to return a stream with MAX_TOKENS finish reason
@@ -3015,4 +3090,68 @@ describe('useGeminiStream', () => {
});
});
});
describe('MCP Server Initialization', () => {
it('should allow slash commands to run while MCP servers are initializing', async () => {
const mockMcpClientManager = {
getDiscoveryState: vi
.fn()
.mockReturnValue(MCPDiscoveryState.IN_PROGRESS),
getMcpServerCount: vi.fn().mockReturnValue(1),
};
mockConfig.getMcpClientManager = () => mockMcpClientManager as any;
const { result } = renderTestHook();
await act(async () => {
await result.current.submitQuery('/help');
});
// Slash command should be handled, and no Gemini call should be made.
expect(mockHandleSlashCommand).toHaveBeenCalledWith('/help');
expect(coreEvents.emitFeedback).not.toHaveBeenCalled();
});
it('should block normal prompts and provide feedback while MCP servers are initializing', async () => {
const mockMcpClientManager = {
getDiscoveryState: vi
.fn()
.mockReturnValue(MCPDiscoveryState.IN_PROGRESS),
getMcpServerCount: vi.fn().mockReturnValue(1),
};
mockConfig.getMcpClientManager = () => mockMcpClientManager as any;
const { result } = renderTestHook();
await act(async () => {
await result.current.submitQuery('a normal prompt');
});
// No slash command, no Gemini call, but feedback should be emitted.
expect(mockHandleSlashCommand).not.toHaveBeenCalled();
expect(mockSendMessageStream).not.toHaveBeenCalled();
expect(coreEvents.emitFeedback).toHaveBeenCalledWith(
'info',
'Waiting for MCP servers to initialize... Slash commands are still available.',
);
});
it('should allow normal prompts to run when MCP servers are finished initializing', async () => {
const mockMcpClientManager = {
getDiscoveryState: vi.fn().mockReturnValue(MCPDiscoveryState.COMPLETED),
getMcpServerCount: vi.fn().mockReturnValue(1),
};
mockConfig.getMcpClientManager = () => mockMcpClientManager as any;
const { result } = renderTestHook();
await act(async () => {
await result.current.submitQuery('a normal prompt');
});
// Prompt should be sent to Gemini.
expect(mockHandleSlashCommand).not.toHaveBeenCalled();
expect(mockSendMessageStream).toHaveBeenCalled();
expect(coreEvents.emitFeedback).not.toHaveBeenCalled();
});
});
});
+25 -4
View File
@@ -30,6 +30,7 @@ import {
ToolErrorType,
coreEvents,
CoreEvent,
MCPDiscoveryState,
} from '@google/gemini-cli-core';
import type {
Config,
@@ -83,7 +84,7 @@ enum StreamProcessingStatus {
}
function showCitations(settings: LoadedSettings): boolean {
const enabled = settings?.merged?.ui?.showCitations;
const enabled = settings.merged.ui.showCitations;
if (enabled !== undefined) {
return enabled;
}
@@ -459,7 +460,7 @@ export const useGeminiStream = (
isClientInitiated: true,
prompt_id,
};
scheduleToolCalls([toolCallRequest], abortSignal);
await scheduleToolCalls([toolCallRequest], abortSignal);
return { queryToSend: null, shouldProceed: false };
}
case 'submit_prompt': {
@@ -782,7 +783,7 @@ export const useGeminiStream = (
const handleChatModelEvent = useCallback(
(eventValue: string, userMessageTimestamp: number) => {
if (!settings?.merged?.ui?.showModelInfoInChat) {
if (!settings.merged.ui.showModelInfoInChat) {
return;
}
if (pendingHistoryItemRef.current) {
@@ -922,7 +923,7 @@ export const useGeminiStream = (
}
}
if (toolCallRequests.length > 0) {
scheduleToolCalls(toolCallRequests, signal);
await scheduleToolCalls(toolCallRequests, signal);
}
return StreamProcessingStatus.Completed;
},
@@ -951,6 +952,26 @@ export const useGeminiStream = (
{ name: 'submitQuery' },
async ({ metadata: spanMetadata }) => {
spanMetadata.input = query;
const discoveryState = config
.getMcpClientManager()
?.getDiscoveryState();
const mcpServerCount =
config.getMcpClientManager()?.getMcpServerCount() ?? 0;
if (
!options?.isContinuation &&
typeof query === 'string' &&
!isSlashCommand(query.trim()) &&
mcpServerCount > 0 &&
discoveryState !== MCPDiscoveryState.COMPLETED
) {
coreEvents.emitFeedback(
'info',
'Waiting for MCP servers to initialize... Slash commands are still available.',
);
return;
}
const queryId = `${Date.now()}-${Math.random()}`;
activeQueryIdRef.current = queryId;
if (
@@ -85,7 +85,7 @@ export const usePermissionsModifyTrust = (
);
const [needsRestart, setNeedsRestart] = useState(false);
const isFolderTrustEnabled = !!settings.merged.security?.folderTrust?.enabled;
const isFolderTrustEnabled = !!settings.merged.security.folderTrust.enabled;
const updateTrustLevel = useCallback(
(trustLevel: TrustLevel) => {
@@ -32,7 +32,7 @@ import { ToolCallStatus } from '../types.js';
export type ScheduleFn = (
request: ToolCallRequestInfo | ToolCallRequestInfo[],
signal: AbortSignal,
) => void;
) => Promise<void>;
export type MarkToolsAsSubmittedFn = (callIds: string[]) => void;
export type TrackedScheduledToolCall = ScheduledToolCall & {
@@ -181,7 +181,7 @@ export function useReactToolScheduler(
signal: AbortSignal,
) => {
setToolCallsForDisplay([]);
void scheduler.schedule(request, signal);
return scheduler.schedule(request, signal);
},
[scheduler, setToolCallsForDisplay],
);
@@ -32,7 +32,7 @@ export function createShowMemoryAction(
const currentMemory = config.getUserMemory();
const fileCount = config.getGeminiMdFileCount();
const contextFileName = settings.merged.context?.fileName;
const contextFileName = settings.merged.context.fileName;
const contextFileNames = Array.isArray(contextFileName)
? contextFileName
: [contextFileName];
+4 -4
View File
@@ -67,7 +67,7 @@ export const useThemeCommand = (
const closeThemeDialog = useCallback(() => {
// Re-apply the saved theme to revert any preview changes from highlighting
applyTheme(loadedSettings.merged.ui?.theme);
applyTheme(loadedSettings.merged.ui.theme);
setIsThemeDialogOpen(false);
}, [applyTheme, loadedSettings]);
@@ -88,10 +88,10 @@ export const useThemeCommand = (
return;
}
loadedSettings.setValue(scope, 'ui.theme', themeName); // Update the merged settings
if (loadedSettings.merged.ui?.customThemes) {
themeManager.loadCustomThemes(loadedSettings.merged.ui?.customThemes);
if (loadedSettings.merged.ui.customThemes) {
themeManager.loadCustomThemes(loadedSettings.merged.ui.customThemes);
}
applyTheme(loadedSettings.merged.ui?.theme); // Apply the current theme
applyTheme(loadedSettings.merged.ui.theme); // Apply the current theme
setThemeError(null);
} finally {
setIsThemeDialogOpen(false); // Close the dialog
@@ -172,8 +172,8 @@ describe('useReactToolScheduler in YOLO Mode', () => {
args: { data: 'any data' },
} as any;
act(() => {
schedule(request, new AbortController().signal);
await act(async () => {
await schedule(request, new AbortController().signal);
});
await act(async () => {
@@ -229,11 +229,11 @@ describe('useReactToolScheduler', () => {
schedule: (
req: ToolCallRequestInfo | ToolCallRequestInfo[],
signal: AbortSignal,
) => void,
) => Promise<void>,
request: ToolCallRequestInfo | ToolCallRequestInfo[],
) => {
act(() => {
schedule(request, new AbortController().signal);
await act(async () => {
await schedule(request, new AbortController().signal);
});
await advanceAndSettle();
@@ -322,10 +322,13 @@ describe('useReactToolScheduler', () => {
it('should clear previous tool calls when scheduling new ones', async () => {
mockToolRegistry.getTool.mockReturnValue(mockTool);
(mockTool.execute as Mock).mockResolvedValue({
llmContent: 'Tool output',
returnDisplay: 'Formatted tool output',
} as ToolResult);
(mockTool.execute as Mock).mockImplementation(async () => {
await new Promise((r) => setTimeout(r, 10));
return {
llmContent: 'Tool output',
returnDisplay: 'Formatted tool output',
};
});
const { result } = renderScheduler();
const schedule = result.current[1];
@@ -346,10 +349,13 @@ describe('useReactToolScheduler', () => {
name: 'mockTool',
args: {},
} as any;
act(() => {
schedule(newRequest, new AbortController().signal);
let schedulePromise: Promise<void>;
await act(async () => {
schedulePromise = schedule(newRequest, new AbortController().signal);
});
await advanceAndSettle();
// After scheduling, the old call should be gone,
// and the new one should be in the display in its initial state.
expect(result.current[0].length).toBe(1);
@@ -358,14 +364,13 @@ describe('useReactToolScheduler', () => {
// Let the new call finish.
await act(async () => {
await vi.advanceTimersByTimeAsync(0);
await vi.advanceTimersByTimeAsync(20);
});
await act(async () => {
await vi.advanceTimersByTimeAsync(0);
});
await act(async () => {
await vi.advanceTimersByTimeAsync(0);
await schedulePromise;
});
expect(onComplete).toHaveBeenCalled();
});
@@ -388,16 +393,14 @@ describe('useReactToolScheduler', () => {
args: {},
} as any;
act(() => {
schedule(request, new AbortController().signal);
});
let schedulePromise: Promise<void>;
await act(async () => {
await vi.advanceTimersByTimeAsync(0);
}); // validation
await act(async () => {
await vi.advanceTimersByTimeAsync(0); // Process scheduling
schedulePromise = schedule(request, new AbortController().signal);
});
await advanceAndSettle(); // validation
await advanceAndSettle(); // Process scheduling
// At this point, the tool is 'executing' and waiting on the promise.
expect(result.current[0][0].status).toBe('executing');
@@ -406,9 +409,7 @@ describe('useReactToolScheduler', () => {
cancelAllToolCalls(cancelController.signal);
});
await act(async () => {
await vi.advanceTimersByTimeAsync(0);
});
await advanceAndSettle();
expect(onComplete).toHaveBeenCalledWith([
expect.objectContaining({
@@ -421,6 +422,11 @@ describe('useReactToolScheduler', () => {
await act(async () => {
resolveExecute({ llmContent: 'output', returnDisplay: 'display' });
});
// Now await the schedule promise
await act(async () => {
await schedulePromise;
});
});
it.each([
@@ -520,8 +526,9 @@ describe('useReactToolScheduler', () => {
args: { data: 'sensitive' },
} as any;
act(() => {
schedule(request, new AbortController().signal);
let schedulePromise: Promise<void>;
await act(async () => {
schedulePromise = schedule(request, new AbortController().signal);
});
await advanceAndSettle();
@@ -535,8 +542,11 @@ describe('useReactToolScheduler', () => {
});
await advanceAndSettle();
await advanceAndSettle();
await advanceAndSettle();
// Now await the schedule promise as it should complete
await act(async () => {
await schedulePromise;
});
expect(mockOnUserConfirmForToolConfirmation).toHaveBeenCalledWith(
ToolConfirmationOutcome.ProceedOnce,
@@ -567,8 +577,9 @@ describe('useReactToolScheduler', () => {
args: {},
} as any;
act(() => {
schedule(request, new AbortController().signal);
let schedulePromise: Promise<void>;
await act(async () => {
schedulePromise = schedule(request, new AbortController().signal);
});
await advanceAndSettle();
@@ -580,8 +591,13 @@ describe('useReactToolScheduler', () => {
await act(async () => {
await capturedOnConfirmForTest?.(ToolConfirmationOutcome.Cancel);
});
await advanceAndSettle();
await advanceAndSettle();
// Now await the schedule promise
await act(async () => {
await schedulePromise;
});
expect(mockOnUserConfirmForToolConfirmation).toHaveBeenCalledWith(
ToolConfirmationOutcome.Cancel,
@@ -628,8 +644,12 @@ describe('useReactToolScheduler', () => {
args: {},
} as any;
act(() => {
result.current[1](request, new AbortController().signal);
let schedulePromise: Promise<void>;
await act(async () => {
schedulePromise = result.current[1](
request,
new AbortController().signal,
);
});
await advanceAndSettle();
@@ -653,7 +673,11 @@ describe('useReactToolScheduler', () => {
} as ToolResult);
});
await advanceAndSettle();
await advanceAndSettle();
// Now await schedule
await act(async () => {
await schedulePromise;
});
const completedCalls = onComplete.mock.calls[0][0] as ToolCall[];
expect(completedCalls[0].status).toBe('success');
@@ -699,8 +723,8 @@ describe('useReactToolScheduler', () => {
{ callId: 'multi2', name: 'tool2', args: { p: 2 } } as any,
];
act(() => {
schedule(requests, new AbortController().signal);
await act(async () => {
await schedule(requests, new AbortController().signal);
});
await act(async () => {
await vi.advanceTimersByTimeAsync(0);
@@ -791,24 +815,30 @@ describe('useReactToolScheduler', () => {
args: {},
} as any;
act(() => {
schedule(request1, new AbortController().signal);
let schedulePromise1: Promise<void>;
let schedulePromise2: Promise<void>;
await act(async () => {
schedulePromise1 = schedule(request1, new AbortController().signal);
});
await act(async () => {
await vi.advanceTimersByTimeAsync(0);
});
act(() => {
schedule(request2, new AbortController().signal);
await act(async () => {
schedulePromise2 = schedule(request2, new AbortController().signal);
});
await act(async () => {
await vi.advanceTimersByTimeAsync(50);
await vi.advanceTimersByTimeAsync(0);
await act(async () => {
await vi.advanceTimersByTimeAsync(0);
});
});
// Wait for first to complete
await act(async () => {
await schedulePromise1;
});
expect(onComplete).toHaveBeenCalledWith([
expect.objectContaining({
status: 'success',
@@ -816,13 +846,17 @@ describe('useReactToolScheduler', () => {
response: expect.objectContaining({ resultDisplay: 'done display' }),
}),
]);
await act(async () => {
await vi.advanceTimersByTimeAsync(50);
await vi.advanceTimersByTimeAsync(0);
await act(async () => {
await vi.advanceTimersByTimeAsync(0);
});
});
// Wait for second to complete
await act(async () => {
await schedulePromise2;
});
expect(onComplete).toHaveBeenCalledWith([
expect.objectContaining({
status: 'success',
+1 -1
View File
@@ -149,7 +149,7 @@ export function colorizeCode({
const activeTheme = theme || themeManager.getActiveTheme();
const showLineNumbers = hideLineNumbers
? false
: (settings?.merged.ui?.showLineNumbers ?? true);
: settings.merged.ui.showLineNumbers;
const useMaxSizedBox = !isAlternateBufferEnabled(settings);
try {
+84 -10
View File
@@ -33,6 +33,7 @@ vi.mock('child_process');
// fs (for /dev/tty)
const mockFs = vi.hoisted(() => ({
createWriteStream: vi.fn(),
constants: { W_OK: 2 },
}));
vi.mock('node:fs', () => ({
default: mockFs,
@@ -63,10 +64,12 @@ const makeWritable = (opts?: { isTTY?: boolean; writeReturn?: boolean }) => {
once: EventEmitter.prototype.once,
on: EventEmitter.prototype.on,
off: EventEmitter.prototype.off,
removeAllListeners: EventEmitter.prototype.removeAllListeners,
}) as unknown as EventEmitter & {
write: Mock;
end: Mock;
isTTY?: boolean;
removeAllListeners: Mock;
};
return stream;
};
@@ -125,9 +128,11 @@ describe('commandUtils', () => {
// Setup clipboardy mock
mockClipboardyWrite = clipboardy.write as Mock;
// default: no /dev/tty available
// default: /dev/tty creation succeeds and emits 'open'
mockFs.createWriteStream.mockImplementation(() => {
throw new Error('ENOENT');
const tty = makeWritable({ isTTY: true });
setTimeout(() => tty.emit('open'), 0);
return tty;
});
// default: stdio are not TTY for tests unless explicitly set
@@ -224,7 +229,10 @@ describe('commandUtils', () => {
it('writes OSC-52 to /dev/tty when in SSH', async () => {
const testText = 'abc';
const tty = makeWritable({ isTTY: true });
mockFs.createWriteStream.mockReturnValue(tty);
mockFs.createWriteStream.mockImplementation(() => {
setTimeout(() => tty.emit('open'), 0);
return tty;
});
process.env['SSH_CONNECTION'] = '1';
@@ -242,7 +250,10 @@ describe('commandUtils', () => {
it('wraps OSC-52 for tmux when in SSH', async () => {
const testText = 'tmux-copy';
const tty = makeWritable({ isTTY: true });
mockFs.createWriteStream.mockReturnValue(tty);
mockFs.createWriteStream.mockImplementation(() => {
setTimeout(() => tty.emit('open'), 0);
return tty;
});
process.env['SSH_CONNECTION'] = '1';
process.env['TMUX'] = '1';
@@ -262,7 +273,10 @@ describe('commandUtils', () => {
// ensure payload > chunk size (240) so there are multiple chunks
const testText = 'x'.repeat(1200);
const tty = makeWritable({ isTTY: true });
mockFs.createWriteStream.mockReturnValue(tty);
mockFs.createWriteStream.mockImplementation(() => {
setTimeout(() => tty.emit('open'), 0);
return tty;
});
process.env['SSH_CONNECTION'] = '1';
process.env['STY'] = 'screen-session';
@@ -290,6 +304,13 @@ describe('commandUtils', () => {
process.env['SSH_TTY'] = '/dev/pts/1';
// Simulate /dev/tty access failure
mockFs.createWriteStream.mockImplementation(() => {
const tty = makeWritable({ isTTY: true });
setTimeout(() => tty.emit('error', new Error('EACCES')), 0);
return tty;
});
await copyToClipboard(testText);
const b64 = Buffer.from(testText, 'utf8').toString('base64');
@@ -303,7 +324,11 @@ describe('commandUtils', () => {
const testText = 'no-tty';
mockClipboardyWrite.mockResolvedValue(undefined);
// /dev/tty throws; stderr/stdout are non-TTY by default
// /dev/tty throws or errors
mockFs.createWriteStream.mockImplementation(() => {
throw new Error('ENOENT');
});
process.env['SSH_CLIENT'] = 'client';
await copyToClipboard(testText);
@@ -313,7 +338,10 @@ describe('commandUtils', () => {
it('resolves on drain when backpressure occurs', async () => {
const tty = makeWritable({ isTTY: true, writeReturn: false });
mockFs.createWriteStream.mockReturnValue(tty);
mockFs.createWriteStream.mockImplementation(() => {
setTimeout(() => tty.emit('open'), 0);
return tty;
});
process.env['SSH_CONNECTION'] = '1';
const p = copyToClipboard('drain-test');
@@ -325,7 +353,10 @@ describe('commandUtils', () => {
it('propagates errors from OSC-52 write path', async () => {
const tty = makeWritable({ isTTY: true, writeReturn: false });
mockFs.createWriteStream.mockReturnValue(tty);
mockFs.createWriteStream.mockImplementation(() => {
setTimeout(() => tty.emit('open'), 0);
return tty;
});
process.env['SSH_CONNECTION'] = '1';
const p = copyToClipboard('err-test');
@@ -349,7 +380,10 @@ describe('commandUtils', () => {
it('uses clipboardy when not in eligible env even if /dev/tty exists', async () => {
const tty = makeWritable({ isTTY: true });
mockFs.createWriteStream.mockReturnValue(tty);
mockFs.createWriteStream.mockImplementation(() => {
setTimeout(() => tty.emit('open'), 0);
return tty;
});
const text = 'local-terminal';
mockClipboardyWrite.mockResolvedValue(undefined);
@@ -360,9 +394,31 @@ describe('commandUtils', () => {
expect(tty.end).not.toHaveBeenCalled();
});
it('falls back if /dev/tty emits error (e.g. sandbox)', async () => {
const testText = 'access-denied-fallback';
process.env['SSH_CONNECTION'] = '1'; // normally would trigger OSC52 on TTY
mockFs.createWriteStream.mockImplementation(() => {
const stream = makeWritable({ isTTY: true });
// Emit error instead of open
setTimeout(() => stream.emit('error', new Error('EACCES')), 0);
return stream;
});
// Fallback to clipboardy since stdio isn't configured as TTY in this test (default from beforeEach)
mockClipboardyWrite.mockResolvedValue(undefined);
await copyToClipboard(testText);
expect(mockFs.createWriteStream).toHaveBeenCalled();
expect(mockClipboardyWrite).toHaveBeenCalledWith(testText);
});
it('uses clipboardy in tmux when not in SSH/WSL', async () => {
const tty = makeWritable({ isTTY: true });
mockFs.createWriteStream.mockReturnValue(tty);
mockFs.createWriteStream.mockImplementation(() => {
setTimeout(() => tty.emit('open'), 0);
return tty;
});
const text = 'tmux-local';
mockClipboardyWrite.mockResolvedValue(undefined);
@@ -375,6 +431,24 @@ describe('commandUtils', () => {
expect(tty.end).not.toHaveBeenCalled();
});
it('falls back if /dev/tty hangs (timeout)', async () => {
const testText = 'timeout-fallback';
process.env['SSH_CONNECTION'] = '1';
mockFs.createWriteStream.mockImplementation(() =>
// Stream that never emits open or error
makeWritable({ isTTY: true }),
);
mockClipboardyWrite.mockResolvedValue(undefined);
// Should complete even though stream hangs
await copyToClipboard(testText);
expect(mockFs.createWriteStream).toHaveBeenCalled();
expect(mockClipboardyWrite).toHaveBeenCalledWith(testText);
});
it('skips /dev/tty on Windows and uses stderr fallback for OSC-52', async () => {
mockProcess.platform = 'win32';
const stderrStream = makeWritable({ isTTY: true });
+44 -14
View File
@@ -65,20 +65,50 @@ const SCREEN_DCS_CHUNK_SIZE = 240;
type TtyTarget = { stream: Writable; closeAfter: boolean } | null;
const pickTty = (): TtyTarget => {
// /dev/tty is only available on Unix-like systems (Linux, macOS, BSD, etc.)
if (process.platform !== 'win32') {
// Prefer the controlling TTY to avoid interleaving escape sequences with piped stdout.
try {
const devTty = fs.createWriteStream('/dev/tty');
// Prevent unhandled 'error' events from crashing the process.
devTty.on('error', () => {});
return { stream: devTty, closeAfter: true };
} catch {
// fall through - /dev/tty not accessible
}
}
const pickTty = (): Promise<TtyTarget> =>
new Promise((resolve) => {
// /dev/tty is only available on Unix-like systems (Linux, macOS, BSD, etc.)
if (process.platform !== 'win32') {
// Prefer the controlling TTY to avoid interleaving escape sequences with piped stdout.
try {
const devTty = fs.createWriteStream('/dev/tty');
// Safety timeout: if /dev/tty doesn't respond quickly, fallback to avoid hanging.
const timeout = setTimeout(() => {
// Remove listeners to prevent them from firing after timeout.
devTty.removeAllListeners('open');
devTty.removeAllListeners('error');
devTty.destroy();
resolve(getStdioTty());
}, 100);
// If we can't open it (e.g. sandbox), we'll get an error.
// We wait for 'open' to confirm it's usable, or 'error' to fallback.
// If it opens, we resolve with the stream.
devTty.once('open', () => {
clearTimeout(timeout);
devTty.removeAllListeners('error');
// Prevent future unhandled 'error' events from crashing the process
devTty.on('error', () => {});
resolve({ stream: devTty, closeAfter: true });
});
// If it errors immediately (or quickly), we fallback.
devTty.once('error', () => {
clearTimeout(timeout);
devTty.removeAllListeners('open');
resolve(getStdioTty());
});
return;
} catch {
// fall through - synchronous failure
}
}
resolve(getStdioTty());
});
const getStdioTty = (): TtyTarget => {
if (process.stderr?.isTTY)
return { stream: process.stderr, closeAfter: false };
if (process.stdout?.isTTY)
@@ -172,7 +202,7 @@ const writeAll = (stream: Writable, data: string): Promise<void> =>
export const copyToClipboard = async (text: string): Promise<void> => {
if (!text) return;
const tty = pickTty();
const tty = await pickTty();
if (shouldUseOsc52(tty)) {
const osc = buildOsc52(text);
+2 -2
View File
@@ -212,13 +212,13 @@ describe('parseInputForHighlighting with Transformations', () => {
});
it('should handle empty transformations array', () => {
const line = 'Check out @test.png';
const line = 'Check out @test_no_transform.png';
const result = parseInputForHighlighting(line, 0, [], 0);
// Should fall back to default highlighting
expect(result).toEqual([
{ text: 'Check out ', type: 'default' },
{ text: '@test.png', type: 'file' },
{ text: '@test_no_transform.png', type: 'file' },
]);
});
+24 -1
View File
@@ -4,8 +4,10 @@
* SPDX-License-Identifier: Apache-2.0
*/
import { LruCache } from '@google/gemini-cli-core';
import type { Transformation } from '../components/shared/text-buffer.js';
import { cpLen, cpSlice } from './textUtils.js';
import { LRU_BUFFER_PERF_CACHE_LIMIT } from '../constants.js';
export type HighlightToken = {
text: string;
@@ -18,12 +20,30 @@ export type HighlightToken = {
// semicolon, common punctuation, and brackets.
const HIGHLIGHT_REGEX = /(^\/[a-zA-Z0-9_-]+|@(?:\\ |[^,\s;!?()[\]{}])+)/g;
const highlightCache = new LruCache<string, readonly HighlightToken[]>(
LRU_BUFFER_PERF_CACHE_LIMIT,
);
export function parseInputForHighlighting(
text: string,
index: number,
transformations: Transformation[] = [],
cursorCol?: number,
): readonly HighlightToken[] {
let isCursorInsideTransform = false;
if (cursorCol !== undefined) {
for (const transform of transformations) {
if (cursorCol >= transform.logStart && cursorCol <= transform.logEnd) {
isCursorInsideTransform = true;
break;
}
}
}
const cacheKey = `${index === 0 ? 'F' : 'N'}:${isCursorInsideTransform ? cursorCol : 'NC'}:${text}`;
const cached = highlightCache.get(cacheKey);
if (cached !== undefined) return cached;
HIGHLIGHT_REGEX.lastIndex = 0;
if (!text) {
@@ -79,7 +99,7 @@ export function parseInputForHighlighting(
tokens.push(...parseUntransformedInput(textBeforeTransformation));
const isCursorInside =
typeof cursorCol === 'number' &&
cursorCol !== undefined &&
cursorCol >= transformation.logStart &&
cursorCol <= transformation.logEnd;
const transformationText = isCursorInside
@@ -92,6 +112,9 @@ export function parseInputForHighlighting(
const textAfterFinalTransformation = cpSlice(text, column);
tokens.push(...parseUntransformedInput(textAfterFinalTransformation));
highlightCache.set(cacheKey, tokens);
return tokens;
}
+21 -12
View File
@@ -8,6 +8,8 @@ import stripAnsi from 'strip-ansi';
import ansiRegex from 'ansi-regex';
import { stripVTControlCharacters } from 'node:util';
import stringWidth from 'string-width';
import { LruCache } from '@google/gemini-cli-core';
import { LRU_BUFFER_PERF_CACHE_LIMIT } from '../constants.js';
/**
* Calculates the maximum width of a multi-line ASCII art string.
@@ -28,9 +30,11 @@ export const getAsciiArtWidth = (asciiArt: string): number => {
* code units so that surrogatepair emoji count as one "column".)
* ---------------------------------------------------------------------- */
// Cache for code points to reduce GC pressure
const codePointsCache = new Map<string, string[]>();
// Cache for code points
const MAX_STRING_LENGTH_TO_CACHE = 1000;
const codePointsCache = new LruCache<string, string[]>(
LRU_BUFFER_PERF_CACHE_LIMIT,
);
export function toCodePoints(str: string): string[] {
// ASCII fast path - check if all chars are ASCII (0-127)
@@ -48,14 +52,14 @@ export function toCodePoints(str: string): string[] {
// Cache short strings
if (str.length <= MAX_STRING_LENGTH_TO_CACHE) {
const cached = codePointsCache.get(str);
if (cached) {
if (cached !== undefined) {
return cached;
}
}
const result = Array.from(str);
// Cache result (unlimited like Ink)
// Cache result
if (str.length <= MAX_STRING_LENGTH_TO_CACHE) {
codePointsCache.set(str, result);
}
@@ -119,21 +123,26 @@ export function stripUnsafeCharacters(str: string): string {
.join('');
}
// String width caching for performance optimization
const stringWidthCache = new Map<string, number>();
const stringWidthCache = new LruCache<string, number>(
LRU_BUFFER_PERF_CACHE_LIMIT,
);
/**
* Cached version of stringWidth function for better performance
* Follows Ink's approach with unlimited cache (no eviction)
*/
export const getCachedStringWidth = (str: string): number => {
// ASCII printable chars have width 1
if (/^[\x20-\x7E]*$/.test(str)) {
return str.length;
// ASCII printable chars (32-126) have width 1.
// This is a very frequent path, so we use a fast numeric check.
if (str.length === 1) {
const code = str.charCodeAt(0);
if (code >= 0x20 && code <= 0x7e) {
return 1;
}
}
if (stringWidthCache.has(str)) {
return stringWidthCache.get(str)!;
const cached = stringWidthCache.get(str);
if (cached !== undefined) {
return cached;
}
let width: number;
+1 -1
View File
@@ -27,7 +27,7 @@ export const calculateMainAreaWidth = (
terminalWidth: number,
settings: LoadedSettings,
): number => {
if (settings.merged.ui?.useFullWidth) {
if (settings.merged.ui.useFullWidth) {
if (isAlternateBufferEnabled(settings)) {
return terminalWidth - 1;
}
@@ -46,7 +46,7 @@ describe('checkForUpdates', () => {
});
it('should return null if disableUpdateNag is true', async () => {
mockSettings.merged.general!.disableUpdateNag = true;
mockSettings.merged.general.disableUpdateNag = true;
const result = await checkForUpdates(mockSettings);
expect(result).toBeNull();
expect(getPackageJson).not.toHaveBeenCalled();
+1 -1
View File
@@ -51,7 +51,7 @@ export async function checkForUpdates(
settings: LoadedSettings,
): Promise<UpdateObject | null> {
try {
if (settings.merged.general?.disableUpdateNag) {
if (settings.merged.general.disableUpdateNag) {
return null;
}
// Skip update check when running from source (development mode)
+5 -8
View File
@@ -10,7 +10,6 @@ import {
type LoadedSettings,
} from '../config/settings.js';
import type { ModifiedScope } from './skillSettings.js';
import type { AgentOverride } from '@google/gemini-cli-core';
export type AgentActionStatus = 'success' | 'no-op' | 'error';
@@ -44,8 +43,8 @@ export function enableAgent(
for (const scope of writableScopes) {
if (isLoadableSettingScope(scope)) {
const scopePath = settings.forScope(scope).path;
const agentOverrides = settings.forScope(scope).settings.agents
?.overrides as Record<string, AgentOverride> | undefined;
const agentOverrides =
settings.forScope(scope).settings.agents?.overrides;
const isDisabled = agentOverrides?.[agentName]?.disabled === true;
if (isDisabled) {
@@ -105,9 +104,7 @@ export function disableAgent(
}
const scopePath = settings.forScope(scope).path;
const agentOverrides = settings.forScope(scope).settings.agents?.overrides as
| Record<string, AgentOverride>
| undefined;
const agentOverrides = settings.forScope(scope).settings.agents?.overrides;
const isDisabled = agentOverrides?.[agentName]?.disabled === true;
if (isDisabled) {
@@ -128,8 +125,8 @@ export function disableAgent(
const alreadyDisabledInOther: ModifiedScope[] = [];
if (isLoadableSettingScope(otherScope)) {
const otherOverrides = settings.forScope(otherScope).settings.agents
?.overrides as Record<string, AgentOverride> | undefined;
const otherOverrides =
settings.forScope(otherScope).settings.agents?.overrides;
if (otherOverrides?.[agentName]?.disabled === true) {
alreadyDisabledInOther.push({
scope: otherScope,

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