mirror of
https://github.com/google-gemini/gemini-cli.git
synced 2026-07-24 00:30:59 -07:00
Compare commits
68 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 46fc062314 | |||
| 27d21f9921 | |||
| b24544c80e | |||
| 61040d0eb8 | |||
| 3cca8ec0f0 | |||
| 2219bac16d | |||
| 1c9a57c3c2 | |||
| a1233e7e5c | |||
| 1033550f78 | |||
| addecf2c74 | |||
| c266b529ae | |||
| dce450b1e8 | |||
| a91665db40 | |||
| f79124f96e | |||
| eb2af84bb9 | |||
| f190b87223 | |||
| e894871afc | |||
| 45d554ae2f | |||
| acbef4cd31 | |||
| 80069b4a78 | |||
| d0cae4547e | |||
| 7399c623d8 | |||
| 6b14dc8240 | |||
| 4d77934a83 | |||
| 9e3d36c19b | |||
| c43b04b44c | |||
| 8605d0d024 | |||
| c21c297133 | |||
| 0605e6e3e9 | |||
| b703c87a64 | |||
| a784c263be | |||
| 169d906fe3 | |||
| 525539fc13 | |||
| 53e0c212cc | |||
| 7990073543 | |||
| 367e7bf401 | |||
| 3a4012f76a | |||
| c9f0a48bdc | |||
| 97aac696fb | |||
| 93ae7772fd | |||
| 9866eb0551 | |||
| 55c2783e6a | |||
| 2455f939a3 | |||
| 995ae42f53 | |||
| e1fd5be429 | |||
| 3b626e7c61 | |||
| aceb06a587 | |||
| 211d2c5fdd | |||
| c9061a1cfe | |||
| b288f124b2 | |||
| 645e2ec041 | |||
| 67d69084e9 | |||
| ed0b0fae49 | |||
| f0f705d3ca | |||
| f42b4c80ac | |||
| b99e841021 | |||
| a16d5983cf | |||
| e5745f16cb | |||
| 12b0fe1cc2 | |||
| 5b8a23979f | |||
| b71fe94e0a | |||
| e92f60b4fc | |||
| 15f26175b8 | |||
| 85b17166a5 | |||
| 88df6210eb | |||
| 943481ccc0 | |||
| 79076d1d52 | |||
| 166e04a8dd |
@@ -0,0 +1,65 @@
|
||||
---
|
||||
name: code-reviewer
|
||||
description:
|
||||
Use this skill to review code. It supports both local changes (staged or working tree)
|
||||
and remote Pull Requests (by ID or URL). It focuses on correctness, maintainability,
|
||||
and adherence to project standards.
|
||||
---
|
||||
|
||||
# Code Reviewer
|
||||
|
||||
This skill guides the agent in conducting professional and thorough code reviews for both local development and remote Pull Requests.
|
||||
|
||||
## Workflow
|
||||
|
||||
### 1. Determine Review Target
|
||||
* **Remote PR**: If the user provides a PR number or URL (e.g., "Review PR #123"), target that remote PR.
|
||||
* **Local Changes**: If no specific PR is mentioned, or if the user asks to "review my changes", target the current local file system states (staged and unstaged changes).
|
||||
|
||||
### 2. Preparation
|
||||
|
||||
#### For Remote PRs:
|
||||
1. **Checkout**: Use the GitHub CLI to checkout the PR.
|
||||
```bash
|
||||
gh pr checkout <PR_NUMBER>
|
||||
```
|
||||
2. **Preflight**: Execute the project's standard verification suite to catch automated failures early.
|
||||
```bash
|
||||
npm run preflight
|
||||
```
|
||||
3. **Context**: Read the PR description and any existing comments to understand the goal and history.
|
||||
|
||||
#### For Local Changes:
|
||||
1. **Identify Changes**:
|
||||
* Check status: `git status`
|
||||
* Read diffs: `git diff` (working tree) and/or `git diff --staged` (staged).
|
||||
2. **Preflight (Optional)**: If the changes are substantial, ask the user if they want to run `npm run preflight` before reviewing.
|
||||
|
||||
### 3. In-Depth Analysis
|
||||
Analyze the code changes based on the following pillars:
|
||||
|
||||
* **Correctness**: Does the code achieve its stated purpose without bugs or logical errors?
|
||||
* **Maintainability**: Is the code clean, well-structured, and easy to understand and modify in the future? Consider factors like code clarity, modularity, and adherence to established design patterns.
|
||||
* **Readability**: Is the code well-commented (where necessary) and consistently formatted according to our project's coding style guidelines?
|
||||
* **Efficiency**: Are there any obvious performance bottlenecks or resource inefficiencies introduced by the changes?
|
||||
* **Security**: Are there any potential security vulnerabilities or insecure coding practices?
|
||||
* **Edge Cases and Error Handling**: Does the code appropriately handle edge cases and potential errors?
|
||||
* **Testability**: Is the new or modified code adequately covered by tests (even if preflight checks pass)? Suggest additional test cases that would improve coverage or robustness.
|
||||
|
||||
### 4. Provide Feedback
|
||||
|
||||
#### Structure
|
||||
* **Summary**: A high-level overview of the review.
|
||||
* **Findings**:
|
||||
* **Critical**: Bugs, security issues, or breaking changes.
|
||||
* **Improvements**: Suggestions for better code quality or performance.
|
||||
* **Nitpicks**: Formatting or minor style issues (optional).
|
||||
* **Conclusion**: Clear recommendation (Approved / Request Changes).
|
||||
|
||||
#### Tone
|
||||
* Be constructive, professional, and friendly.
|
||||
* Explain *why* a change is requested.
|
||||
* For approvals, acknowledge the specific value of the contribution.
|
||||
|
||||
### 5. Cleanup (Remote PRs only)
|
||||
* After the review, ask the user if they want to switch back to the default branch (e.g., `main` or `master`).
|
||||
@@ -14,16 +14,25 @@ repository's standards.
|
||||
|
||||
Follow these steps to create a Pull Request:
|
||||
|
||||
1. **Locate Template**: Search for a pull request template in the repository.
|
||||
1. **Branch Management**: Check the current branch to avoid working directly
|
||||
on `main`.
|
||||
- Run `git branch --show-current`.
|
||||
- If the current branch is `main`, create and switch to a new descriptive
|
||||
branch:
|
||||
```bash
|
||||
git checkout -b <new-branch-name>
|
||||
```
|
||||
|
||||
2. **Locate Template**: Search for a pull request template in the repository.
|
||||
- Check `.github/pull_request_template.md`
|
||||
- Check `.github/PULL_REQUEST_TEMPLATE.md`
|
||||
- If multiple templates exist (e.g., in `.github/PULL_REQUEST_TEMPLATE/`),
|
||||
ask the user which one to use or select the most appropriate one based on
|
||||
the context (e.g., `bug_fix.md` vs `feature.md`).
|
||||
|
||||
2. **Read Template**: Read the content of the identified template file.
|
||||
3. **Read Template**: Read the content of the identified template file.
|
||||
|
||||
3. **Draft Description**: Create a PR description that strictly follows the
|
||||
4. **Draft Description**: Create a PR description that strictly follows the
|
||||
template's structure.
|
||||
- **Headings**: Keep all headings from the template.
|
||||
- **Checklists**: Review each item. Mark with `[x]` if completed. If an item
|
||||
@@ -35,7 +44,14 @@ Follow these steps to create a Pull Request:
|
||||
- **Related Issues**: Link any issues fixed or related to this PR (e.g.,
|
||||
"Fixes #123").
|
||||
|
||||
4. **Create PR**: Use the `gh` CLI to create the PR. To avoid shell escaping
|
||||
5. **Preflight Check**: Before creating the PR, run the workspace preflight
|
||||
script to ensure all build, lint, and test checks pass.
|
||||
```bash
|
||||
npm run preflight
|
||||
```
|
||||
If any checks fail, address the issues before proceeding to create the PR.
|
||||
|
||||
6. **Create PR**: Use the `gh` CLI to create the PR. To avoid shell escaping
|
||||
issues with multi-line Markdown, write the description to a temporary file
|
||||
first.
|
||||
```bash
|
||||
|
||||
@@ -22,6 +22,12 @@ jobs:
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
model:
|
||||
- 'gemini-3-pro-preview'
|
||||
- 'gemini-3-flash-preview'
|
||||
- 'gemini-2.5-pro'
|
||||
- 'gemini-2.5-flash'
|
||||
- 'gemini-2.5-flash-lite'
|
||||
run_attempt: [1, 2, 3]
|
||||
steps:
|
||||
- name: 'Checkout'
|
||||
@@ -45,6 +51,7 @@ jobs:
|
||||
- name: 'Run Evals'
|
||||
env:
|
||||
GEMINI_API_KEY: '${{ secrets.GEMINI_API_KEY }}'
|
||||
GEMINI_MODEL: '${{ matrix.model }}'
|
||||
RUN_EVALS: "${{ github.event.inputs.run_all != 'false' }}"
|
||||
run: 'npm run test:all_evals'
|
||||
|
||||
@@ -52,7 +59,7 @@ jobs:
|
||||
if: 'always()'
|
||||
uses: 'actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02' # ratchet:actions/upload-artifact@v4
|
||||
with:
|
||||
name: 'eval-logs-${{ matrix.run_attempt }}'
|
||||
name: 'eval-logs-${{ matrix.model }}-${{ matrix.run_attempt }}'
|
||||
path: 'evals/logs'
|
||||
retention-days: 7
|
||||
|
||||
|
||||
@@ -23,16 +23,32 @@ jobs:
|
||||
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'
|
||||
'https://github.com/google-gemini/gemini-cli/issues/15324',
|
||||
'https://github.com/google-gemini/gemini-cli/issues/17202',
|
||||
'https://github.com/google-gemini/gemini-cli/issues/17203'
|
||||
];
|
||||
|
||||
async function getIssueParent(owner, repo, number) {
|
||||
// Single issue processing (for event triggers)
|
||||
async function processSingleIssue(owner, repo, number) {
|
||||
const query = `
|
||||
query($owner:String!, $repo:String!, $number:Int!) {
|
||||
repository(owner:$owner, name:$repo) {
|
||||
issue(number:$number) {
|
||||
number
|
||||
parent {
|
||||
url
|
||||
parent {
|
||||
url
|
||||
parent {
|
||||
url
|
||||
parent {
|
||||
url
|
||||
parent {
|
||||
url
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -40,55 +56,119 @@ jobs:
|
||||
`;
|
||||
try {
|
||||
const result = await github.graphql(query, { owner, repo, number });
|
||||
return result.repository.issue.parent ? result.repository.issue.parent.url : null;
|
||||
|
||||
if (!result || !result.repository || !result.repository.issue) {
|
||||
console.log(`Issue #${number} not found or data missing.`);
|
||||
return;
|
||||
}
|
||||
|
||||
const issue = result.repository.issue;
|
||||
await checkAndLabel(issue, owner, repo);
|
||||
} catch (error) {
|
||||
console.error(`Failed to fetch parent for #${number}:`, error);
|
||||
return null;
|
||||
console.error(`Failed to process issue #${number}:`, error);
|
||||
throw error; // Re-throw to be caught by main execution
|
||||
}
|
||||
}
|
||||
|
||||
// Determine which issues to process
|
||||
let issuesToProcess = [];
|
||||
// Bulk processing (for schedule/dispatch)
|
||||
async function processAllOpenIssues(owner, repo) {
|
||||
const query = `
|
||||
query($owner:String!, $repo:String!, $cursor:String) {
|
||||
repository(owner:$owner, name:$repo) {
|
||||
issues(first: 100, states: OPEN, after: $cursor) {
|
||||
pageInfo {
|
||||
hasNextPage
|
||||
endCursor
|
||||
}
|
||||
nodes {
|
||||
number
|
||||
parent {
|
||||
url
|
||||
parent {
|
||||
url
|
||||
parent {
|
||||
url
|
||||
parent {
|
||||
url
|
||||
parent {
|
||||
url
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
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
|
||||
}));
|
||||
let hasNextPage = true;
|
||||
let cursor = null;
|
||||
|
||||
while (hasNextPage) {
|
||||
try {
|
||||
const result = await github.graphql(query, { owner, repo, cursor });
|
||||
|
||||
if (!result || !result.repository || !result.repository.issues) {
|
||||
console.error('Invalid response structure from GitHub API');
|
||||
break;
|
||||
}
|
||||
|
||||
const issues = result.repository.issues.nodes || [];
|
||||
|
||||
console.log(`Processing batch of ${issues.length} issues...`);
|
||||
for (const issue of issues) {
|
||||
await checkAndLabel(issue, owner, repo);
|
||||
}
|
||||
|
||||
hasNextPage = result.repository.issues.pageInfo.hasNextPage;
|
||||
cursor = result.repository.issues.pageInfo.endCursor;
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch issues batch:', error);
|
||||
throw error; // Re-throw to be caught by main execution
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
console.log(`Processing ${issuesToProcess.length} issue(s)...`);
|
||||
async function checkAndLabel(issue, owner, repo) {
|
||||
if (!issue || !issue.parent) return;
|
||||
|
||||
for (const issue of issuesToProcess) {
|
||||
const parentUrl = await getIssueParent(issue.owner, issue.repo, issue.number);
|
||||
let currentParent = issue.parent;
|
||||
let tracedParents = [];
|
||||
let matched = false;
|
||||
|
||||
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]
|
||||
});
|
||||
while (currentParent) {
|
||||
tracedParents.push(currentParent.url);
|
||||
|
||||
if (allowedParentUrls.includes(currentParent.url)) {
|
||||
console.log(`SUCCESS: Issue #${issue.number} is a descendant of ${currentParent.url}. Trace: ${tracedParents.join(' -> ')}. Adding label.`);
|
||||
await github.rest.issues.addLabels({
|
||||
owner,
|
||||
repo,
|
||||
issue_number: issue.number,
|
||||
labels: [labelToAdd]
|
||||
});
|
||||
matched = true;
|
||||
break;
|
||||
}
|
||||
currentParent = currentParent.parent;
|
||||
}
|
||||
|
||||
if (!matched && context.eventName === 'issues') {
|
||||
console.log(`Issue #${issue.number} did not match any allowed workstreams. Trace: ${tracedParents.join(' -> ') || 'None'}.`);
|
||||
}
|
||||
}
|
||||
|
||||
// Main execution
|
||||
try {
|
||||
if (context.eventName === 'issues') {
|
||||
console.log(`Processing single issue #${context.payload.issue.number}...`);
|
||||
await processSingleIssue(context.repo.owner, context.repo.repo, context.payload.issue.number);
|
||||
} else {
|
||||
// logging only for single execution to avoid spam
|
||||
if (context.eventName === 'issues') {
|
||||
console.log(`Issue #${issue.number} parent is ${parentUrl || 'None'}. No action.`);
|
||||
}
|
||||
console.log(`Running for event: ${context.eventName}. Processing all open issues...`);
|
||||
await processAllOpenIssues(context.repo.owner, context.repo.repo);
|
||||
}
|
||||
} catch (error) {
|
||||
core.setFailed(`Workflow failed: ${error.message}`);
|
||||
}
|
||||
|
||||
@@ -1,420 +1,72 @@
|
||||
## Building and running
|
||||
|
||||
Before submitting any changes, it is crucial to validate them by running the
|
||||
full preflight check. This command will build the repository, run all tests,
|
||||
check for type errors, and lint the code.
|
||||
|
||||
To run the full suite of checks, execute the following command:
|
||||
|
||||
```bash
|
||||
npm run preflight
|
||||
```
|
||||
|
||||
This single command ensures that your changes meet all the quality gates of the
|
||||
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
|
||||
tests, aim to follow existing patterns. Key conventions include:
|
||||
|
||||
### Test Structure and Framework
|
||||
|
||||
- **Framework**: All tests are written using Vitest (`describe`, `it`, `expect`,
|
||||
`vi`).
|
||||
- **File Location**: Test files (`*.test.ts` for logic, `*.test.tsx` for React
|
||||
components) are co-located with the source files they test.
|
||||
- **Configuration**: Test environments are defined in `vitest.config.ts` files.
|
||||
- **Setup/Teardown**: Use `beforeEach` and `afterEach`. Commonly,
|
||||
`vi.resetAllMocks()` is called in `beforeEach` and `vi.restoreAllMocks()` in
|
||||
`afterEach`.
|
||||
|
||||
### Mocking (`vi` from Vitest)
|
||||
|
||||
- **ES Modules**: Mock with
|
||||
`vi.mock('module-name', async (importOriginal) => { ... })`. Use
|
||||
`importOriginal` for selective mocking.
|
||||
- _Example_:
|
||||
`vi.mock('os', async (importOriginal) => { const actual = await importOriginal(); return { ...actual, homedir: vi.fn() }; });`
|
||||
- **Mocking Order**: For critical dependencies (e.g., `os`, `fs`) that affect
|
||||
module-level constants, place `vi.mock` at the _very top_ of the test file,
|
||||
before other imports.
|
||||
- **Hoisting**: Use `const myMock = vi.hoisted(() => vi.fn());` if a mock
|
||||
function needs to be defined before its use in a `vi.mock` factory.
|
||||
- **Mock Functions**: Create with `vi.fn()`. Define behavior with
|
||||
`mockImplementation()`, `mockResolvedValue()`, or `mockRejectedValue()`.
|
||||
- **Spying**: Use `vi.spyOn(object, 'methodName')`. Restore spies with
|
||||
`mockRestore()` in `afterEach`.
|
||||
|
||||
### Commonly Mocked Modules
|
||||
|
||||
- **Node.js built-ins**: `fs`, `fs/promises`, `os` (especially `os.homedir()`),
|
||||
`path`, `child_process` (`execSync`, `spawn`).
|
||||
- **External SDKs**: `@google/genai`, `@modelcontextprotocol/sdk`.
|
||||
- **Internal Project Modules**: Dependencies from other project packages are
|
||||
often mocked.
|
||||
|
||||
### React Component Testing (CLI UI - Ink)
|
||||
|
||||
- Use `render()` from `ink-testing-library`.
|
||||
- Assert output with `lastFrame()`.
|
||||
- Wrap components in necessary `Context.Provider`s.
|
||||
- Mock custom React hooks and complex child components using `vi.mock()`.
|
||||
|
||||
### Asynchronous Testing
|
||||
|
||||
- Use `async/await`.
|
||||
- For timers, use `vi.useFakeTimers()`, `vi.advanceTimersByTimeAsync()`,
|
||||
`vi.runAllTimersAsync()`.
|
||||
- Test promise rejections with `await expect(promise).rejects.toThrow(...)`.
|
||||
|
||||
### General Guidance
|
||||
|
||||
- When adding tests, first examine existing tests to understand and conform to
|
||||
established conventions.
|
||||
- Pay close attention to the mocks at the top of existing test files; they
|
||||
reveal critical dependencies and how they are managed in a test environment.
|
||||
|
||||
## Git Repo
|
||||
|
||||
The main branch for this project is called "main"
|
||||
|
||||
## JavaScript/TypeScript
|
||||
|
||||
When contributing to this React, Node, and TypeScript codebase, please
|
||||
prioritize the use of plain JavaScript objects with accompanying TypeScript
|
||||
interface or type declarations over JavaScript class syntax. This approach
|
||||
offers significant advantages, especially concerning interoperability with React
|
||||
and overall code maintainability.
|
||||
|
||||
### Preferring Plain Objects over Classes
|
||||
|
||||
JavaScript classes, by their nature, are designed to encapsulate internal state
|
||||
and behavior. While this can be useful in some object-oriented paradigms, it
|
||||
often introduces unnecessary complexity and friction when working with React's
|
||||
component-based architecture. Here's why plain objects are preferred:
|
||||
|
||||
- Seamless React Integration: React components thrive on explicit props and
|
||||
state management. Classes' tendency to store internal state directly within
|
||||
instances can make prop and state propagation harder to reason about and
|
||||
maintain. Plain objects, on the other hand, are inherently immutable (when
|
||||
used thoughtfully) and can be easily passed as props, simplifying data flow
|
||||
and reducing unexpected side effects.
|
||||
|
||||
- Reduced Boilerplate and Increased Conciseness: Classes often promote the use
|
||||
of constructors, this binding, getters, setters, and other boilerplate that
|
||||
can unnecessarily bloat code. TypeScript interface and type declarations
|
||||
provide powerful static type checking without the runtime overhead or
|
||||
verbosity of class definitions. This allows for more succinct and readable
|
||||
code, aligning with JavaScript's strengths in functional programming.
|
||||
|
||||
- Enhanced Readability and Predictability: Plain objects, especially when their
|
||||
structure is clearly defined by TypeScript interfaces, are often easier to
|
||||
read and understand. Their properties are directly accessible, and there's no
|
||||
hidden internal state or complex inheritance chains to navigate. This
|
||||
predictability leads to fewer bugs and a more maintainable codebase.
|
||||
|
||||
- Simplified Immutability: While not strictly enforced, plain objects encourage
|
||||
an immutable approach to data. When you need to modify an object, you
|
||||
typically create a new one with the desired changes, rather than mutating the
|
||||
original. This pattern aligns perfectly with React's reconciliation process
|
||||
and helps prevent subtle bugs related to shared mutable state.
|
||||
|
||||
- Better Serialization and Deserialization: Plain JavaScript objects are
|
||||
naturally easy to serialize to JSON and deserialize back, which is a common
|
||||
requirement in web development (e.g., for API communication or local storage).
|
||||
Classes, with their methods and prototypes, can complicate this process.
|
||||
|
||||
### Embracing ES Module Syntax for Encapsulation
|
||||
|
||||
Rather than relying on Java-esque private or public class members, which can be
|
||||
verbose and sometimes limit flexibility, we strongly prefer leveraging ES module
|
||||
syntax (`import`/`export`) for encapsulating private and public APIs.
|
||||
|
||||
- Clearer Public API Definition: With ES modules, anything that is exported is
|
||||
part of the public API of that module, while anything not exported is
|
||||
inherently private to that module. This provides a very clear and explicit way
|
||||
to define what parts of your code are meant to be consumed by other modules.
|
||||
|
||||
- Enhanced Testability (Without Exposing Internals): By default, unexported
|
||||
functions or variables are not accessible from outside the module. This
|
||||
encourages you to test the public API of your modules, rather than their
|
||||
internal implementation details. If you find yourself needing to spy on or
|
||||
stub an unexported function for testing purposes, it's often a "code smell"
|
||||
indicating that the function might be a good candidate for extraction into its
|
||||
own separate, testable module with a well-defined public API. This promotes a
|
||||
more robust and maintainable testing strategy.
|
||||
|
||||
- Reduced Coupling: Explicitly defined module boundaries through import/export
|
||||
help reduce coupling between different parts of your codebase. This makes it
|
||||
easier to refactor, debug, and understand individual components in isolation.
|
||||
|
||||
### Avoiding `any` Types and Type Assertions; Preferring `unknown`
|
||||
|
||||
TypeScript's power lies in its ability to provide static type checking, catching
|
||||
potential errors before your code runs. To fully leverage this, it's crucial to
|
||||
avoid the `any` type and be judicious with type assertions.
|
||||
|
||||
- **The Dangers of `any`**: Using any effectively opts out of TypeScript's type
|
||||
checking for that particular variable or expression. While it might seem
|
||||
convenient in the short term, it introduces significant risks:
|
||||
- **Loss of Type Safety**: You lose all the benefits of type checking, making
|
||||
it easy to introduce runtime errors that TypeScript would otherwise have
|
||||
caught.
|
||||
- **Reduced Readability and Maintainability**: Code with `any` types is harder
|
||||
to understand and maintain, as the expected type of data is no longer
|
||||
explicitly defined.
|
||||
- **Masking Underlying Issues**: Often, the need for any indicates a deeper
|
||||
problem in the design of your code or the way you're interacting with
|
||||
external libraries. It's a sign that you might need to refine your types or
|
||||
refactor your code.
|
||||
|
||||
- **Preferring `unknown` over `any`**: When you absolutely cannot determine the
|
||||
type of a value at compile time, and you're tempted to reach for any, consider
|
||||
using unknown instead. unknown is a type-safe counterpart to any. While a
|
||||
variable of type unknown can hold any value, you must perform type narrowing
|
||||
(e.g., using typeof or instanceof checks, or a type assertion) before you can
|
||||
perform any operations on it. This forces you to handle the unknown type
|
||||
explicitly, preventing accidental runtime errors.
|
||||
|
||||
```ts
|
||||
function processValue(value: unknown) {
|
||||
if (typeof value === 'string') {
|
||||
// value is now safely a string
|
||||
console.log(value.toUpperCase());
|
||||
} else if (typeof value === 'number') {
|
||||
// value is now safely a number
|
||||
console.log(value * 2);
|
||||
}
|
||||
// Without narrowing, you cannot access properties or methods on 'value'
|
||||
// console.log(value.someProperty); // Error: Object is of type 'unknown'.
|
||||
}
|
||||
```
|
||||
|
||||
- **Type Assertions (`as Type`) - Use with Caution**: Type assertions tell the
|
||||
TypeScript compiler, "Trust me, I know what I'm doing; this is definitely of
|
||||
this type." While there are legitimate use cases (e.g., when dealing with
|
||||
external libraries that don't have perfect type definitions, or when you have
|
||||
more information than the compiler), they should be used sparingly and with
|
||||
extreme caution.
|
||||
- **Bypassing Type Checking**: Like `any`, type assertions bypass TypeScript's
|
||||
safety checks. If your assertion is incorrect, you introduce a runtime error
|
||||
that TypeScript would not have warned you about.
|
||||
- **Code Smell in Testing**: A common scenario where `any` or type assertions
|
||||
might be tempting is when trying to test "private" implementation details
|
||||
(e.g., spying on or stubbing an unexported function within a module). This
|
||||
is a strong indication of a "code smell" in your testing strategy and
|
||||
potentially your code structure. Instead of trying to force access to
|
||||
private internals, consider whether those internal details should be
|
||||
refactored into a separate module with a well-defined public API. This makes
|
||||
them inherently testable without compromising encapsulation.
|
||||
|
||||
### Type narrowing `switch` clauses
|
||||
|
||||
Use the `checkExhaustive` helper in the default clause of a switch statement.
|
||||
This will ensure that all of the possible options within the value or
|
||||
enumeration are used.
|
||||
|
||||
This helper method can be found in `packages/cli/src/utils/checks.ts`
|
||||
|
||||
### Embracing JavaScript's Array Operators
|
||||
|
||||
To further enhance code cleanliness and promote safe functional programming
|
||||
practices, leverage JavaScript's rich set of array operators as much as
|
||||
possible. Methods like `.map()`, `.filter()`, `.reduce()`, `.slice()`,
|
||||
`.sort()`, and others are incredibly powerful for transforming and manipulating
|
||||
data collections in an immutable and declarative way.
|
||||
|
||||
Using these operators:
|
||||
|
||||
- Promotes Immutability: Most array operators return new arrays, leaving the
|
||||
original array untouched. This functional approach helps prevent unintended
|
||||
side effects and makes your code more predictable.
|
||||
- Improves Readability: Chaining array operators often lead to more concise and
|
||||
expressive code than traditional for loops or imperative logic. The intent of
|
||||
the operation is clear at a glance.
|
||||
- Facilitates Functional Programming: These operators are cornerstones of
|
||||
functional programming, encouraging the creation of pure functions that take
|
||||
inputs and produce outputs without causing side effects. This paradigm is
|
||||
highly beneficial for writing robust and testable code that pairs well with
|
||||
React.
|
||||
|
||||
By consistently applying these principles, we can maintain a codebase that is
|
||||
not only efficient and performant but also a joy to work with, both now and in
|
||||
the future.
|
||||
|
||||
## React (mirrored and adjusted from [react-mcp-server](https://github.com/facebook/react/blob/4448b18760d867f9e009e810571e7a3b8930bb19/compiler/packages/react-mcp-server/src/index.ts#L376C1-L441C94))
|
||||
|
||||
### Role
|
||||
|
||||
You are a React assistant that helps users write more efficient and optimizable
|
||||
React code. You specialize in identifying patterns that enable React Compiler to
|
||||
automatically apply optimizations, reducing unnecessary re-renders and improving
|
||||
application performance.
|
||||
|
||||
### Follow these guidelines in all code you produce and suggest
|
||||
|
||||
Use functional components with Hooks: Do not generate class components or use
|
||||
old lifecycle methods. Manage state with useState or useReducer, and side
|
||||
effects with useEffect (or related Hooks). Always prefer functions and Hooks for
|
||||
any new component logic.
|
||||
|
||||
Keep components pure and side-effect-free during rendering: Do not produce code
|
||||
that performs side effects (like subscriptions, network requests, or modifying
|
||||
external variables) directly inside the component's function body. Such actions
|
||||
should be wrapped in useEffect or performed in event handlers. Ensure your
|
||||
render logic is a pure function of props and state.
|
||||
|
||||
Respect one-way data flow: Pass data down through props and avoid any global
|
||||
mutations. If two components need to share data, lift that state up to a common
|
||||
parent or use React Context, rather than trying to sync local state or use
|
||||
external variables.
|
||||
|
||||
Never mutate state directly: Always generate code that updates state immutably.
|
||||
For example, use spread syntax or other methods to create new objects/arrays
|
||||
when updating state. Do not use assignments like state.someValue = ... or array
|
||||
mutations like array.push() on state variables. Use the state setter (setState
|
||||
from useState, etc.) to update state.
|
||||
|
||||
Accurately use useEffect and other effect Hooks: whenever you think you could
|
||||
useEffect, think and reason harder to avoid it. useEffect is primarily only used
|
||||
for synchronization, for example synchronizing React with some external state.
|
||||
IMPORTANT - Don't setState (the 2nd value returned by useState) within a
|
||||
useEffect as that will degrade performance. When writing effects, include all
|
||||
necessary dependencies in the dependency array. Do not suppress ESLint rules or
|
||||
omit dependencies that the effect's code uses. Structure the effect callbacks to
|
||||
handle changing values properly (e.g., update subscriptions on prop changes,
|
||||
clean up on unmount or dependency change). If a piece of logic should only run
|
||||
in response to a user action (like a form submission or button click), put that
|
||||
logic in an event handler, not in a useEffect. Where possible, useEffects should
|
||||
return a cleanup function.
|
||||
|
||||
Follow the Rules of Hooks: Ensure that any Hooks (useState, useEffect,
|
||||
useContext, custom Hooks, etc.) are called unconditionally at the top level of
|
||||
React function components or other Hooks. Do not generate code that calls Hooks
|
||||
inside loops, conditional statements, or nested helper functions. Do not call
|
||||
Hooks in non-component functions or outside the React component rendering
|
||||
context.
|
||||
|
||||
Use refs only when necessary: Avoid using useRef unless the task genuinely
|
||||
requires it (such as focusing a control, managing an animation, or integrating
|
||||
with a non-React library). Do not use refs to store application state that
|
||||
should be reactive. If you do use refs, never write to or read from ref.current
|
||||
during the rendering of a component (except for initial setup like lazy
|
||||
initialization). Any ref usage should not affect the rendered output directly.
|
||||
|
||||
Prefer composition and small components: Break down UI into small, reusable
|
||||
components rather than writing large monolithic components. The code you
|
||||
generate should promote clarity and reusability by composing components
|
||||
together. Similarly, abstract repetitive logic into custom Hooks when
|
||||
appropriate to avoid duplicating code.
|
||||
|
||||
Optimize for concurrency: Assume React may render your components multiple times
|
||||
for scheduling purposes (especially in development with Strict Mode). Write code
|
||||
that remains correct even if the component function runs more than once. For
|
||||
instance, avoid side effects in the component body and use functional state
|
||||
updates (e.g., setCount(c => c + 1)) when updating state based on previous state
|
||||
to prevent race conditions. Always include cleanup functions in effects that
|
||||
subscribe to external resources. Don't write useEffects for "do this when this
|
||||
changes" side effects. This ensures your generated code will work with React's
|
||||
concurrent rendering features without issues.
|
||||
|
||||
Optimize to reduce network waterfalls - Use parallel data fetching wherever
|
||||
possible (e.g., start multiple requests at once rather than one after another).
|
||||
Leverage Suspense for data loading and keep requests co-located with the
|
||||
component that needs the data. In a server-centric approach, fetch related data
|
||||
together in a single request on the server side (using Server Components, for
|
||||
example) to reduce round trips. Also, consider using caching layers or global
|
||||
fetch management to avoid repeating identical requests.
|
||||
|
||||
Rely on React Compiler - useMemo, useCallback, and React.memo can be omitted if
|
||||
React Compiler is enabled. Avoid premature optimization with manual memoization.
|
||||
Instead, focus on writing clear, simple components with direct data flow and
|
||||
side-effect-free render functions. Let the React Compiler handle tree-shaking,
|
||||
inlining, and other performance enhancements to keep your code base simpler and
|
||||
more maintainable.
|
||||
|
||||
Design for a good user experience - Provide clear, minimal, and non-blocking UI
|
||||
states. When data is loading, show lightweight placeholders (e.g., skeleton
|
||||
screens) rather than intrusive spinners everywhere. Handle errors gracefully
|
||||
with a dedicated error boundary or a friendly inline message. Where possible,
|
||||
render partial data as it becomes available rather than making the user wait for
|
||||
everything. Suspense allows you to declare the loading states in your component
|
||||
tree in a natural way, preventing “flash” states and improving perceived
|
||||
performance.
|
||||
|
||||
### Process
|
||||
|
||||
1. Analyze the user's code for optimization opportunities:
|
||||
- Check for React anti-patterns that prevent compiler optimization
|
||||
- Look for component structure issues that limit compiler effectiveness
|
||||
- Think about each suggestion you are making and consult React docs for best
|
||||
practices
|
||||
|
||||
2. Provide actionable guidance:
|
||||
- Explain specific code changes with clear reasoning
|
||||
- Show before/after examples when suggesting changes
|
||||
- Only suggest changes that meaningfully improve optimization potential
|
||||
|
||||
### Optimization Guidelines
|
||||
|
||||
- State updates should be structured to enable granular updates
|
||||
- Side effects should be isolated and dependencies clearly defined
|
||||
|
||||
## Documentation guidelines
|
||||
|
||||
When working in the `/docs` directory, follow the guidelines in this section:
|
||||
|
||||
- **Role:** You are an expert technical writer and AI assistant for contributors
|
||||
to Gemini CLI. Produce professional, accurate, and consistent documentation to
|
||||
guide users of Gemini CLI.
|
||||
- **Technical Accuracy:** Do not invent facts, commands, code, API names, or
|
||||
output. All technical information specific to Gemini CLI must be based on code
|
||||
found within this directory and its subdirectories.
|
||||
- **Style Authority:** Your source for writing guidance and style is the
|
||||
"Documentation contribution process" section in the root directory's
|
||||
`CONTRIBUTING.md` file, as well as any guidelines provided this section.
|
||||
- **Information Architecture Consideration:** Before proposing documentation
|
||||
changes, consider the information architecture. If a change adds significant
|
||||
new content to existing documents, evaluate if creating a new, more focused
|
||||
page or changes to `sidebar.json` would provide a better user experience.
|
||||
- **Proactive User Consideration:** The user experience should be a primary
|
||||
concern when making changes to documentation. Aim to fill gaps in existing
|
||||
knowledge whenever possible while keeping documentation concise and easy for
|
||||
users to understand. If changes might hinder user understanding or
|
||||
accessibility, proactively raise these concerns and propose alternatives.
|
||||
|
||||
## Comments policy
|
||||
|
||||
Only write high-value comments if at all. Avoid talking to the user through
|
||||
comments.
|
||||
|
||||
## Logging and Error Handling
|
||||
|
||||
- **Avoid Console Statements:** Do not use `console.log`, `console.error`, or
|
||||
similar methods directly.
|
||||
- **Non-User-Facing Logs:** For developer-facing debug messages, use
|
||||
`debugLogger` (from `@google/gemini-cli-core`).
|
||||
- **User-Facing Feedback:** To surface errors or warnings to the user, use
|
||||
`coreEvents.emitFeedback` (from `@google/gemini-cli-core`).
|
||||
|
||||
## General requirements
|
||||
|
||||
- If there is something you do not understand or is ambiguous, seek confirmation
|
||||
or clarification from the user before making changes based on assumptions.
|
||||
- Use hyphens instead of underscores in flag names (e.g. `my-flag` instead of
|
||||
`my_flag`).
|
||||
- Always refer to Gemini CLI as `Gemini CLI`, never `the Gemini CLI`.
|
||||
# Gemini CLI Project Context
|
||||
|
||||
Gemini CLI is an open-source AI agent that brings the power of Gemini directly
|
||||
into the terminal. It is designed to be a terminal-first, extensible, and
|
||||
powerful tool for developers.
|
||||
|
||||
## Project Overview
|
||||
|
||||
- **Purpose:** Provide a seamless terminal interface for Gemini models,
|
||||
supporting code understanding, generation, automation, and integration via MCP
|
||||
(Model Context Protocol).
|
||||
- **Main Technologies:**
|
||||
- **Runtime:** Node.js (>=20.0.0, recommended ~20.19.0 for development)
|
||||
- **Language:** TypeScript
|
||||
- **UI Framework:** React (using [Ink](https://github.com/vadimdemedes/ink)
|
||||
for CLI rendering)
|
||||
- **Testing:** Vitest
|
||||
- **Bundling:** esbuild
|
||||
- **Linting/Formatting:** ESLint, Prettier
|
||||
- **Architecture:** Monorepo structure using npm workspaces.
|
||||
- `packages/cli`: User-facing terminal UI, input processing, and display
|
||||
rendering.
|
||||
- `packages/core`: Backend logic, Gemini API orchestration, prompt
|
||||
construction, and tool execution.
|
||||
- `packages/core/src/tools/`: Built-in tools for file system, shell, and web
|
||||
operations.
|
||||
- `packages/a2a-server`: Experimental Agent-to-Agent server.
|
||||
- `packages/vscode-ide-companion`: VS Code extension pairing with the CLI.
|
||||
|
||||
## Building and Running
|
||||
|
||||
- **Install Dependencies:** `npm install`
|
||||
- **Build All:** `npm run build:all` (Builds packages, sandbox, and VS Code
|
||||
companion)
|
||||
- **Build Packages:** `npm run build`
|
||||
- **Run in Development:** `npm run start`
|
||||
- **Run in Debug Mode:** `npm run debug` (Enables Node.js inspector)
|
||||
- **Bundle Project:** `npm run bundle`
|
||||
- **Clean Artifacts:** `npm run clean`
|
||||
|
||||
## Testing and Quality
|
||||
|
||||
- **Test Commands:**
|
||||
- **Unit (All):** `npm run test`
|
||||
- **Integration (E2E):** `npm run test:e2e`
|
||||
- **Workspace-Specific:** `npm test -w <pkg> -- <path>` (Note: `<path>` must
|
||||
be relative to the workspace root, e.g.,
|
||||
`-w @google/gemini-cli-core -- src/routing/modelRouterService.test.ts`)
|
||||
- **Full Validation:** `npm run preflight` (Heaviest check; runs clean, install,
|
||||
build, lint, type check, and tests. Recommended before submitting PRs.)
|
||||
- **Individual Checks:** `npm run lint` / `npm run format` / `npm run typecheck`
|
||||
|
||||
## Development Conventions
|
||||
|
||||
- **Contributions:** Follow the process outlined in `CONTRIBUTING.md`. Requires
|
||||
signing the Google CLA.
|
||||
- **Pull Requests:** Keep PRs small, focused, and linked to an existing issue.
|
||||
- **Commit Messages:** Follow the
|
||||
[Conventional Commits](https://www.conventionalcommits.org/) standard.
|
||||
- **Coding Style:** Adhere to existing patterns in `packages/cli` (React/Ink)
|
||||
and `packages/core` (Backend logic).
|
||||
- **Imports:** Use specific imports and avoid restricted relative imports
|
||||
between packages (enforced by ESLint).
|
||||
|
||||
## Documentation
|
||||
|
||||
- Located in the `docs/` directory.
|
||||
- Architecture overview: `docs/architecture.md`.
|
||||
- Contribution guide: `CONTRIBUTING.md`.
|
||||
- Documentation is organized via `docs/sidebar.json`.
|
||||
- Follows the
|
||||
[Google Developer Documentation Style Guide](https://developers.google.com/style).
|
||||
|
||||
@@ -18,6 +18,93 @@ on GitHub.
|
||||
| [Preview](preview.md) | Experimental features ready for early feedback. |
|
||||
| [Stable](latest.md) | Stable, recommended for general use. |
|
||||
|
||||
## Announcements: v0.25.0 - 2026-01-20
|
||||
|
||||
- **Skills and Agents Improvements:** We've enhanced the `activate_skill` tool,
|
||||
added a new `pr-creator` skill
|
||||
([#16232](https://github.com/google-gemini/gemini-cli/pull/16232) by
|
||||
[@NTaylorMullen](https://github.com/NTaylorMullen)), enabled skills by
|
||||
default, improved the `cli_help` agent
|
||||
([#16100](https://github.com/google-gemini/gemini-cli/pull/16100) by
|
||||
[@scidomino](https://github.com/scidomino)), and added a new `/agents refresh`
|
||||
command ([#16204](https://github.com/google-gemini/gemini-cli/pull/16204) by
|
||||
[@joshualitt](https://github.com/joshualitt)).
|
||||
- **UI/UX Refinements:** You'll notice more transparent feedback for skills
|
||||
([#15954](https://github.com/google-gemini/gemini-cli/pull/15954) by
|
||||
[@NTaylorMullen](https://github.com/NTaylorMullen)), the ability to switch
|
||||
focus between the shell and input with Tab
|
||||
([#14332](https://github.com/google-gemini/gemini-cli/pull/14332) by
|
||||
[@jacob314](https://github.com/jacob314)), and dynamic terminal tab titles
|
||||
([#16378](https://github.com/google-gemini/gemini-cli/pull/16378) by
|
||||
[@NTaylorMullen](https://github.com/NTaylorMullen)).
|
||||
- **Core Functionality & Performance:** This release includes support for
|
||||
built-in agent skills
|
||||
([#16045](https://github.com/google-gemini/gemini-cli/pull/16045) by
|
||||
[@NTaylorMullen](https://github.com/NTaylorMullen)), refined Gemini 3 system
|
||||
instructions ([#16139](https://github.com/google-gemini/gemini-cli/pull/16139)
|
||||
by [@NTaylorMullen](https://github.com/NTaylorMullen)), caching for ignore
|
||||
instances to improve performance
|
||||
([#16185](https://github.com/google-gemini/gemini-cli/pull/16185) by
|
||||
[@EricRahm](https://github.com/EricRahm)), and enhanced retry mechanisms
|
||||
([#16489](https://github.com/google-gemini/gemini-cli/pull/16489) by
|
||||
[@sehoon38](https://github.com/sehoon38)).
|
||||
- **Bug Fixes and Stability:** We've squashed numerous bugs across the CLI,
|
||||
core, and workflows, addressing issues with subagent delegation, unicode
|
||||
character crashes, and sticky header regressions.
|
||||
|
||||
## Announcements: v0.24.0 - 2026-01-14
|
||||
|
||||
- **Agent Skills:** We've introduced significant advancements in Agent Skills.
|
||||
This includes initial documentation and tutorials to help you get started,
|
||||
alongside enhanced support for remote agents, allowing for more distributed
|
||||
and powerful automation within Gemini CLI.
|
||||
([#15869](https://github.com/google-gemini/gemini-cli/pull/15869) by
|
||||
[@NTaylorMullen](https://github.com/NTaylorMullen)),
|
||||
([#16013](https://github.com/google-gemini/gemini-cli/pull/16013) by
|
||||
[@adamweidman](https://github.com/adamweidman))
|
||||
- **Improved UI/UX:** The user interface has received several updates, featuring
|
||||
visual indicators for hook execution, a more refined display for settings, and
|
||||
the ability to use the Tab key to effortlessly switch focus between the shell
|
||||
and input areas.
|
||||
([#15408](https://github.com/google-gemini/gemini-cli/pull/15408) by
|
||||
[@abhipatel12](https://github.com/abhipatel12)),
|
||||
([#14332](https://github.com/google-gemini/gemini-cli/pull/14332) by
|
||||
[@galz10](https://github.com/galz10))
|
||||
- **Enhanced Security:** Security has been a major focus, with default folder
|
||||
trust now set to untrusted for increased safety. The Policy Engine has been
|
||||
improved to allow specific modes in user and administrator policies, and
|
||||
granular allowlisting for shell commands has been implemented, providing finer
|
||||
control over tool execution.
|
||||
([#15943](https://github.com/google-gemini/gemini-cli/pull/15943) by
|
||||
[@galz10](https://github.com/galz10)),
|
||||
([#15977](https://github.com/google-gemini/gemini-cli/pull/15977) by
|
||||
[@NTaylorMullen](https://github.com/NTaylorMullen))
|
||||
- **Core Functionality:** This release includes a mandatory MessageBus
|
||||
injection, marking Phase 3 of a hard migration to a more robust internal
|
||||
communication system. We've also added support for built-in skills with the
|
||||
CLI itself, and enhanced model routing to effectively utilize subagents.
|
||||
([#15776](https://github.com/google-gemini/gemini-cli/pull/15776) by
|
||||
[@abhipatel12](https://github.com/abhipatel12)),
|
||||
([#16300](https://github.com/google-gemini/gemini-cli/pull/16300) by
|
||||
[@NTaylorMullen](https://github.com/NTaylorMullen))
|
||||
- **Terminal Features:** Terminal interactions are more seamless with new
|
||||
features like OSC 52 paste support, along with fixes for Windows clipboard
|
||||
paste issues and general improvements to pasting in Windows terminals.
|
||||
([#15336](https://github.com/google-gemini/gemini-cli/pull/15336) by
|
||||
[@scidomino](https://github.com/scidomino)),
|
||||
([#15932](https://github.com/google-gemini/gemini-cli/pull/15932) by
|
||||
[@scidomino](https://github.com/scidomino))
|
||||
- **New Commands:** To manage the new features, we've added several new
|
||||
commands: `/agents refresh` to update agent configurations, `/skills reload`
|
||||
to refresh skill definitions, and `/skills install/uninstall` for easier
|
||||
management of your Agent Skills.
|
||||
([#16204](https://github.com/google-gemini/gemini-cli/pull/16204) by
|
||||
[@NTaylorMullen](https://github.com/NTaylorMullen)),
|
||||
([#15865](https://github.com/google-gemini/gemini-cli/pull/15865) by
|
||||
[@NTaylorMullen](https://github.com/NTaylorMullen)),
|
||||
([#16377](https://github.com/google-gemini/gemini-cli/pull/16377) by
|
||||
[@NTaylorMullen](https://github.com/NTaylorMullen))
|
||||
|
||||
## Announcements: v0.23.0 - 2026-01-07
|
||||
|
||||
- 🎉 **Experimental Agent Skills Support in Preview:** Gemini CLI now supports
|
||||
|
||||
+355
-150
@@ -1,6 +1,6 @@
|
||||
# Latest stable release: v0.23.0
|
||||
# Latest stable release: v0.25.0
|
||||
|
||||
Released: January 6, 2026
|
||||
Released: January 20, 2026
|
||||
|
||||
For most users, our latest stable release is the recommended release. Install
|
||||
the latest stable version with:
|
||||
@@ -11,155 +11,360 @@ npm install -g @google/gemini-cli
|
||||
|
||||
## Highlights
|
||||
|
||||
- **Gemini CLI wrapped:** Run `npx gemini-wrapped` to visualize your usage
|
||||
stats, top models, languages, and more!
|
||||
- **Windows clipboard image support:** Windows users can now paste images
|
||||
directly from their clipboard into the CLI using `Alt`+`V`.
|
||||
([pr](https://github.com/google-gemini/gemini-cli/pull/13997) by
|
||||
[@sgeraldes](https://github.com/sgeraldes))
|
||||
- **Terminal background color detection:** Automatically optimizes your
|
||||
terminal's background color to select compatible themes and provide
|
||||
accessibility warnings.
|
||||
([pr](https://github.com/google-gemini/gemini-cli/pull/15132) by
|
||||
[@jacob314](https://github.com/jacob314))
|
||||
- **Session logout:** Use the new `/logout` command to instantly clear
|
||||
credentials and reset your authentication state for seamless account
|
||||
switching. ([pr](https://github.com/google-gemini/gemini-cli/pull/13383) by
|
||||
[@CN-Scars](https://github.com/CN-Scars))
|
||||
- **Skills and Agents Improvements:** Enhanced `activate_skill` tool, new
|
||||
`pr-creator` skill, default enablement of skills, improved `cli_help` agent,
|
||||
and a new `/agents refresh` command.
|
||||
- **UI/UX Refinements:** Transparent feedback for skills, ability to switch
|
||||
focus between shell and input with Tab, and dynamic terminal tab titles.
|
||||
- **Core Functionality & Performance:** Support for built-in agent skills,
|
||||
refined Gemini 3 system instructions, caching ignore instances for
|
||||
performance, and improved retry mechanisms.
|
||||
- **Bug Fixes and Stability:** Numerous bug fixes across the CLI, core, and
|
||||
workflows, including issues with subagent delegation, unicode character
|
||||
crashes, and sticky header regressions.
|
||||
|
||||
## What's changed
|
||||
## What's Changed
|
||||
|
||||
- Code assist service metrics. by @gundermanc in
|
||||
https://github.com/google-gemini/gemini-cli/pull/15024
|
||||
- chore/release: bump version to 0.21.0-nightly.20251216.bb0c0d8ee by
|
||||
@gemini-cli-robot in https://github.com/google-gemini/gemini-cli/pull/15121
|
||||
- Docs by @Roaimkhan in https://github.com/google-gemini/gemini-cli/pull/15103
|
||||
- Use official ACP SDK and support HTTP/SSE based MCP servers by @SteffenDE in
|
||||
https://github.com/google-gemini/gemini-cli/pull/13856
|
||||
- Remove foreground for themes other than shades of purple and holiday. by
|
||||
@jacob314 in https://github.com/google-gemini/gemini-cli/pull/14606
|
||||
- chore: remove repo specific tips by @jackwotherspoon in
|
||||
https://github.com/google-gemini/gemini-cli/pull/15164
|
||||
- chore: remove user query from footer in debug mode by @jackwotherspoon in
|
||||
https://github.com/google-gemini/gemini-cli/pull/15169
|
||||
- Disallow unnecessary awaits. by @gundermanc in
|
||||
https://github.com/google-gemini/gemini-cli/pull/15172
|
||||
- Add one to the padding in settings dialog to avoid flicker. by @jacob314 in
|
||||
https://github.com/google-gemini/gemini-cli/pull/15173
|
||||
- feat(core): introduce remote agent infrastructure and rename local executor by
|
||||
@adamfweidman in https://github.com/google-gemini/gemini-cli/pull/15110
|
||||
- feat(cli): Add `/auth logout` command to clear credentials and auth state by
|
||||
@CN-Scars in https://github.com/google-gemini/gemini-cli/pull/13383
|
||||
- (fix) Automated pr labeller by @DaanVersavel in
|
||||
https://github.com/google-gemini/gemini-cli/pull/14885
|
||||
- feat: launch Gemini 3 Flash in Gemini CLI ⚡️⚡️⚡️ by @scidomino in
|
||||
https://github.com/google-gemini/gemini-cli/pull/15196
|
||||
- Refactor: Migrate console.error in ripGrep.ts to debugLogger by @Adib234 in
|
||||
https://github.com/google-gemini/gemini-cli/pull/15201
|
||||
- chore: update a2a-js to 0.3.7 by @adamfweidman in
|
||||
https://github.com/google-gemini/gemini-cli/pull/15197
|
||||
- chore(core): remove redundant isModelAvailabilityServiceEnabled toggle and
|
||||
clean up dead code by @adamfweidman in
|
||||
https://github.com/google-gemini/gemini-cli/pull/15207
|
||||
- feat(core): Late resolve `GenerateContentConfig`s and reduce mutation. by
|
||||
@joshualitt in https://github.com/google-gemini/gemini-cli/pull/14920
|
||||
- Respect previewFeatures value from the remote flag if undefined by @sehoon38
|
||||
in https://github.com/google-gemini/gemini-cli/pull/15214
|
||||
- feat(ui): add Windows clipboard image support and Alt+V paste workaround by
|
||||
@jacob314 in https://github.com/google-gemini/gemini-cli/pull/15218
|
||||
- chore(core): remove legacy fallback flags and migrate loop detection by
|
||||
@adamfweidman in https://github.com/google-gemini/gemini-cli/pull/15213
|
||||
- fix(ui): Prevent eager slash command completion hiding sibling commands by
|
||||
@SandyTao520 in https://github.com/google-gemini/gemini-cli/pull/15224
|
||||
- Docs: Update Changelog for Dec 17, 2025 by @jkcinouye in
|
||||
https://github.com/google-gemini/gemini-cli/pull/15204
|
||||
- Code Assist backend telemetry for user accept/reject of suggestions by
|
||||
@gundermanc in https://github.com/google-gemini/gemini-cli/pull/15206
|
||||
- fix(cli): correct initial history length handling for chat commands by
|
||||
@SandyTao520 in https://github.com/google-gemini/gemini-cli/pull/15223
|
||||
- chore/release: bump version to 0.21.0-nightly.20251218.739c02bd6 by
|
||||
@gemini-cli-robot in https://github.com/google-gemini/gemini-cli/pull/15231
|
||||
- Change detailed model stats to use a new shared Table class to resolve
|
||||
robustness issues. by @jacob314 in
|
||||
https://github.com/google-gemini/gemini-cli/pull/15208
|
||||
- feat: add agent toml parser by @abhipatel12 in
|
||||
https://github.com/google-gemini/gemini-cli/pull/15112
|
||||
- Add core tool that adds all context from the core package. by @jacob314 in
|
||||
https://github.com/google-gemini/gemini-cli/pull/15238
|
||||
- (docs): Add reference section to hooks documentation by @abhipatel12 in
|
||||
https://github.com/google-gemini/gemini-cli/pull/15159
|
||||
- feat(hooks): add support for friendly names and descriptions by @abhipatel12
|
||||
in https://github.com/google-gemini/gemini-cli/pull/15174
|
||||
- feat: Detect background color by @jacob314 in
|
||||
https://github.com/google-gemini/gemini-cli/pull/15132
|
||||
- add 3.0 to allowed sensitive keywords by @scidomino in
|
||||
https://github.com/google-gemini/gemini-cli/pull/15276
|
||||
- feat: Pass additional environment variables to shell execution by @galz10 in
|
||||
https://github.com/google-gemini/gemini-cli/pull/15160
|
||||
- Remove unused code by @scidomino in
|
||||
https://github.com/google-gemini/gemini-cli/pull/15290
|
||||
- Handle all 429 as retryableQuotaError by @sehoon38 in
|
||||
https://github.com/google-gemini/gemini-cli/pull/15288
|
||||
- Remove unnecessary dependencies by @scidomino in
|
||||
https://github.com/google-gemini/gemini-cli/pull/15291
|
||||
- fix: prevent infinite loop in prompt completion on error by @galz10 in
|
||||
https://github.com/google-gemini/gemini-cli/pull/14548
|
||||
- fix(ui): show command suggestions even on perfect match and sort them by
|
||||
@SandyTao520 in https://github.com/google-gemini/gemini-cli/pull/15287
|
||||
- feat(hooks): reduce log verbosity and improve error reporting in UI by
|
||||
@abhipatel12 in https://github.com/google-gemini/gemini-cli/pull/15297
|
||||
- feat: simplify tool confirmation labels for better UX by @NTaylorMullen in
|
||||
https://github.com/google-gemini/gemini-cli/pull/15296
|
||||
- chore/release: bump version to 0.21.0-nightly.20251219.70696e364 by
|
||||
@gemini-cli-robot in https://github.com/google-gemini/gemini-cli/pull/15301
|
||||
- feat(core): Implement JIT context memory loading and UI sync by @SandyTao520
|
||||
in https://github.com/google-gemini/gemini-cli/pull/14469
|
||||
- feat(ui): Put "Allow for all future sessions" behind a setting off by default.
|
||||
by @jacob314 in https://github.com/google-gemini/gemini-cli/pull/15322
|
||||
- fix(cli):change the placeholder of input during the shell mode by
|
||||
@JayadityaGit in https://github.com/google-gemini/gemini-cli/pull/15135
|
||||
- Validate OAuth resource parameter matches MCP server URL by @galz10 in
|
||||
https://github.com/google-gemini/gemini-cli/pull/15289
|
||||
- docs(cli): add System Prompt Override (GEMINI_SYSTEM_MD) by @ashmod in
|
||||
https://github.com/google-gemini/gemini-cli/pull/9515
|
||||
- more robust command parsing logs by @scidomino in
|
||||
https://github.com/google-gemini/gemini-cli/pull/15339
|
||||
- Introspection agent demo by @scidomino in
|
||||
https://github.com/google-gemini/gemini-cli/pull/15232
|
||||
- fix(core): sanitize hook command expansion and prevent injection by
|
||||
@SandyTao520 in https://github.com/google-gemini/gemini-cli/pull/15343
|
||||
- fix(folder trust): add validation for trusted folder level by @adamfweidman in
|
||||
https://github.com/google-gemini/gemini-cli/pull/12215
|
||||
- fix(cli): fix right border overflow in trust dialogs by @galz10 in
|
||||
https://github.com/google-gemini/gemini-cli/pull/15350
|
||||
- fix(policy): fix bug where accepting-edits continued after it was turned off
|
||||
by @jacob314 in https://github.com/google-gemini/gemini-cli/pull/15351
|
||||
- fix: prevent infinite relaunch loop when --resume fails (#14941) by @Ying-xi
|
||||
in https://github.com/google-gemini/gemini-cli/pull/14951
|
||||
- chore/release: bump version to 0.21.0-nightly.20251220.41a1a3eed by
|
||||
@gemini-cli-robot in https://github.com/google-gemini/gemini-cli/pull/15352
|
||||
- feat(telemetry): add clearcut logging for hooks by @abhipatel12 in
|
||||
https://github.com/google-gemini/gemini-cli/pull/15405
|
||||
- fix(core): Add `.geminiignore` support to SearchText tool by @xyrolle in
|
||||
https://github.com/google-gemini/gemini-cli/pull/13763
|
||||
- fix(patch): cherry-pick 0843d9a to release/v0.23.0-preview.0-pr-15443 to patch
|
||||
version v0.23.0-preview.0 and create version 0.23.0-preview.1 by
|
||||
@gemini-cli-robot in https://github.com/google-gemini/gemini-cli/pull/15445
|
||||
- fix(patch): cherry-pick 9cdb267 to release/v0.23.0-preview.1-pr-15494 to patch
|
||||
version v0.23.0-preview.1 and create version 0.23.0-preview.2 by
|
||||
@gemini-cli-robot in https://github.com/google-gemini/gemini-cli/pull/15592
|
||||
- fix(patch): cherry-pick 37be162 to release/v0.23.0-preview.2-pr-15601 to patch
|
||||
version v0.23.0-preview.2 and create version 0.23.0-preview.3 by
|
||||
@gemini-cli-robot in https://github.com/google-gemini/gemini-cli/pull/15603
|
||||
- fix(patch): cherry-pick 07e597d to release/v0.23.0-preview.3-pr-15684
|
||||
[CONFLICTS] by @gemini-cli-robot in
|
||||
https://github.com/google-gemini/gemini-cli/pull/15734
|
||||
- fix(patch): cherry-pick c31f053 to release/v0.23.0-preview.4-pr-16004 to patch
|
||||
version v0.23.0-preview.4 and create version 0.23.0-preview.5 by
|
||||
@gemini-cli-robot in https://github.com/google-gemini/gemini-cli/pull/16027
|
||||
- fix(patch): cherry-pick 788bb04 to release/v0.23.0-preview.5-pr-15817
|
||||
[CONFLICTS] by @gemini-cli-robot in
|
||||
https://github.com/google-gemini/gemini-cli/pull/16038
|
||||
- feat(core): improve activate_skill tool and use lowercase XML tags by
|
||||
@NTaylorMullen in
|
||||
[#16009](https://github.com/google-gemini/gemini-cli/pull/16009)
|
||||
- Add initiation method telemetry property by @gundermanc in
|
||||
[#15818](https://github.com/google-gemini/gemini-cli/pull/15818)
|
||||
- chore(release): bump version to 0.25.0-nightly.20260107.59a18e710 by
|
||||
@gemini-cli-robot in
|
||||
[#16048](https://github.com/google-gemini/gemini-cli/pull/16048)
|
||||
- Hx support by @kevinfjiang in
|
||||
[#16032](https://github.com/google-gemini/gemini-cli/pull/16032)
|
||||
- [Skills] Foundation: Centralize management logic and feedback rendering by
|
||||
@NTaylorMullen in
|
||||
[#15952](https://github.com/google-gemini/gemini-cli/pull/15952)
|
||||
- Introduce GEMINI_CLI_HOME for strict test isolation by @NTaylorMullen in
|
||||
[#15907](https://github.com/google-gemini/gemini-cli/pull/15907)
|
||||
- [Skills] Multi-scope skill enablement and shadowing fix by @NTaylorMullen in
|
||||
[#15953](https://github.com/google-gemini/gemini-cli/pull/15953)
|
||||
- policy: extract legacy policy from core tool scheduler to policy engine by
|
||||
@abhipatel12 in
|
||||
[#15902](https://github.com/google-gemini/gemini-cli/pull/15902)
|
||||
- Enhance TestRig with process management and timeouts by @NTaylorMullen in
|
||||
[#15908](https://github.com/google-gemini/gemini-cli/pull/15908)
|
||||
- Update troubleshooting doc for UNABLE_TO_GET_ISSUER_CERT_LOCALLY by @sehoon38
|
||||
in [#16069](https://github.com/google-gemini/gemini-cli/pull/16069)
|
||||
- Add keytar to dependencies by @chrstnb in
|
||||
[#15928](https://github.com/google-gemini/gemini-cli/pull/15928)
|
||||
- Simplify extension settings command by @chrstnb in
|
||||
[#16001](https://github.com/google-gemini/gemini-cli/pull/16001)
|
||||
- feat(admin): implement extensions disabled by @skeshive in
|
||||
[#16024](https://github.com/google-gemini/gemini-cli/pull/16024)
|
||||
- Core data structure updates for Rewind functionality by @Adib234 in
|
||||
[#15714](https://github.com/google-gemini/gemini-cli/pull/15714)
|
||||
- feat(hooks): simplify hook firing with HookSystem wrapper methods by @ved015
|
||||
in [#15982](https://github.com/google-gemini/gemini-cli/pull/15982)
|
||||
- Add exp.gws_experiment field to LogEventEntry by @gsquared94 in
|
||||
[#16062](https://github.com/google-gemini/gemini-cli/pull/16062)
|
||||
- Revert "feat(admin): implement extensions disabled" by @chrstnb in
|
||||
[#16082](https://github.com/google-gemini/gemini-cli/pull/16082)
|
||||
- feat(core): Decouple enabling hooks UI from subsystem. by @joshualitt in
|
||||
[#16074](https://github.com/google-gemini/gemini-cli/pull/16074)
|
||||
- docs: add docs for hooks + extensions by @abhipatel12 in
|
||||
[#16073](https://github.com/google-gemini/gemini-cli/pull/16073)
|
||||
- feat(core): Preliminary changes for subagent model routing. by @joshualitt in
|
||||
[#16035](https://github.com/google-gemini/gemini-cli/pull/16035)
|
||||
- Optimize CI workflow: Parallelize jobs and cache linters by @NTaylorMullen in
|
||||
[#16054](https://github.com/google-gemini/gemini-cli/pull/16054)
|
||||
- Add option to fallback for capacity errors in ProQuotaDi… by @sehoon38 in
|
||||
[#16050](https://github.com/google-gemini/gemini-cli/pull/16050)
|
||||
- feat: add confirmation details support + jsonrpc vs http rest support by
|
||||
@adamfweidman in
|
||||
[#16079](https://github.com/google-gemini/gemini-cli/pull/16079)
|
||||
- fix(workflows): fix and limit labels for pr-triage.sh script by @jacob314 in
|
||||
[#16096](https://github.com/google-gemini/gemini-cli/pull/16096)
|
||||
- Fix and rename introspection agent -> cli help agent by @scidomino in
|
||||
[#16097](https://github.com/google-gemini/gemini-cli/pull/16097)
|
||||
- Docs: Changelogs update 20260105 by @jkcinouye in
|
||||
[#15937](https://github.com/google-gemini/gemini-cli/pull/15937)
|
||||
- enable cli_help agent by default by @scidomino in
|
||||
[#16100](https://github.com/google-gemini/gemini-cli/pull/16100)
|
||||
- Optimize json-output tests with mock responses by @NTaylorMullen in
|
||||
[#16102](https://github.com/google-gemini/gemini-cli/pull/16102)
|
||||
- Fix CI for forks by @scidomino in
|
||||
[#16113](https://github.com/google-gemini/gemini-cli/pull/16113)
|
||||
- Reduce nags about PRs that reference issues but don't fix them. by @jacob314
|
||||
in [#16112](https://github.com/google-gemini/gemini-cli/pull/16112)
|
||||
- feat(cli): add filepath autosuggestion after slash commands by @jasmeetsb in
|
||||
[#14738](https://github.com/google-gemini/gemini-cli/pull/14738)
|
||||
- Add upgrade option for paid users by @cayden-google in
|
||||
[#15978](https://github.com/google-gemini/gemini-cli/pull/15978)
|
||||
- [Skills] UX Polishing: Transparent feedback and CLI refinements by
|
||||
@NTaylorMullen in
|
||||
[#15954](https://github.com/google-gemini/gemini-cli/pull/15954)
|
||||
- Polish: Move 'Failed to load skills' warning to debug logs by @NTaylorMullen
|
||||
in [#16142](https://github.com/google-gemini/gemini-cli/pull/16142)
|
||||
- feat(cli): export chat history in /bug and prefill GitHub issue by
|
||||
@NTaylorMullen in
|
||||
[#16115](https://github.com/google-gemini/gemini-cli/pull/16115)
|
||||
- bug(core): fix issue with overrides to bases. by @joshualitt in
|
||||
[#15255](https://github.com/google-gemini/gemini-cli/pull/15255)
|
||||
- enableInteractiveShell for external tooling relying on a2a server by
|
||||
@DavidAPierce in
|
||||
[#16080](https://github.com/google-gemini/gemini-cli/pull/16080)
|
||||
- Reapply "feat(admin): implement extensions disabled" (#16082) by @skeshive in
|
||||
[#16109](https://github.com/google-gemini/gemini-cli/pull/16109)
|
||||
- bug(core): Fix spewie getter in hookTranslator.ts by @joshualitt in
|
||||
[#16108](https://github.com/google-gemini/gemini-cli/pull/16108)
|
||||
- feat(hooks): add mcp_context to BeforeTool and AfterTool hook inputs by @vrv
|
||||
in [#15656](https://github.com/google-gemini/gemini-cli/pull/15656)
|
||||
- Add extension linking capabilities in cli by @kevinjwang1 in
|
||||
[#16040](https://github.com/google-gemini/gemini-cli/pull/16040)
|
||||
- Update the page's title to be consistent and show in site. by @kschaab in
|
||||
[#16174](https://github.com/google-gemini/gemini-cli/pull/16174)
|
||||
- docs: correct typo in bufferFastReturn JSDoc ("accomodate" → "accommodate") by
|
||||
@minglu7 in [#16056](https://github.com/google-gemini/gemini-cli/pull/16056)
|
||||
- fix: typo in MCP servers settings description by @alphanota in
|
||||
[#15929](https://github.com/google-gemini/gemini-cli/pull/15929)
|
||||
- fix: yolo should auto allow redirection by @abhipatel12 in
|
||||
[#16183](https://github.com/google-gemini/gemini-cli/pull/16183)
|
||||
- fix(cli): disableYoloMode shouldn't enforce default approval mode against args
|
||||
by @psinha40898 in
|
||||
[#16155](https://github.com/google-gemini/gemini-cli/pull/16155)
|
||||
- feat: add native Sublime Text support to IDE detection by @phreakocious in
|
||||
[#16083](https://github.com/google-gemini/gemini-cli/pull/16083)
|
||||
- refactor(core): extract ToolModificationHandler from scheduler by @abhipatel12
|
||||
in [#16118](https://github.com/google-gemini/gemini-cli/pull/16118)
|
||||
- Add support for Antigravity terminal in terminal setup utility by @raky291 in
|
||||
[#16051](https://github.com/google-gemini/gemini-cli/pull/16051)
|
||||
- feat(core): Wire up model routing to subagents. by @joshualitt in
|
||||
[#16043](https://github.com/google-gemini/gemini-cli/pull/16043)
|
||||
- feat(cli): add /agents slash command to list available agents by @adamfweidman
|
||||
in [#16182](https://github.com/google-gemini/gemini-cli/pull/16182)
|
||||
- docs(cli): fix includeDirectories nesting in configuration.md by @maru0804 in
|
||||
[#15067](https://github.com/google-gemini/gemini-cli/pull/15067)
|
||||
- feat: implement file system reversion utilities for rewind by @Adib234 in
|
||||
[#15715](https://github.com/google-gemini/gemini-cli/pull/15715)
|
||||
- Always enable redaction in GitHub actions. by @gundermanc in
|
||||
[#16200](https://github.com/google-gemini/gemini-cli/pull/16200)
|
||||
- fix: remove unsupported 'enabled' key from workflow config by @Han5991 in
|
||||
[#15611](https://github.com/google-gemini/gemini-cli/pull/15611)
|
||||
- docs: Remove redundant and duplicate documentation files by @liqzheng in
|
||||
[#14699](https://github.com/google-gemini/gemini-cli/pull/14699)
|
||||
- docs: shorten run command and use published version by @dsherret in
|
||||
[#16172](https://github.com/google-gemini/gemini-cli/pull/16172)
|
||||
- test(command-registry): increase initialization test timeout by @wszqkzqk in
|
||||
[#15979](https://github.com/google-gemini/gemini-cli/pull/15979)
|
||||
- Ensure TERM is set to xterm-256color by @falouu in
|
||||
[#15828](https://github.com/google-gemini/gemini-cli/pull/15828)
|
||||
- The telemetry.js script should handle paths that contain spaces by @JohnJAS in
|
||||
[#12078](https://github.com/google-gemini/gemini-cli/pull/12078)
|
||||
- ci: guard links workflow from running on forks by @wtanaka in
|
||||
[#15461](https://github.com/google-gemini/gemini-cli/pull/15461)
|
||||
- ci: guard nightly release workflow from running on forks by @wtanaka in
|
||||
[#15463](https://github.com/google-gemini/gemini-cli/pull/15463)
|
||||
- Support @ suggestions for subagenets by @sehoon38 in
|
||||
[#16201](https://github.com/google-gemini/gemini-cli/pull/16201)
|
||||
- feat(hooks): Support explicit stop and block execution control in model hooks
|
||||
by @SandyTao520 in
|
||||
[#15947](https://github.com/google-gemini/gemini-cli/pull/15947)
|
||||
- Refine Gemini 3 system instructions to reduce model verbosity by
|
||||
@NTaylorMullen in
|
||||
[#16139](https://github.com/google-gemini/gemini-cli/pull/16139)
|
||||
- chore: clean up unused models and use consts by @sehoon38 in
|
||||
[#16246](https://github.com/google-gemini/gemini-cli/pull/16246)
|
||||
- Always enable bracketed paste by @scidomino in
|
||||
[#16179](https://github.com/google-gemini/gemini-cli/pull/16179)
|
||||
- refactor: migrate clearCommand hook calls to HookSystem by @ved015 in
|
||||
[#16157](https://github.com/google-gemini/gemini-cli/pull/16157)
|
||||
- refactor: migrate app containter hook calls to hook system by @ishaanxgupta in
|
||||
[#16161](https://github.com/google-gemini/gemini-cli/pull/16161)
|
||||
- Show settings source in extensions lists by @chrstnb in
|
||||
[#16207](https://github.com/google-gemini/gemini-cli/pull/16207)
|
||||
- feat(skills): add pr-creator skill and enable skills by @NTaylorMullen in
|
||||
[#16232](https://github.com/google-gemini/gemini-cli/pull/16232)
|
||||
- fix: handle Shift+Space in Kitty keyboard protocol terminals by @tt-a1i in
|
||||
[#15767](https://github.com/google-gemini/gemini-cli/pull/15767)
|
||||
- feat(core, ui): Add /agents refresh command. by @joshualitt in
|
||||
[#16204](https://github.com/google-gemini/gemini-cli/pull/16204)
|
||||
- feat(core): add local experiments override via GEMINI_EXP by @kevin-ramdass in
|
||||
[#16181](https://github.com/google-gemini/gemini-cli/pull/16181)
|
||||
- feat(ui): reduce home directory warning noise and add opt-out setting by
|
||||
@NTaylorMullen in
|
||||
[#16229](https://github.com/google-gemini/gemini-cli/pull/16229)
|
||||
- refactor: migrate chatCompressionService to use HookSystem by @ved015 in
|
||||
[#16259](https://github.com/google-gemini/gemini-cli/pull/16259)
|
||||
- fix: properly use systemMessage for hooks in UI by @jackwotherspoon in
|
||||
[#16250](https://github.com/google-gemini/gemini-cli/pull/16250)
|
||||
- Infer modifyOtherKeys support by @scidomino in
|
||||
[#16270](https://github.com/google-gemini/gemini-cli/pull/16270)
|
||||
- feat(core): Cache ignore instances for performance by @EricRahm in
|
||||
[#16185](https://github.com/google-gemini/gemini-cli/pull/16185)
|
||||
- feat: apply remote admin settings (no-op) by @skeshive in
|
||||
[#16106](https://github.com/google-gemini/gemini-cli/pull/16106)
|
||||
- Autogenerate docs/cli/settings.md docs/getting-started/configuration.md was
|
||||
already autogenerated but settings.md was not. by @jacob314 in
|
||||
[#14408](https://github.com/google-gemini/gemini-cli/pull/14408)
|
||||
- refactor(config): remove legacy V1 settings migration logic by @galz10 in
|
||||
[#16252](https://github.com/google-gemini/gemini-cli/pull/16252)
|
||||
- Fix an issue where the agent stops prematurely by @gundermanc in
|
||||
[#16269](https://github.com/google-gemini/gemini-cli/pull/16269)
|
||||
- Update system prompt to prefer non-interactive commands by @NTaylorMullen in
|
||||
[#16117](https://github.com/google-gemini/gemini-cli/pull/16117)
|
||||
- Update ink version to 6.4.7 by @jacob314 in
|
||||
[#16284](https://github.com/google-gemini/gemini-cli/pull/16284)
|
||||
- Support for Built-in Agent Skills by @NTaylorMullen in
|
||||
[#16045](https://github.com/google-gemini/gemini-cli/pull/16045)
|
||||
- fix(skills): remove "Restart required" message from non-interactive commands
|
||||
by @NTaylorMullen in
|
||||
[#16307](https://github.com/google-gemini/gemini-cli/pull/16307)
|
||||
- remove unused sessionHookTriggers and exports by @ved015 in
|
||||
[#16324](https://github.com/google-gemini/gemini-cli/pull/16324)
|
||||
- Triage action cleanup by @bdmorgan in
|
||||
[#16319](https://github.com/google-gemini/gemini-cli/pull/16319)
|
||||
- fix: Add event-driven trigger to issue triage workflow by @bdmorgan in
|
||||
[#16334](https://github.com/google-gemini/gemini-cli/pull/16334)
|
||||
- fix(workflows): resolve triage workflow failures and actionlint errors by
|
||||
@bdmorgan in [#16338](https://github.com/google-gemini/gemini-cli/pull/16338)
|
||||
- docs: add note about experimental hooks by @abhipatel12 in
|
||||
[#16337](https://github.com/google-gemini/gemini-cli/pull/16337)
|
||||
- feat(cli): implement passive activity logger for session analysis by
|
||||
@SandyTao520 in
|
||||
[#15829](https://github.com/google-gemini/gemini-cli/pull/15829)
|
||||
- feat(cli): add /chat debug command for nightly builds by @abhipatel12 in
|
||||
[#16339](https://github.com/google-gemini/gemini-cli/pull/16339)
|
||||
- style: format pr-creator skill by @NTaylorMullen in
|
||||
[#16381](https://github.com/google-gemini/gemini-cli/pull/16381)
|
||||
- feat(cli): Hooks enable-all/disable-all feature with dynamic status by
|
||||
@AbdulTawabJuly in
|
||||
[#15552](https://github.com/google-gemini/gemini-cli/pull/15552)
|
||||
- fix(core): ensure silent local subagent delegation while allowing remote
|
||||
confirmation by @adamfweidman in
|
||||
[#16395](https://github.com/google-gemini/gemini-cli/pull/16395)
|
||||
- Markdown w/ Frontmatter Agent Parser by @sehoon38 in
|
||||
[#16094](https://github.com/google-gemini/gemini-cli/pull/16094)
|
||||
- Fix crash on unicode character by @chrstnb in
|
||||
[#16420](https://github.com/google-gemini/gemini-cli/pull/16420)
|
||||
- Attempt to resolve OOM w/ useMemo on history items by @chrstnb in
|
||||
[#16424](https://github.com/google-gemini/gemini-cli/pull/16424)
|
||||
- fix(core): ensure sub-agent schema and prompt refresh during runtime by
|
||||
@adamfweidman in
|
||||
[#16409](https://github.com/google-gemini/gemini-cli/pull/16409)
|
||||
- Update extension examples by @chrstnb in
|
||||
[#16274](https://github.com/google-gemini/gemini-cli/pull/16274)
|
||||
- revert the change that was recently added from a fix by @sehoon38 in
|
||||
[#16390](https://github.com/google-gemini/gemini-cli/pull/16390)
|
||||
- Add other hook wrapper methods to hooksystem by @ved015 in
|
||||
[#16361](https://github.com/google-gemini/gemini-cli/pull/16361)
|
||||
- feat: introduce useRewindLogic hook for conversation history navigation by
|
||||
@Adib234 in [#15716](https://github.com/google-gemini/gemini-cli/pull/15716)
|
||||
- docs: Fix formatting issue in memport documentation by @wanglc02 in
|
||||
[#14774](https://github.com/google-gemini/gemini-cli/pull/14774)
|
||||
- fix(policy): enhance shell command safety and parsing by @allenhutchison in
|
||||
[#15034](https://github.com/google-gemini/gemini-cli/pull/15034)
|
||||
- fix(core): avoid 'activate_skill' re-registration warning by @NTaylorMullen in
|
||||
[#16398](https://github.com/google-gemini/gemini-cli/pull/16398)
|
||||
- perf(workflows): optimize PR triage script for faster execution by @bdmorgan
|
||||
in [#16355](https://github.com/google-gemini/gemini-cli/pull/16355)
|
||||
- feat(admin): prompt user to restart the CLI if they change auth to oauth
|
||||
mid-session or don't have auth type selected at start of session by @skeshive
|
||||
in [#16426](https://github.com/google-gemini/gemini-cli/pull/16426)
|
||||
- Update cli-help agent's system prompt in sub-agents section by @sehoon38 in
|
||||
[#16441](https://github.com/google-gemini/gemini-cli/pull/16441)
|
||||
- Revert "Update extension examples" by @chrstnb in
|
||||
[#16442](https://github.com/google-gemini/gemini-cli/pull/16442)
|
||||
- Fix: add back fastreturn support by @scidomino in
|
||||
[#16440](https://github.com/google-gemini/gemini-cli/pull/16440)
|
||||
- feat(a2a): Introduce /memory command for a2a server by @cocosheng-g in
|
||||
[#14456](https://github.com/google-gemini/gemini-cli/pull/14456)
|
||||
- docs: fix broken internal link by using relative path by @Gong-Mi in
|
||||
[#15371](https://github.com/google-gemini/gemini-cli/pull/15371)
|
||||
- migrate yolo/auto-edit keybindings by @scidomino in
|
||||
[#16457](https://github.com/google-gemini/gemini-cli/pull/16457)
|
||||
- feat(cli): add install and uninstall commands for skills by @NTaylorMullen in
|
||||
[#16377](https://github.com/google-gemini/gemini-cli/pull/16377)
|
||||
- feat(ui): use Tab to switch focus between shell and input by @jacob314 in
|
||||
[#14332](https://github.com/google-gemini/gemini-cli/pull/14332)
|
||||
- feat(core): support shipping built-in skills with the CLI by @NTaylorMullen in
|
||||
[#16300](https://github.com/google-gemini/gemini-cli/pull/16300)
|
||||
- Collect hardware details telemetry. by @gundermanc in
|
||||
[#16119](https://github.com/google-gemini/gemini-cli/pull/16119)
|
||||
- feat(agents): improve UI feedback and parser reliability by @NTaylorMullen in
|
||||
[#16459](https://github.com/google-gemini/gemini-cli/pull/16459)
|
||||
- Migrate keybindings by @scidomino in
|
||||
[#16460](https://github.com/google-gemini/gemini-cli/pull/16460)
|
||||
- feat(cli): cleanup activity logs alongside session files by @SandyTao520 in
|
||||
[#16399](https://github.com/google-gemini/gemini-cli/pull/16399)
|
||||
- feat(cli): implement dynamic terminal tab titles for CLI status by
|
||||
@NTaylorMullen in
|
||||
[#16378](https://github.com/google-gemini/gemini-cli/pull/16378)
|
||||
- feat(core): add disableLLMCorrection setting to skip auto-correction in edit
|
||||
tools by @SandyTao520 in
|
||||
[#16000](https://github.com/google-gemini/gemini-cli/pull/16000)
|
||||
- fix: Set both tab and window title instead of just window title by
|
||||
@NTaylorMullen in
|
||||
[#16464](https://github.com/google-gemini/gemini-cli/pull/16464)
|
||||
- fix(policy): ensure MCP policies match unqualified names in non-interactive
|
||||
mode by @NTaylorMullen in
|
||||
[#16490](https://github.com/google-gemini/gemini-cli/pull/16490)
|
||||
- fix(cli): refine 'Action Required' indicator and focus hints by @NTaylorMullen
|
||||
in [#16497](https://github.com/google-gemini/gemini-cli/pull/16497)
|
||||
- Refactor beforeAgent and afterAgent hookEvents to follow desired output by
|
||||
@ved015 in [#16495](https://github.com/google-gemini/gemini-cli/pull/16495)
|
||||
- feat(agents): clarify mandatory YAML frontmatter for sub-agents by
|
||||
@NTaylorMullen in
|
||||
[#16515](https://github.com/google-gemini/gemini-cli/pull/16515)
|
||||
- docs(telemetry): add Google Cloud Monitoring dashboard documentation by @jerop
|
||||
in [#16520](https://github.com/google-gemini/gemini-cli/pull/16520)
|
||||
- Implement support for subagents as extensions. by @gundermanc in
|
||||
[#16473](https://github.com/google-gemini/gemini-cli/pull/16473)
|
||||
- refactor: make baseTimestamp optional in addItem and remove redundant calls by
|
||||
@sehoon38 in [#16471](https://github.com/google-gemini/gemini-cli/pull/16471)
|
||||
- Improve key binding names and descriptions by @scidomino in
|
||||
[#16529](https://github.com/google-gemini/gemini-cli/pull/16529)
|
||||
- feat(core, cli): Add support for agents in settings.json. by @joshualitt in
|
||||
[#16433](https://github.com/google-gemini/gemini-cli/pull/16433)
|
||||
- fix(cli): fix 'gemini skills install' unknown argument error by @NTaylorMullen
|
||||
in [#16537](https://github.com/google-gemini/gemini-cli/pull/16537)
|
||||
- chore(ui): optimize AgentsStatus layout with dense list style and group
|
||||
separation by @adamfweidman in
|
||||
[#16545](https://github.com/google-gemini/gemini-cli/pull/16545)
|
||||
- fix(cli): allow @ file selector on slash command lines by @galz10 in
|
||||
[#16370](https://github.com/google-gemini/gemini-cli/pull/16370)
|
||||
- fix(ui): resolve sticky header regression in tool messages by @jacob314 in
|
||||
[#16514](https://github.com/google-gemini/gemini-cli/pull/16514)
|
||||
- feat(core): Align internal agent settings with configs exposed through
|
||||
settings.json by @joshualitt in
|
||||
[#16458](https://github.com/google-gemini/gemini-cli/pull/16458)
|
||||
- fix(cli): copy uses OSC52 only in SSH/WSL by @assagman in
|
||||
[#16554](https://github.com/google-gemini/gemini-cli/pull/16554)
|
||||
- docs(skills): clarify skill directory structure and file location by
|
||||
@NTaylorMullen in
|
||||
[#16532](https://github.com/google-gemini/gemini-cli/pull/16532)
|
||||
- Fix: make ctrl+x use preferred editor by @scidomino in
|
||||
[#16556](https://github.com/google-gemini/gemini-cli/pull/16556)
|
||||
- fix(core): Resolve race condition in tool response reporting by @abhipatel12
|
||||
in [#16557](https://github.com/google-gemini/gemini-cli/pull/16557)
|
||||
- feat(ui): highlight persist mode status in ModelDialog by @sehoon38 in
|
||||
[#16483](https://github.com/google-gemini/gemini-cli/pull/16483)
|
||||
- refactor: clean up A2A task output for users and LLMs by @adamfweidman in
|
||||
[#16561](https://github.com/google-gemini/gemini-cli/pull/16561)
|
||||
- feat(core/ui): enhance retry mechanism and UX by @sehoon38 in
|
||||
[#16489](https://github.com/google-gemini/gemini-cli/pull/16489)
|
||||
- Modernize MaxSizedBox to use and ResizeObservers by @jacob314 in
|
||||
[#16565](https://github.com/google-gemini/gemini-cli/pull/16565)
|
||||
- Behavioral evals framework. by @gundermanc in
|
||||
[#16047](https://github.com/google-gemini/gemini-cli/pull/16047)
|
||||
- Aggregate test results. by @gundermanc in
|
||||
[#16581](https://github.com/google-gemini/gemini-cli/pull/16581)
|
||||
- feat(admin): support admin-enforced settings for Agent Skills by
|
||||
@NTaylorMullen in
|
||||
[#16406](https://github.com/google-gemini/gemini-cli/pull/16406)
|
||||
- fix(patch): cherry-pick cfdc4cf to release/v0.25.0-preview.0-pr-16759 to patch
|
||||
version v0.25.0-preview.0 and create version 0.25.0-preview.1 by
|
||||
@gemini-cli-robot in
|
||||
[#16866](https://github.com/google-gemini/gemini-cli/pull/16866)
|
||||
- Patch #16730 into v0.25.0 preview by @chrstnb in
|
||||
[#16882](https://github.com/google-gemini/gemini-cli/pull/16882)
|
||||
- fix(patch): cherry-pick 3b55581 to release/v0.25.0-preview.2-pr-16506 to patch
|
||||
version v0.25.0-preview.2 and create version 0.25.0-preview.3 by
|
||||
@gemini-cli-robot in
|
||||
[#17098](https://github.com/google-gemini/gemini-cli/pull/17098)
|
||||
|
||||
**Full changelog**:
|
||||
https://github.com/google-gemini/gemini-cli/compare/v0.22.5...v0.23.0
|
||||
https://github.com/google-gemini/gemini-cli/compare/v0.24.5...v0.25.0
|
||||
|
||||
+318
-210
@@ -1,6 +1,6 @@
|
||||
# Preview release: Release v0.24.0-preview.0
|
||||
# Preview release: Release v0.26.0-preview.0
|
||||
|
||||
Released: January 6, 2026
|
||||
Released: January 21, 2026
|
||||
|
||||
Our preview release includes the latest, new, and experimental features. This
|
||||
release may not be as stable as our [latest weekly release](latest.md).
|
||||
@@ -11,214 +11,322 @@ To install the preview release:
|
||||
npm install -g @google/gemini-cli@preview
|
||||
```
|
||||
|
||||
## What's changed
|
||||
## Highlights
|
||||
|
||||
- chore(core): refactor model resolution and cleanup fallback logic by
|
||||
@adamfweidman in https://github.com/google-gemini/gemini-cli/pull/15228
|
||||
- Add Folder Trust Support To Hooks by @sehoon38 in
|
||||
https://github.com/google-gemini/gemini-cli/pull/15325
|
||||
- Record timestamp with code assist metrics. by @gundermanc in
|
||||
https://github.com/google-gemini/gemini-cli/pull/15439
|
||||
- feat(policy): implement dynamic mode-aware policy evaluation by @abhipatel12
|
||||
in https://github.com/google-gemini/gemini-cli/pull/15307
|
||||
- fix(core): use debugLogger.debug for startup profiler logs by @NTaylorMullen
|
||||
in https://github.com/google-gemini/gemini-cli/pull/15443
|
||||
- feat(ui): Add security warning and improve layout for Hooks list by
|
||||
@SandyTao520 in https://github.com/google-gemini/gemini-cli/pull/15440
|
||||
- fix #15369, prevent crash on unhandled EIO error in readStdin cleanup by
|
||||
@ElecTwix in https://github.com/google-gemini/gemini-cli/pull/15410
|
||||
- chore: improve error messages for --resume by @jackwotherspoon in
|
||||
https://github.com/google-gemini/gemini-cli/pull/15360
|
||||
- chore: remove clipboard file by @jackwotherspoon in
|
||||
https://github.com/google-gemini/gemini-cli/pull/15447
|
||||
- Implemented unified secrets sanitization and env. redaction options by
|
||||
@gundermanc in https://github.com/google-gemini/gemini-cli/pull/15348
|
||||
- feat: automatic `/model` persistence across Gemini CLI sessions by @niyasrad
|
||||
in https://github.com/google-gemini/gemini-cli/pull/13199
|
||||
- refactor(core): remove deprecated permission aliases from BeforeToolHookOutput
|
||||
by @StoyanD in https://github.com/google-gemini/gemini-cli/pull/14855
|
||||
- fix: add missing `type` field to MCPServerConfig by @jackwotherspoon in
|
||||
https://github.com/google-gemini/gemini-cli/pull/15465
|
||||
- Make schema validation errors non-fatal by @jacob314 in
|
||||
https://github.com/google-gemini/gemini-cli/pull/15487
|
||||
- chore: limit MCP resources display to 10 by default by @jackwotherspoon in
|
||||
https://github.com/google-gemini/gemini-cli/pull/15489
|
||||
- Add experimental in-CLI extension install and uninstall subcommands by
|
||||
@chrstnb in https://github.com/google-gemini/gemini-cli/pull/15178
|
||||
- feat: Add A2A Client Manager and tests by @adamfweidman in
|
||||
https://github.com/google-gemini/gemini-cli/pull/15485
|
||||
- feat: terse transformations of image paths in text buffer by @psinha40898 in
|
||||
https://github.com/google-gemini/gemini-cli/pull/4924
|
||||
- Security: Project-level hook warnings by @sehoon38 in
|
||||
https://github.com/google-gemini/gemini-cli/pull/15470
|
||||
- Added modifyOtherKeys protocol support for tmux by @ved015 in
|
||||
https://github.com/google-gemini/gemini-cli/pull/15524
|
||||
- chore(core): fix comment typo by @Mapleeeeeeeeeee in
|
||||
https://github.com/google-gemini/gemini-cli/pull/15558
|
||||
- feat: Show snowfall animation for holiday theme by @sehoon38 in
|
||||
https://github.com/google-gemini/gemini-cli/pull/15494
|
||||
- do not persist the fallback model by @sehoon38 in
|
||||
https://github.com/google-gemini/gemini-cli/pull/15483
|
||||
- Resolve unhandled promise rejection in ide-client.ts by @Adib234 in
|
||||
https://github.com/google-gemini/gemini-cli/pull/15587
|
||||
- fix(core): handle checkIsRepo failure in GitService.initialize by
|
||||
@Mapleeeeeeeeeee in https://github.com/google-gemini/gemini-cli/pull/15574
|
||||
- fix(cli): add enableShellOutputEfficiency to settings schema by
|
||||
@Mapleeeeeeeeeee in https://github.com/google-gemini/gemini-cli/pull/15560
|
||||
- Manual nightly version bump to 0.24.0-nightly.20251226.546baf993 by @galz10 in
|
||||
https://github.com/google-gemini/gemini-cli/pull/15594
|
||||
- refactor(core): extract static concerns from CoreToolScheduler by @abhipatel12
|
||||
in https://github.com/google-gemini/gemini-cli/pull/15589
|
||||
- fix(core): enable granular shell command allowlisting in policy engine by
|
||||
@abhipatel12 in https://github.com/google-gemini/gemini-cli/pull/15601
|
||||
- chore/release: bump version to 0.24.0-nightly.20251227.37be16243 by
|
||||
@gemini-cli-robot in https://github.com/google-gemini/gemini-cli/pull/15612
|
||||
- refactor: deprecate legacy confirmation settings and enforce Policy Engine by
|
||||
@abhipatel12 in https://github.com/google-gemini/gemini-cli/pull/15626
|
||||
- Migrate console to coreEvents.emitFeedback or debugLogger by @Adib234 in
|
||||
https://github.com/google-gemini/gemini-cli/pull/15219
|
||||
- Exponential back-off retries for retryable error without a specified … by
|
||||
@sehoon38 in https://github.com/google-gemini/gemini-cli/pull/15684
|
||||
- feat(agents): add support for remote agents and multi-agent TOML files by
|
||||
@adamfweidman in https://github.com/google-gemini/gemini-cli/pull/15437
|
||||
- Update wittyPhrases.ts by @segyges in
|
||||
https://github.com/google-gemini/gemini-cli/pull/15697
|
||||
- refactor(auth): Refactor non-interactive mode auth validation & refresh by
|
||||
@skeshive in https://github.com/google-gemini/gemini-cli/pull/15679
|
||||
- Revert "Update wittyPhrases.ts (#15697)" by @abhipatel12 in
|
||||
https://github.com/google-gemini/gemini-cli/pull/15719
|
||||
- fix(hooks): deduplicate agent hooks and add cross-platform integration tests
|
||||
by @abhipatel12 in https://github.com/google-gemini/gemini-cli/pull/15701
|
||||
- Implement support for tool input modification by @gundermanc in
|
||||
https://github.com/google-gemini/gemini-cli/pull/15492
|
||||
- Add instructions to the extensions update info notification by @chrstnb in
|
||||
https://github.com/google-gemini/gemini-cli/pull/14907
|
||||
- Add extension settings info to /extensions list by @chrstnb in
|
||||
https://github.com/google-gemini/gemini-cli/pull/14905
|
||||
- Agent Skills: Implement Core Skill Infrastructure & Tiered Discovery by
|
||||
@NTaylorMullen in https://github.com/google-gemini/gemini-cli/pull/15698
|
||||
- chore: remove cot style comments by @abhipatel12 in
|
||||
https://github.com/google-gemini/gemini-cli/pull/15735
|
||||
- feat(agents): Add remote agents to agent registry by @sehoon38 in
|
||||
https://github.com/google-gemini/gemini-cli/pull/15711
|
||||
- feat(hooks): implement STOP_EXECUTION and enhance hook decision handling by
|
||||
@SandyTao520 in https://github.com/google-gemini/gemini-cli/pull/15685
|
||||
- Fix build issues caused by year-specific linter rule by @gundermanc in
|
||||
https://github.com/google-gemini/gemini-cli/pull/15780
|
||||
- fix(core): handle unhandled promise rejection in mcp-client-manager by
|
||||
@kamja44 in https://github.com/google-gemini/gemini-cli/pull/14701
|
||||
- log fallback mode by @sehoon38 in
|
||||
https://github.com/google-gemini/gemini-cli/pull/15817
|
||||
- Agent Skills: Implement Autonomous Activation Tool & Context Injection by
|
||||
@NTaylorMullen in https://github.com/google-gemini/gemini-cli/pull/15725
|
||||
- fix(core): improve shell command with redirection detection by @galz10 in
|
||||
https://github.com/google-gemini/gemini-cli/pull/15683
|
||||
- Add security docs by @abhipatel12 in
|
||||
https://github.com/google-gemini/gemini-cli/pull/15739
|
||||
- feat: add folder suggestions to `/dir add` command by @jackwotherspoon in
|
||||
https://github.com/google-gemini/gemini-cli/pull/15724
|
||||
- Agent Skills: Implement Agent Integration and System Prompt Awareness by
|
||||
@NTaylorMullen in https://github.com/google-gemini/gemini-cli/pull/15728
|
||||
- chore: cleanup old smart edit settings by @abhipatel12 in
|
||||
https://github.com/google-gemini/gemini-cli/pull/15832
|
||||
- Agent Skills: Status Bar Integration for Skill Counts by @NTaylorMullen in
|
||||
https://github.com/google-gemini/gemini-cli/pull/15741
|
||||
- fix(core): mock powershell output in shell-utils test by @galz10 in
|
||||
https://github.com/google-gemini/gemini-cli/pull/15831
|
||||
- Agent Skills: Unify Representation & Centralize Loading by @NTaylorMullen in
|
||||
https://github.com/google-gemini/gemini-cli/pull/15833
|
||||
- Unify shell security policy and remove legacy logic by @abhipatel12 in
|
||||
https://github.com/google-gemini/gemini-cli/pull/15770
|
||||
- feat(core): restore MessageBus optionality for soft migration (Phase 1) by
|
||||
@abhipatel12 in https://github.com/google-gemini/gemini-cli/pull/15774
|
||||
- feat(core): Standardize Tool and Agent Invocation constructors (Phase 2) by
|
||||
@abhipatel12 in https://github.com/google-gemini/gemini-cli/pull/15775
|
||||
- feat(core,cli): enforce mandatory MessageBus injection (Phase 3 Hard
|
||||
Migration) by @abhipatel12 in
|
||||
https://github.com/google-gemini/gemini-cli/pull/15776
|
||||
- Agent Skills: Extension Support & Security Disclosure by @NTaylorMullen in
|
||||
https://github.com/google-gemini/gemini-cli/pull/15834
|
||||
- feat(hooks): implement granular stop and block behavior for agent hooks by
|
||||
@SandyTao520 in https://github.com/google-gemini/gemini-cli/pull/15824
|
||||
- Agent Skills: Add gemini skills CLI management command by @NTaylorMullen in
|
||||
https://github.com/google-gemini/gemini-cli/pull/15837
|
||||
- refactor: consolidate EditTool and SmartEditTool by @abhipatel12 in
|
||||
https://github.com/google-gemini/gemini-cli/pull/15857
|
||||
- fix(cli): mock fs.readdir in consent tests for Windows compatibility by
|
||||
@NTaylorMullen in https://github.com/google-gemini/gemini-cli/pull/15904
|
||||
- refactor(core): Extract and integrate ToolExecutor by @abhipatel12 in
|
||||
https://github.com/google-gemini/gemini-cli/pull/15900
|
||||
- Fix terminal hang when user exits browser without logging in by @gundermanc in
|
||||
https://github.com/google-gemini/gemini-cli/pull/15748
|
||||
- fix: avoid SDK warning by not accessing .text getter in logging by @ved015 in
|
||||
https://github.com/google-gemini/gemini-cli/pull/15706
|
||||
- Make default settings apply by @devr0306 in
|
||||
https://github.com/google-gemini/gemini-cli/pull/15354
|
||||
- chore: rename smart-edit to edit by @abhipatel12 in
|
||||
https://github.com/google-gemini/gemini-cli/pull/15923
|
||||
- Opt-in to persist model from /model by @sehoon38 in
|
||||
https://github.com/google-gemini/gemini-cli/pull/15820
|
||||
- fix: prevent /copy crash on Windows by skipping /dev/tty by @ManojINaik in
|
||||
https://github.com/google-gemini/gemini-cli/pull/15657
|
||||
- Support context injection via SessionStart hook. by @gundermanc in
|
||||
https://github.com/google-gemini/gemini-cli/pull/15746
|
||||
- Fix order of preflight by @scidomino in
|
||||
https://github.com/google-gemini/gemini-cli/pull/15941
|
||||
- Fix failing unit tests by @gundermanc in
|
||||
https://github.com/google-gemini/gemini-cli/pull/15940
|
||||
- fix(cli): resolve paste issue on Windows terminals. by @scidomino in
|
||||
https://github.com/google-gemini/gemini-cli/pull/15932
|
||||
- Agent Skills: Implement /skills reload by @NTaylorMullen in
|
||||
https://github.com/google-gemini/gemini-cli/pull/15865
|
||||
- Add setting to support OSC 52 paste by @scidomino in
|
||||
https://github.com/google-gemini/gemini-cli/pull/15336
|
||||
- remove manual string when displaying manual model in the footer by @sehoon38
|
||||
in https://github.com/google-gemini/gemini-cli/pull/15967
|
||||
- fix(core): use correct interactive check for system prompt by @ppergame in
|
||||
https://github.com/google-gemini/gemini-cli/pull/15020
|
||||
- Inform user of missing settings on extensions update by @chrstnb in
|
||||
https://github.com/google-gemini/gemini-cli/pull/15944
|
||||
- feat(policy): allow 'modes' in user and admin policies by @NTaylorMullen in
|
||||
https://github.com/google-gemini/gemini-cli/pull/15977
|
||||
- fix: default folder trust to untrusted for enhanced security by @galz10 in
|
||||
https://github.com/google-gemini/gemini-cli/pull/15943
|
||||
- Add description for each settings item in /settings by @sehoon38 in
|
||||
https://github.com/google-gemini/gemini-cli/pull/15936
|
||||
- Use GetOperation to poll for OnboardUser completion by @ishaanxgupta in
|
||||
https://github.com/google-gemini/gemini-cli/pull/15827
|
||||
- Agent Skills: Add skill directory to WorkspaceContext upon activation by
|
||||
@NTaylorMullen in https://github.com/google-gemini/gemini-cli/pull/15870
|
||||
- Fix settings command fallback by @chrstnb in
|
||||
https://github.com/google-gemini/gemini-cli/pull/15926
|
||||
- fix: writeTodo construction by @scidomino in
|
||||
https://github.com/google-gemini/gemini-cli/pull/16014
|
||||
- properly disable keyboard modes on exit by @scidomino in
|
||||
https://github.com/google-gemini/gemini-cli/pull/16006
|
||||
- Add workflow to label child issues for rollup by @bdmorgan in
|
||||
https://github.com/google-gemini/gemini-cli/pull/16002
|
||||
- feat(ui): add visual indicators for hook execution by @abhipatel12 in
|
||||
https://github.com/google-gemini/gemini-cli/pull/15408
|
||||
- fix: image token estimation by @jackwotherspoon in
|
||||
https://github.com/google-gemini/gemini-cli/pull/16004
|
||||
- feat(hooks): Add a hooks.enabled setting. by @joshualitt in
|
||||
https://github.com/google-gemini/gemini-cli/pull/15933
|
||||
- feat(admin): Introduce remote admin settings & implement
|
||||
secureModeEnabled/mcpEnabled by @skeshive in
|
||||
https://github.com/google-gemini/gemini-cli/pull/15935
|
||||
- Remove trailing whitespace in yaml. by @joshualitt in
|
||||
https://github.com/google-gemini/gemini-cli/pull/16036
|
||||
- feat(agents): add support for remote agents by @adamfweidman in
|
||||
https://github.com/google-gemini/gemini-cli/pull/16013
|
||||
- fix: limit scheduled issue triage queries to prevent argument list too long
|
||||
error by @jerop in https://github.com/google-gemini/gemini-cli/pull/16021
|
||||
- ci(github-actions): triage all new issues automatically by @jerop in
|
||||
https://github.com/google-gemini/gemini-cli/pull/16018
|
||||
- Fix test. by @gundermanc in
|
||||
https://github.com/google-gemini/gemini-cli/pull/16011
|
||||
- fix: hide broken skills object from settings dialog by @korade-krushna in
|
||||
https://github.com/google-gemini/gemini-cli/pull/15766
|
||||
- Agent Skills: Initial Documentation & Tutorial by @NTaylorMullen in
|
||||
https://github.com/google-gemini/gemini-cli/pull/15869
|
||||
- **Skills and Agents:** Improvements to the `activate_skill` tool and skill
|
||||
management. Experimental Agent Skills support.
|
||||
- **UI/UX:** Addition of a Rewind Confirmation dialog and Viewer component.
|
||||
- **Extensions:** Experimental setting for extension configuration.
|
||||
- **Bug Fixes and Stability:** PDF token estimation fix and improvements to
|
||||
scheduled issue triage.
|
||||
|
||||
## What's Changed
|
||||
|
||||
- fix: PDF token estimation
|
||||
([#16494](https://github.com/google-gemini/gemini-cli/pull/16494)) by
|
||||
@korade-krushna in
|
||||
[#16527](https://github.com/google-gemini/gemini-cli/pull/16527)
|
||||
- chore(release): bump version to 0.26.0-nightly.20260114.bb6c57414 by
|
||||
@gemini-cli-robot in
|
||||
[#16604](https://github.com/google-gemini/gemini-cli/pull/16604)
|
||||
- docs: clarify F12 to open debug console by @jackwotherspoon in
|
||||
[#16570](https://github.com/google-gemini/gemini-cli/pull/16570)
|
||||
- docs: Remove .md extension from internal links in architecture.md by
|
||||
@medic-code in
|
||||
[#12899](https://github.com/google-gemini/gemini-cli/pull/12899)
|
||||
- Add an experimental setting for extension config by @chrstnb in
|
||||
[#16506](https://github.com/google-gemini/gemini-cli/pull/16506)
|
||||
- feat: add Rewind Confirmation dialog and Rewind Viewer component by @Adib234
|
||||
in [#15717](https://github.com/google-gemini/gemini-cli/pull/15717)
|
||||
- fix(a2a): Don't throw errors for GeminiEventType Retry and InvalidStream. by
|
||||
@ehedlund in [#16541](https://github.com/google-gemini/gemini-cli/pull/16541)
|
||||
- prefactor: add rootCommands as array so it can be used for policy parsing by
|
||||
@abhipatel12 in
|
||||
[#16640](https://github.com/google-gemini/gemini-cli/pull/16640)
|
||||
- remove unnecessary \x7f key bindings by @scidomino in
|
||||
[#16646](https://github.com/google-gemini/gemini-cli/pull/16646)
|
||||
- docs(skills): use body-file in pr-creator skill for better reliability by
|
||||
@abhipatel12 in
|
||||
[#16642](https://github.com/google-gemini/gemini-cli/pull/16642)
|
||||
- chore(automation): recursive labeling for workstream descendants by @bdmorgan
|
||||
in [#16609](https://github.com/google-gemini/gemini-cli/pull/16609)
|
||||
- feat: introduce 'skill-creator' built-in skill and CJS management tools by
|
||||
@NTaylorMullen in
|
||||
[#16394](https://github.com/google-gemini/gemini-cli/pull/16394)
|
||||
- chore(automation): remove automated PR size and complexity labeler by
|
||||
@bdmorgan in [#16648](https://github.com/google-gemini/gemini-cli/pull/16648)
|
||||
- refactor(skills): replace 'project' with 'workspace' scope by @NTaylorMullen
|
||||
in [#16380](https://github.com/google-gemini/gemini-cli/pull/16380)
|
||||
- Docs: Update release notes for 1/13/2026 by @jkcinouye in
|
||||
[#16583](https://github.com/google-gemini/gemini-cli/pull/16583)
|
||||
- Simplify paste handling by @scidomino in
|
||||
[#16654](https://github.com/google-gemini/gemini-cli/pull/16654)
|
||||
- chore(automation): improve scheduled issue triage discovery and throughput by
|
||||
@bdmorgan in [#16652](https://github.com/google-gemini/gemini-cli/pull/16652)
|
||||
- fix(acp): run exit cleanup when stdin closes by @codefromthecrypt in
|
||||
[#14953](https://github.com/google-gemini/gemini-cli/pull/14953)
|
||||
- feat(scheduler): add types needed for event driven scheduler by @abhipatel12
|
||||
in [#16641](https://github.com/google-gemini/gemini-cli/pull/16641)
|
||||
- Remove unused rewind key binding by @scidomino in
|
||||
[#16659](https://github.com/google-gemini/gemini-cli/pull/16659)
|
||||
- Remove sequence binding by @scidomino in
|
||||
[#16664](https://github.com/google-gemini/gemini-cli/pull/16664)
|
||||
- feat(cli): undeprecate the --prompt flag by @alexaustin007 in
|
||||
[#13981](https://github.com/google-gemini/gemini-cli/pull/13981)
|
||||
- chore: update dependabot configuration by @cosmopax in
|
||||
[#13507](https://github.com/google-gemini/gemini-cli/pull/13507)
|
||||
- feat(config): add 'auto' alias for default model selection by @sehoon38 in
|
||||
[#16661](https://github.com/google-gemini/gemini-cli/pull/16661)
|
||||
- Enable & disable agents by @sehoon38 in
|
||||
[#16225](https://github.com/google-gemini/gemini-cli/pull/16225)
|
||||
- cleanup: Improve keybindings by @scidomino in
|
||||
[#16672](https://github.com/google-gemini/gemini-cli/pull/16672)
|
||||
- Add timeout for shell-utils to prevent hangs. by @jacob314 in
|
||||
[#16667](https://github.com/google-gemini/gemini-cli/pull/16667)
|
||||
- feat(plan): add experimental plan flag by @jerop in
|
||||
[#16650](https://github.com/google-gemini/gemini-cli/pull/16650)
|
||||
- feat(cli): add security consent prompts for skill installation by
|
||||
@NTaylorMullen in
|
||||
[#16549](https://github.com/google-gemini/gemini-cli/pull/16549)
|
||||
- fix: replace 3 consecutive periods with ellipsis character by @Vist233 in
|
||||
[#16587](https://github.com/google-gemini/gemini-cli/pull/16587)
|
||||
- chore(automation): ensure status/need-triage is applied and never cleared
|
||||
automatically by @bdmorgan in
|
||||
[#16657](https://github.com/google-gemini/gemini-cli/pull/16657)
|
||||
- fix: Handle colons in skill description frontmatter by @maru0804 in
|
||||
[#16345](https://github.com/google-gemini/gemini-cli/pull/16345)
|
||||
- refactor(core): harden skill frontmatter parsing by @NTaylorMullen in
|
||||
[#16705](https://github.com/google-gemini/gemini-cli/pull/16705)
|
||||
- feat(skills): add conflict detection and warnings for skill overrides by
|
||||
@NTaylorMullen in
|
||||
[#16709](https://github.com/google-gemini/gemini-cli/pull/16709)
|
||||
- feat(scheduler): add SchedulerStateManager for reactive tool state by
|
||||
@abhipatel12 in
|
||||
[#16651](https://github.com/google-gemini/gemini-cli/pull/16651)
|
||||
- chore(automation): enforce 'help wanted' label permissions and update
|
||||
guidelines by @bdmorgan in
|
||||
[#16707](https://github.com/google-gemini/gemini-cli/pull/16707)
|
||||
- fix(core): resolve circular dependency via tsconfig paths by @sehoon38 in
|
||||
[#16730](https://github.com/google-gemini/gemini-cli/pull/16730)
|
||||
- chore/release: bump version to 0.26.0-nightly.20260115.6cb3ae4e0 by
|
||||
@gemini-cli-robot in
|
||||
[#16738](https://github.com/google-gemini/gemini-cli/pull/16738)
|
||||
- fix(automation): correct status/need-issue label matching wildcard by
|
||||
@bdmorgan in [#16727](https://github.com/google-gemini/gemini-cli/pull/16727)
|
||||
- fix(automation): prevent label-enforcer loop by ignoring all bots by @bdmorgan
|
||||
in [#16746](https://github.com/google-gemini/gemini-cli/pull/16746)
|
||||
- Add links to supported locations and minor fixes by @g-samroberts in
|
||||
[#16476](https://github.com/google-gemini/gemini-cli/pull/16476)
|
||||
- feat(policy): add source tracking to policy rules by @allenhutchison in
|
||||
[#16670](https://github.com/google-gemini/gemini-cli/pull/16670)
|
||||
- feat(automation): enforce '🔒 maintainer only' and fix bot loop by @bdmorgan
|
||||
in [#16751](https://github.com/google-gemini/gemini-cli/pull/16751)
|
||||
- Make merged settings non-nullable and fix all lints related to that. by
|
||||
@jacob314 in [#16647](https://github.com/google-gemini/gemini-cli/pull/16647)
|
||||
- fix(core): prevent ModelInfo event emission on aborted signal by @sehoon38 in
|
||||
[#16752](https://github.com/google-gemini/gemini-cli/pull/16752)
|
||||
- Replace relative paths to fix website build by @chrstnb in
|
||||
[#16755](https://github.com/google-gemini/gemini-cli/pull/16755)
|
||||
- Restricting to localhost by @cocosheng-g in
|
||||
[#16548](https://github.com/google-gemini/gemini-cli/pull/16548)
|
||||
- fix(cli): add explicit dependency on color-convert by @sehoon38 in
|
||||
[#16757](https://github.com/google-gemini/gemini-cli/pull/16757)
|
||||
- fix(automation): robust label enforcement with permission checks by @bdmorgan
|
||||
in [#16762](https://github.com/google-gemini/gemini-cli/pull/16762)
|
||||
- fix(cli): prevent OOM crash by limiting file search traversal and adding
|
||||
timeout by @galz10 in
|
||||
[#16696](https://github.com/google-gemini/gemini-cli/pull/16696)
|
||||
- fix(cli): safely handle /dev/tty access on macOS by @korade-krushna in
|
||||
[#16531](https://github.com/google-gemini/gemini-cli/pull/16531)
|
||||
- docs: clarify workspace test execution in GEMINI.md by @mattKorwel in
|
||||
[#16764](https://github.com/google-gemini/gemini-cli/pull/16764)
|
||||
- Add support for running available commands prior to MCP servers loading by
|
||||
@Adib234 in [#15596](https://github.com/google-gemini/gemini-cli/pull/15596)
|
||||
- feat(plan): add experimental 'plan' approval mode by @jerop in
|
||||
[#16753](https://github.com/google-gemini/gemini-cli/pull/16753)
|
||||
- feat(scheduler): add functional awaitConfirmation utility by @abhipatel12 in
|
||||
[#16721](https://github.com/google-gemini/gemini-cli/pull/16721)
|
||||
- fix(infra): update maintainer rollup label to 'workstream-rollup' by @bdmorgan
|
||||
in [#16809](https://github.com/google-gemini/gemini-cli/pull/16809)
|
||||
- fix(infra): use GraphQL to detect direct parents in rollup workflow by
|
||||
@bdmorgan in [#16811](https://github.com/google-gemini/gemini-cli/pull/16811)
|
||||
- chore(workflows): rename label-workstream-rollup workflow by @bdmorgan in
|
||||
[#16818](https://github.com/google-gemini/gemini-cli/pull/16818)
|
||||
- skip simple-mcp-server.test.ts by @scidomino in
|
||||
[#16842](https://github.com/google-gemini/gemini-cli/pull/16842)
|
||||
- Steer outer agent to use expert subagents when present by @gundermanc in
|
||||
[#16763](https://github.com/google-gemini/gemini-cli/pull/16763)
|
||||
- Fix race condition by awaiting scheduleToolCalls by @chrstnb in
|
||||
[#16759](https://github.com/google-gemini/gemini-cli/pull/16759)
|
||||
- cleanup: Organize key bindings by @scidomino in
|
||||
[#16798](https://github.com/google-gemini/gemini-cli/pull/16798)
|
||||
- feat(core): Add generalist agent. by @joshualitt in
|
||||
[#16638](https://github.com/google-gemini/gemini-cli/pull/16638)
|
||||
- perf(ui): optimize text buffer and highlighting for large inputs by
|
||||
@NTaylorMullen in
|
||||
[#16782](https://github.com/google-gemini/gemini-cli/pull/16782)
|
||||
- fix(core): fix PTY descriptor shell leak by @galz10 in
|
||||
[#16773](https://github.com/google-gemini/gemini-cli/pull/16773)
|
||||
- feat(plan): enforce strict read-only policy and halt execution on violation by
|
||||
@jerop in [#16849](https://github.com/google-gemini/gemini-cli/pull/16849)
|
||||
- remove need-triage label from bug_report template by @sehoon38 in
|
||||
[#16864](https://github.com/google-gemini/gemini-cli/pull/16864)
|
||||
- fix(core): truncate large telemetry log entries by @sehoon38 in
|
||||
[#16769](https://github.com/google-gemini/gemini-cli/pull/16769)
|
||||
- docs(extensions): add Agent Skills support and mark feature as experimental by
|
||||
@NTaylorMullen in
|
||||
[#16859](https://github.com/google-gemini/gemini-cli/pull/16859)
|
||||
- fix(core): surface warnings for invalid hook event names in configuration
|
||||
([#16788](https://github.com/google-gemini/gemini-cli/pull/16788)) by
|
||||
@sehoon38 in [#16873](https://github.com/google-gemini/gemini-cli/pull/16873)
|
||||
- feat(plan): remove read_many_files from approval mode policies by @jerop in
|
||||
[#16876](https://github.com/google-gemini/gemini-cli/pull/16876)
|
||||
- feat(admin): implement admin controls polling and restart prompt by @skeshive
|
||||
in [#16627](https://github.com/google-gemini/gemini-cli/pull/16627)
|
||||
- Remove LRUCache class migrating to mnemoist by @jacob314 in
|
||||
[#16872](https://github.com/google-gemini/gemini-cli/pull/16872)
|
||||
- feat(settings): rename negative settings to positive naming (disable* ->
|
||||
enable*) by @afarber in
|
||||
[#14142](https://github.com/google-gemini/gemini-cli/pull/14142)
|
||||
- refactor(cli): unify shell confirmation dialogs by @NTaylorMullen in
|
||||
[#16828](https://github.com/google-gemini/gemini-cli/pull/16828)
|
||||
- feat(agent): enable agent skills by default by @NTaylorMullen in
|
||||
[#16736](https://github.com/google-gemini/gemini-cli/pull/16736)
|
||||
- refactor(core): foundational truncation refactoring and token estimation
|
||||
optimization by @NTaylorMullen in
|
||||
[#16824](https://github.com/google-gemini/gemini-cli/pull/16824)
|
||||
- fix(hooks): enable /hooks disable to reliably stop single hooks by
|
||||
@abhipatel12 in
|
||||
[#16804](https://github.com/google-gemini/gemini-cli/pull/16804)
|
||||
- Don't commit unless user asks us to. by @gundermanc in
|
||||
[#16902](https://github.com/google-gemini/gemini-cli/pull/16902)
|
||||
- chore: remove a2a-adapter and bump @a2a-js/sdk to 0.3.8 by @adamfweidman in
|
||||
[#16800](https://github.com/google-gemini/gemini-cli/pull/16800)
|
||||
- fix: Show experiment values in settings UI for compressionThreshold by
|
||||
@ishaanxgupta in
|
||||
[#16267](https://github.com/google-gemini/gemini-cli/pull/16267)
|
||||
- feat(cli): replace relative keyboard shortcuts link with web URL by
|
||||
@imaliabbas in
|
||||
[#16479](https://github.com/google-gemini/gemini-cli/pull/16479)
|
||||
- fix(core): resolve PKCE length issue and stabilize OAuth redirect port by
|
||||
@sehoon38 in [#16815](https://github.com/google-gemini/gemini-cli/pull/16815)
|
||||
- Delete rewind documentation for now by @Adib234 in
|
||||
[#16932](https://github.com/google-gemini/gemini-cli/pull/16932)
|
||||
- Stabilize skill-creator CI and package format by @NTaylorMullen in
|
||||
[#17001](https://github.com/google-gemini/gemini-cli/pull/17001)
|
||||
- Stabilize the git evals by @gundermanc in
|
||||
[#16989](https://github.com/google-gemini/gemini-cli/pull/16989)
|
||||
- fix(core): attempt compression before context overflow check by @NTaylorMullen
|
||||
in [#16914](https://github.com/google-gemini/gemini-cli/pull/16914)
|
||||
- Fix inverted logic. by @gundermanc in
|
||||
[#17007](https://github.com/google-gemini/gemini-cli/pull/17007)
|
||||
- chore(scripts): add duplicate issue closer script and fix lint errors by
|
||||
@bdmorgan in [#16997](https://github.com/google-gemini/gemini-cli/pull/16997)
|
||||
- docs: update README and config guide to reference Gemini 3 by @JayadityaGit in
|
||||
[#15806](https://github.com/google-gemini/gemini-cli/pull/15806)
|
||||
- fix(cli): correct Homebrew installation detection by @kij in
|
||||
[#14727](https://github.com/google-gemini/gemini-cli/pull/14727)
|
||||
- Demote git evals to nightly run. by @gundermanc in
|
||||
[#17030](https://github.com/google-gemini/gemini-cli/pull/17030)
|
||||
- fix(cli): use OSC-52 clipboard copy in Windows Terminal by @Thomas-Shephard in
|
||||
[#16920](https://github.com/google-gemini/gemini-cli/pull/16920)
|
||||
- Fix: Process all parts in response chunks when thought is first by @pyrytakala
|
||||
in [#13539](https://github.com/google-gemini/gemini-cli/pull/13539)
|
||||
- fix(automation): fix jq quoting error in pr-triage.sh by @Kimsoo0119 in
|
||||
[#16958](https://github.com/google-gemini/gemini-cli/pull/16958)
|
||||
- refactor(core): decouple scheduler into orchestration, policy, and
|
||||
confirmation by @abhipatel12 in
|
||||
[#16895](https://github.com/google-gemini/gemini-cli/pull/16895)
|
||||
- feat: add /introspect slash command by @NTaylorMullen in
|
||||
[#17048](https://github.com/google-gemini/gemini-cli/pull/17048)
|
||||
- refactor(cli): centralize tool mapping and decouple legacy scheduler by
|
||||
@abhipatel12 in
|
||||
[#17044](https://github.com/google-gemini/gemini-cli/pull/17044)
|
||||
- fix(ui): ensure rationale renders before tool calls by @NTaylorMullen in
|
||||
[#17043](https://github.com/google-gemini/gemini-cli/pull/17043)
|
||||
- fix(workflows): use author_association for maintainer check by @bdmorgan in
|
||||
[#17060](https://github.com/google-gemini/gemini-cli/pull/17060)
|
||||
- fix return type of fireSessionStartEvent to defaultHookOutput by @ved015 in
|
||||
[#16833](https://github.com/google-gemini/gemini-cli/pull/16833)
|
||||
- feat(cli): add experiment gate for event-driven scheduler by @abhipatel12 in
|
||||
[#17055](https://github.com/google-gemini/gemini-cli/pull/17055)
|
||||
- feat(core): improve shell redirection transparency and security by
|
||||
@NTaylorMullen in
|
||||
[#16486](https://github.com/google-gemini/gemini-cli/pull/16486)
|
||||
- fix(core): deduplicate ModelInfo emission in GeminiClient by @NTaylorMullen in
|
||||
[#17075](https://github.com/google-gemini/gemini-cli/pull/17075)
|
||||
- docs(themes): remove unsupported DiffModified color key by @jw409 in
|
||||
[#17073](https://github.com/google-gemini/gemini-cli/pull/17073)
|
||||
- fix: update currentSequenceModel when modelChanged by @adamfweidman in
|
||||
[#17051](https://github.com/google-gemini/gemini-cli/pull/17051)
|
||||
- feat(core): enhanced anchored iterative context compression with
|
||||
self-verification by @rmedranollamas in
|
||||
[#15710](https://github.com/google-gemini/gemini-cli/pull/15710)
|
||||
- Fix mcp instructions by @chrstnb in
|
||||
[#16439](https://github.com/google-gemini/gemini-cli/pull/16439)
|
||||
- [A2A] Disable checkpointing if git is not installed by @cocosheng-g in
|
||||
[#16896](https://github.com/google-gemini/gemini-cli/pull/16896)
|
||||
- feat(admin): set admin.skills.enabled based on advancedFeaturesEnabled setting
|
||||
by @skeshive in
|
||||
[#17095](https://github.com/google-gemini/gemini-cli/pull/17095)
|
||||
- Test coverage for hook exit code cases by @gundermanc in
|
||||
[#17041](https://github.com/google-gemini/gemini-cli/pull/17041)
|
||||
- Revert "Revert "Update extension examples"" by @chrstnb in
|
||||
[#16445](https://github.com/google-gemini/gemini-cli/pull/16445)
|
||||
- fix(core): Provide compact, actionable errors for agent delegation failures by
|
||||
@SandyTao520 in
|
||||
[#16493](https://github.com/google-gemini/gemini-cli/pull/16493)
|
||||
- fix: migrate BeforeModel and AfterModel hooks to HookSystem by @ved015 in
|
||||
[#16599](https://github.com/google-gemini/gemini-cli/pull/16599)
|
||||
- feat(admin): apply admin settings to gemini skills/mcp/extensions commands by
|
||||
@skeshive in [#17102](https://github.com/google-gemini/gemini-cli/pull/17102)
|
||||
- fix(core): update telemetry token count after session resume by @psinha40898
|
||||
in [#15491](https://github.com/google-gemini/gemini-cli/pull/15491)
|
||||
- Demote the subagent test to nightly by @gundermanc in
|
||||
[#17105](https://github.com/google-gemini/gemini-cli/pull/17105)
|
||||
- feat(plan): telemetry to track adoption and usage of plan mode by @Adib234 in
|
||||
[#16863](https://github.com/google-gemini/gemini-cli/pull/16863)
|
||||
- feat: Add flash lite utility fallback chain by @adamfweidman in
|
||||
[#17056](https://github.com/google-gemini/gemini-cli/pull/17056)
|
||||
- Fixes Windows crash: "Cannot resize a pty that has already exited" by @dzammit
|
||||
in [#15757](https://github.com/google-gemini/gemini-cli/pull/15757)
|
||||
- feat(core): Add initial eval for generalist agent. by @joshualitt in
|
||||
[#16856](https://github.com/google-gemini/gemini-cli/pull/16856)
|
||||
- feat(core): unify agent enabled and disabled flags by @SandyTao520 in
|
||||
[#17127](https://github.com/google-gemini/gemini-cli/pull/17127)
|
||||
- fix(core): resolve auto model in default strategy by @sehoon38 in
|
||||
[#17116](https://github.com/google-gemini/gemini-cli/pull/17116)
|
||||
- docs: update project context and pr-creator workflow by @NTaylorMullen in
|
||||
[#17119](https://github.com/google-gemini/gemini-cli/pull/17119)
|
||||
- fix(cli): send gemini-cli version as mcp client version by @dsp in
|
||||
[#13407](https://github.com/google-gemini/gemini-cli/pull/13407)
|
||||
- fix(cli): resolve Ctrl+Enter and Ctrl+J newline issues by @imadraude in
|
||||
[#17021](https://github.com/google-gemini/gemini-cli/pull/17021)
|
||||
- Remove missing sidebar item by @chrstnb in
|
||||
[#17145](https://github.com/google-gemini/gemini-cli/pull/17145)
|
||||
- feat(core): Ensure all properties in hooks object are event names. by
|
||||
@joshualitt in
|
||||
[#16870](https://github.com/google-gemini/gemini-cli/pull/16870)
|
||||
- fix(cli): fix newline support broken in previous PR by @scidomino in
|
||||
[#17159](https://github.com/google-gemini/gemini-cli/pull/17159)
|
||||
- Add interactive ValidationDialog for handling 403 VALIDATION_REQUIRED errors.
|
||||
by @gsquared94 in
|
||||
[#16231](https://github.com/google-gemini/gemini-cli/pull/16231)
|
||||
- Add Esc-Esc to clear prompt when it's not empty by @Adib234 in
|
||||
[#17131](https://github.com/google-gemini/gemini-cli/pull/17131)
|
||||
- Avoid spurious warnings about unexpected renders triggered by appEvents and
|
||||
coreEvents. by @jacob314 in
|
||||
[#17160](https://github.com/google-gemini/gemini-cli/pull/17160)
|
||||
- fix(cli): resolve home/end keybinding conflict by @scidomino in
|
||||
[#17124](https://github.com/google-gemini/gemini-cli/pull/17124)
|
||||
- fix(cli): display 'http' type on mcp list by @pamanta in
|
||||
[#16915](https://github.com/google-gemini/gemini-cli/pull/16915)
|
||||
- fix bad fallback logic external editor logic by @scidomino in
|
||||
[#17166](https://github.com/google-gemini/gemini-cli/pull/17166)
|
||||
- Fix bug where System scopes weren't migrated. by @jacob314 in
|
||||
[#17174](https://github.com/google-gemini/gemini-cli/pull/17174)
|
||||
- Fix mcp tool lookup in tool registry by @werdnum in
|
||||
[#17054](https://github.com/google-gemini/gemini-cli/pull/17054)
|
||||
|
||||
**Full changelog**:
|
||||
https://github.com/google-gemini/gemini-cli/compare/v0.23.0-preview.6...v0.24.0-preview.0
|
||||
https://github.com/google-gemini/gemini-cli/compare/v0.25.0-preview.4...v0.26.0-preview.0
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -168,12 +168,6 @@ Slash commands provide meta-level control over the CLI itself.
|
||||
[settings](../get-started/configuration.md). See
|
||||
[Checkpointing documentation](../cli/checkpointing.md) for more details.
|
||||
|
||||
- [**`/rewind`**](./rewind.md)
|
||||
- **Description:** Browse and rewind previous interactions. Allows you to
|
||||
rewind the conversation, revert file changes, or both. Provides an
|
||||
interactive interface to select the exact point to rewind to.
|
||||
- **Keyboard shortcut:** Press **Esc** twice.
|
||||
|
||||
- **`/resume`**
|
||||
- **Description:** Browse and resume previous conversation sessions. Opens an
|
||||
interactive session browser where you can search, filter, and select from
|
||||
|
||||
@@ -19,14 +19,14 @@ available combinations.
|
||||
|
||||
| Action | Keys |
|
||||
| ------------------------------------------- | ------------------------------------------------------------ |
|
||||
| Move the cursor to the start of the line. | `Ctrl + A`<br />`Home` |
|
||||
| Move the cursor to the end of the line. | `Ctrl + E`<br />`End` |
|
||||
| Move the cursor up one line. | `Up Arrow (no Ctrl, no Cmd)` |
|
||||
| Move the cursor down one line. | `Down Arrow (no Ctrl, no Cmd)` |
|
||||
| Move the cursor one character to the left. | `Left Arrow (no Ctrl, no Cmd)`<br />`Ctrl + B` |
|
||||
| Move the cursor one character to the right. | `Right Arrow (no Ctrl, no Cmd)`<br />`Ctrl + F` |
|
||||
| Move the cursor one word to the left. | `Ctrl + Left Arrow`<br />`Cmd + Left Arrow`<br />`Cmd + B` |
|
||||
| Move the cursor one word to the right. | `Ctrl + Right Arrow`<br />`Cmd + Right Arrow`<br />`Cmd + F` |
|
||||
| Move the cursor to the start of the line. | `Ctrl + A`<br />`Home (no Shift, Ctrl)` |
|
||||
| Move the cursor to the end of the line. | `Ctrl + E`<br />`End (no Shift, Ctrl)` |
|
||||
| Move the cursor up one line. | `Up Arrow (no Shift, Alt, Ctrl, Cmd)` |
|
||||
| Move the cursor down one line. | `Down Arrow (no Shift, Alt, Ctrl, Cmd)` |
|
||||
| Move the cursor one character to the left. | `Left Arrow (no Shift, Alt, Ctrl, Cmd)`<br />`Ctrl + B` |
|
||||
| Move the cursor one character to the right. | `Right Arrow (no Shift, Alt, Ctrl, Cmd)`<br />`Ctrl + F` |
|
||||
| Move the cursor one word to the left. | `Ctrl + Left Arrow`<br />`Alt + Left Arrow`<br />`Alt + B` |
|
||||
| Move the cursor one word to the right. | `Ctrl + Right Arrow`<br />`Alt + Right Arrow`<br />`Alt + F` |
|
||||
|
||||
#### Editing
|
||||
|
||||
@@ -35,23 +35,23 @@ available combinations.
|
||||
| Delete from the cursor to the end of the line. | `Ctrl + K` |
|
||||
| Delete from the cursor to the start of the line. | `Ctrl + U` |
|
||||
| Clear all text in the input field. | `Ctrl + C` |
|
||||
| Delete the previous word. | `Ctrl + Backspace`<br />`Cmd + Backspace`<br />`Ctrl + W` |
|
||||
| Delete the next word. | `Ctrl + Delete`<br />`Cmd + Delete` |
|
||||
| Delete the previous word. | `Ctrl + Backspace`<br />`Alt + Backspace`<br />`Ctrl + W` |
|
||||
| Delete the next word. | `Ctrl + Delete`<br />`Alt + Delete` |
|
||||
| Delete the character to the left. | `Backspace`<br />`Ctrl + H` |
|
||||
| Delete the character to the right. | `Delete`<br />`Ctrl + D` |
|
||||
| Undo the most recent text edit. | `Ctrl + Z (no Shift)` |
|
||||
| Redo the most recent undone text edit. | `Ctrl + Shift + Z` |
|
||||
| Redo the most recent undone text edit. | `Shift + Ctrl + Z` |
|
||||
|
||||
#### Scrolling
|
||||
|
||||
| Action | Keys |
|
||||
| ------------------------ | -------------------- |
|
||||
| Scroll content up. | `Shift + Up Arrow` |
|
||||
| Scroll content down. | `Shift + Down Arrow` |
|
||||
| Scroll to the top. | `Home` |
|
||||
| Scroll to the bottom. | `End` |
|
||||
| Scroll up by one page. | `Page Up` |
|
||||
| Scroll down by one page. | `Page Down` |
|
||||
| Action | Keys |
|
||||
| ------------------------ | --------------------------------- |
|
||||
| Scroll content up. | `Shift + Up Arrow` |
|
||||
| Scroll content down. | `Shift + Down Arrow` |
|
||||
| Scroll to the top. | `Ctrl + Home`<br />`Shift + Home` |
|
||||
| Scroll to the bottom. | `Ctrl + End`<br />`Shift + End` |
|
||||
| Scroll up by one page. | `Page Up` |
|
||||
| Scroll down by one page. | `Page Down` |
|
||||
|
||||
#### History & Search
|
||||
|
||||
@@ -84,29 +84,29 @@ 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` |
|
||||
| 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 Shift, Alt, Ctrl, Cmd)` |
|
||||
| Insert a newline without submitting. | `Ctrl + Enter`<br />`Cmd + Enter`<br />`Alt + 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`<br />`Alt + 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 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` |
|
||||
| Restart the application. | `R` |
|
||||
| Action | Keys |
|
||||
| ----------------------------------------------------------------------------------------------------- | ---------------- |
|
||||
| Toggle detailed error information. | `F12` |
|
||||
| Toggle the full TODO list. | `Ctrl + T` |
|
||||
| Show IDE context details. | `Ctrl + G` |
|
||||
| Toggle Markdown rendering. | `Alt + M` |
|
||||
| Toggle copy mode when in alternate buffer mode. | `Ctrl + S` |
|
||||
| Toggle YOLO (auto-approval) mode for tool calls. | `Ctrl + Y` |
|
||||
| Cycle through approval modes: default (prompt), auto_edit (auto-approve edits), and plan (read-only). | `Shift + Tab` |
|
||||
| Expand a height-constrained response to show additional lines when not in alternate buffer mode. | `Ctrl + 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` |
|
||||
| Restart the application. | `R` |
|
||||
|
||||
<!-- KEYBINDINGS-AUTOGEN:END -->
|
||||
|
||||
@@ -117,7 +117,8 @@ available combinations.
|
||||
- `!` on an empty prompt: Enter or exit shell mode.
|
||||
- `\` (at end of a line) + `Enter`: Insert a newline without leaving single-line
|
||||
mode.
|
||||
- `Esc` pressed twice quickly: Browse and rewind previous interactions.
|
||||
- `Esc` pressed twice quickly: Clear the input prompt if it is not empty,
|
||||
otherwise browse and rewind previous interactions.
|
||||
- `Up Arrow` / `Down Arrow`: When the cursor is at the top or bottom of a
|
||||
single-line input, navigate backward or forward through prompt history.
|
||||
- `Number keys (1-9, multi-digit)` inside selection dialogs: Jump directly to
|
||||
|
||||
@@ -17,6 +17,11 @@ policies.
|
||||
may prompt you to switch to a fallback model (by default always prompts
|
||||
you).
|
||||
|
||||
Some internal utility calls (such as prompt completion and classification)
|
||||
use a silent fallback chain for `gemini-2.5-flash-lite` and will fall back
|
||||
to `gemini-2.5-flash` and `gemini-2.5-pro` without prompting or changing the
|
||||
configured model.
|
||||
|
||||
3. **Model switch:** If approved, or if the policy allows for silent fallback,
|
||||
the CLI will use an available fallback model for the current turn or the
|
||||
remainder of the session.
|
||||
|
||||
@@ -99,7 +99,7 @@ they appear in the UI.
|
||||
| Enable Tool Output Truncation | `tools.enableToolOutputTruncation` | Enable truncation of large tool outputs. | `true` |
|
||||
| Tool Output Truncation Threshold | `tools.truncateToolOutputThreshold` | Truncate tool output if it is larger than this many characters. Set to -1 to disable. | `4000000` |
|
||||
| Tool Output Truncation Lines | `tools.truncateToolOutputLines` | The number of lines to keep when truncating tool output. | `1000` |
|
||||
| Disable LLM Correction | `tools.disableLLMCorrection` | Disable LLM-based error correction for edit tools. When enabled, tools will fail immediately if exact string matches are not found, instead of attempting to self-correct. | `false` |
|
||||
| Disable LLM Correction | `tools.disableLLMCorrection` | Disable LLM-based error correction for edit tools. When enabled, tools will fail immediately if exact string matches are not found, instead of attempting to self-correct. | `true` |
|
||||
|
||||
### Security
|
||||
|
||||
@@ -122,10 +122,10 @@ they appear in the UI.
|
||||
| Enable CLI Help Agent | `experimental.cliHelpAgentSettings.enabled` | Enable the CLI Help Agent. | `true` |
|
||||
| Plan | `experimental.plan` | Enable planning features (Plan Mode and tools). | `false` |
|
||||
|
||||
### Hooks
|
||||
### HooksConfig
|
||||
|
||||
| UI Label | Setting | Description | Default |
|
||||
| ------------------ | --------------------- | ------------------------------------------------ | ------- |
|
||||
| Hook Notifications | `hooks.notifications` | Show visual indicators when hooks are executing. | `true` |
|
||||
| UI Label | Setting | Description | Default |
|
||||
| ------------------ | --------------------------- | ------------------------------------------------ | ------- |
|
||||
| Hook Notifications | `hooksConfig.notifications` | Show visual indicators when hooks are executing. | `true` |
|
||||
|
||||
<!-- SETTINGS-AUTOGEN:END -->
|
||||
|
||||
@@ -56,6 +56,38 @@ error with: `missing system prompt file '<path>'`.
|
||||
When `GEMINI_SYSTEM_MD` is active, the CLI shows a `|⌐■_■|` indicator in the UI
|
||||
to signal custom system‑prompt mode.
|
||||
|
||||
## Variable Substitution
|
||||
|
||||
When using a custom system prompt file, you can use the following variables to
|
||||
dynamically include built-in content:
|
||||
|
||||
- `${AgentSkills}`: Injects a complete section (including header) of all
|
||||
available agent skills.
|
||||
- `${SubAgents}`: Injects a complete section (including header) of available
|
||||
sub-agents.
|
||||
- `${AvailableTools}`: Injects a bulleted list of all currently enabled tool
|
||||
names.
|
||||
- Tool Name Variables: Injects the actual name of a tool using the pattern:
|
||||
`${toolName}_ToolName` (e.g., `${write_file_ToolName}`,
|
||||
`${run_shell_command_ToolName}`).
|
||||
|
||||
This pattern is generated dynamically for all available tools.
|
||||
|
||||
### Example
|
||||
|
||||
```markdown
|
||||
# Custom System Prompt
|
||||
|
||||
You are a helpful assistant. ${AgentSkills}
|
||||
${SubAgents}
|
||||
|
||||
## Tooling
|
||||
|
||||
The following tools are available to you: ${AvailableTools}
|
||||
|
||||
You can use ${write_file_ToolName} to save logs.
|
||||
```
|
||||
|
||||
## Export the default prompt (recommended)
|
||||
|
||||
Before overriding, export the current default prompt so you can review required
|
||||
|
||||
@@ -18,6 +18,7 @@ Learn how to enable and setup OpenTelemetry for Gemini CLI.
|
||||
- [Logs and metrics](#logs-and-metrics)
|
||||
- [Logs](#logs)
|
||||
- [Sessions](#sessions)
|
||||
- [Approval Mode](#approval-mode)
|
||||
- [Tools](#tools)
|
||||
- [Files](#files)
|
||||
- [API](#api)
|
||||
@@ -315,6 +316,20 @@ Captures startup configuration and user prompt submissions.
|
||||
- `prompt` (string; excluded if `telemetry.logPrompts` is `false`)
|
||||
- `auth_type` (string)
|
||||
|
||||
#### Approval Mode
|
||||
|
||||
Tracks changes and duration of approval modes.
|
||||
|
||||
- `approval_mode_switch`: Approval mode was changed.
|
||||
- **Attributes**:
|
||||
- `from_mode` (string)
|
||||
- `to_mode` (string)
|
||||
|
||||
- `approval_mode_duration`: Duration spent in an approval mode.
|
||||
- **Attributes**:
|
||||
- `mode` (string)
|
||||
- `duration_ms` (int)
|
||||
|
||||
#### Tools
|
||||
|
||||
Captures tool executions, output truncation, and Edit behavior.
|
||||
|
||||
@@ -68,6 +68,10 @@ If you are using the default "pro" model and the CLI detects that you are being
|
||||
rate-limited, it automatically switches to the "flash" model for the current
|
||||
session. This allows you to continue working without interruption.
|
||||
|
||||
Internal utility calls that use `gemini-2.5-flash-lite` (for example, prompt
|
||||
completion and classification) silently fall back to `gemini-2.5-flash` and
|
||||
`gemini-2.5-pro` when quota is exhausted, without changing the configured model.
|
||||
|
||||
## File discovery service
|
||||
|
||||
The file discovery service is responsible for finding files in the project that
|
||||
|
||||
@@ -707,7 +707,7 @@ their corresponding top-level category object in your `settings.json` file.
|
||||
- **Description:** Disable LLM-based error correction for edit tools. When
|
||||
enabled, tools will fail immediately if exact string matches are not found,
|
||||
instead of attempting to self-correct.
|
||||
- **Default:** `false`
|
||||
- **Default:** `true`
|
||||
- **Requires restart:** Yes
|
||||
|
||||
- **`tools.enableHooks`** (boolean):
|
||||
@@ -837,7 +837,7 @@ their corresponding top-level category object in your `settings.json` file.
|
||||
|
||||
- **`experimental.enableEventDrivenScheduler`** (boolean):
|
||||
- **Description:** Enables event-driven scheduler within the CLI session.
|
||||
- **Default:** `false`
|
||||
- **Default:** `true`
|
||||
- **Requires restart:** Yes
|
||||
|
||||
- **`experimental.extensionReloading`** (boolean):
|
||||
@@ -904,22 +904,24 @@ their corresponding top-level category object in your `settings.json` file.
|
||||
- **Default:** `[]`
|
||||
- **Requires restart:** Yes
|
||||
|
||||
#### `hooks`
|
||||
#### `hooksConfig`
|
||||
|
||||
- **`hooks.enabled`** (boolean):
|
||||
- **`hooksConfig.enabled`** (boolean):
|
||||
- **Description:** Canonical toggle for the hooks system. When disabled, no
|
||||
hooks will be executed.
|
||||
- **Default:** `false`
|
||||
- **Default:** `true`
|
||||
|
||||
- **`hooks.disabled`** (array):
|
||||
- **`hooksConfig.disabled`** (array):
|
||||
- **Description:** List of hook names (commands) that should be disabled.
|
||||
Hooks in this list will not execute even if configured.
|
||||
- **Default:** `[]`
|
||||
|
||||
- **`hooks.notifications`** (boolean):
|
||||
- **`hooksConfig.notifications`** (boolean):
|
||||
- **Description:** Show visual indicators when hooks are executing.
|
||||
- **Default:** `true`
|
||||
|
||||
#### `hooks`
|
||||
|
||||
- **`hooks.BeforeTool`** (array):
|
||||
- **Description:** Hooks that execute before tool execution. Can intercept,
|
||||
validate, or modify tool calls.
|
||||
|
||||
+2
-2
@@ -1,10 +1,10 @@
|
||||
# Gemini CLI hooks
|
||||
# Gemini CLI hooks (experimental)
|
||||
|
||||
Hooks are scripts or programs that Gemini CLI executes at specific points in the
|
||||
agentic loop, allowing you to intercept and customize behavior without modifying
|
||||
the CLI's source code.
|
||||
|
||||
> **Note: Hooks are currently an experimental feature.**
|
||||
> [!NOTE] **Hooks are currently an experimental feature.**
|
||||
>
|
||||
> To use hooks, you must explicitly enable them in your `settings.json`:
|
||||
>
|
||||
|
||||
+1
-9
@@ -80,10 +80,6 @@
|
||||
"label": "Model selection",
|
||||
"slug": "docs/cli/model"
|
||||
},
|
||||
{
|
||||
"label": "Rewind",
|
||||
"slug": "docs/cli/rewind"
|
||||
},
|
||||
{
|
||||
"label": "Sandbox",
|
||||
"slug": "docs/cli/sandbox"
|
||||
@@ -206,7 +202,7 @@
|
||||
]
|
||||
},
|
||||
{
|
||||
"label": "Hooks",
|
||||
"label": "Hooks (experimental)",
|
||||
"items": [
|
||||
{
|
||||
"label": "Introduction",
|
||||
@@ -274,10 +270,6 @@
|
||||
{
|
||||
"label": "Preview release",
|
||||
"slug": "docs/changelogs/preview"
|
||||
},
|
||||
{
|
||||
"label": "Changelog",
|
||||
"slug": "docs/changelogs/releases"
|
||||
}
|
||||
]
|
||||
},
|
||||
|
||||
@@ -304,6 +304,16 @@ export default tseslint.config(
|
||||
'@typescript-eslint/no-require-imports': 'off',
|
||||
},
|
||||
},
|
||||
// Examples should have access to standard globals like fetch
|
||||
{
|
||||
files: ['packages/cli/src/commands/extensions/examples/**/*.js'],
|
||||
languageOptions: {
|
||||
globals: {
|
||||
...globals.node,
|
||||
fetch: 'readonly',
|
||||
},
|
||||
},
|
||||
},
|
||||
// extra settings for scripts that we run directly with node
|
||||
{
|
||||
files: ['packages/vscode-ide-companion/scripts/**/*.js'],
|
||||
|
||||
@@ -0,0 +1,48 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { describe, expect } from 'vitest';
|
||||
import { evalTest } from './test-helper.js';
|
||||
import path from 'node:path';
|
||||
import fs from 'node:fs/promises';
|
||||
|
||||
describe('generalist_agent', () => {
|
||||
evalTest('ALWAYS_PASSES', {
|
||||
name: 'should be able to use generalist agent by explicitly asking the main agent to invoke it',
|
||||
params: {
|
||||
settings: {
|
||||
agents: {
|
||||
overrides: {
|
||||
generalist: { enabled: true },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
prompt:
|
||||
'Please use the generalist agent to create a file called "generalist_test_file.txt" containing exactly the following text: success',
|
||||
assert: async (rig) => {
|
||||
// 1) Verify the generalist agent was invoked via delegate_to_agent
|
||||
const foundToolCall = await rig.waitForToolCall(
|
||||
'delegate_to_agent',
|
||||
undefined,
|
||||
(args) => {
|
||||
const parsed = JSON.parse(args);
|
||||
return parsed.agent_name === 'generalist';
|
||||
},
|
||||
);
|
||||
expect(
|
||||
foundToolCall,
|
||||
'Expected to find a delegate_to_agent tool call for generalist agent',
|
||||
).toBeTruthy();
|
||||
|
||||
// 2) Verify the file was created as expected
|
||||
const filePath = path.join(rig.testDir!, 'generalist_test_file.txt');
|
||||
|
||||
const content = await fs.readFile(filePath, 'utf-8');
|
||||
expect(content.trim()).toBe('success');
|
||||
},
|
||||
});
|
||||
});
|
||||
@@ -31,7 +31,7 @@ describe('subagent eval test cases', () => {
|
||||
*
|
||||
* This tests the system prompt's subagent specific clauses.
|
||||
*/
|
||||
evalTest('ALWAYS_PASSES', {
|
||||
evalTest('USUALLY_PASSES', {
|
||||
name: 'should delegate to user provided agent with relevant expertise',
|
||||
params: {
|
||||
settings: {
|
||||
|
||||
@@ -59,7 +59,10 @@ export function evalTest(policy: EvalPolicy, evalCase: EvalCase) {
|
||||
execSync('git commit --allow-empty -m "Initial commit"', execOptions);
|
||||
}
|
||||
|
||||
const result = await rig.run({ args: evalCase.prompt });
|
||||
const result = await rig.run({
|
||||
args: evalCase.prompt,
|
||||
approvalMode: evalCase.approvalMode ?? 'yolo',
|
||||
});
|
||||
|
||||
const unauthorizedErrorPrefix =
|
||||
createUnauthorizedToolError('').split("'")[0];
|
||||
@@ -91,6 +94,7 @@ export interface EvalCase {
|
||||
params?: Record<string, any>;
|
||||
prompt: string;
|
||||
files?: Record<string, string>;
|
||||
approvalMode?: 'default' | 'auto_edit' | 'yolo' | 'plan';
|
||||
assert: (rig: TestRig, result: string) => Promise<void>;
|
||||
}
|
||||
|
||||
|
||||
@@ -53,8 +53,10 @@ describe('Hooks Agent Flow', () => {
|
||||
|
||||
await rig.setup('should inject additional context via BeforeAgent hook', {
|
||||
settings: {
|
||||
hooks: {
|
||||
hooksConfig: {
|
||||
enabled: true,
|
||||
},
|
||||
hooks: {
|
||||
BeforeAgent: [
|
||||
{
|
||||
hooks: [
|
||||
@@ -116,8 +118,10 @@ describe('Hooks Agent Flow', () => {
|
||||
|
||||
await rig.setup('should receive prompt and response in AfterAgent hook', {
|
||||
settings: {
|
||||
hooks: {
|
||||
hooksConfig: {
|
||||
enabled: true,
|
||||
},
|
||||
hooks: {
|
||||
AfterAgent: [
|
||||
{
|
||||
hooks: [
|
||||
@@ -163,8 +167,10 @@ describe('Hooks Agent Flow', () => {
|
||||
'hooks-agent-flow-multistep.responses',
|
||||
),
|
||||
settings: {
|
||||
hooks: {
|
||||
hooksConfig: {
|
||||
enabled: true,
|
||||
},
|
||||
hooks: {
|
||||
BeforeAgent: [
|
||||
{
|
||||
hooks: [
|
||||
|
||||
@@ -24,7 +24,7 @@ describe('Hooks System Integration', () => {
|
||||
|
||||
describe('Command Hooks - Blocking Behavior', () => {
|
||||
it('should block tool execution when hook returns block decision', async () => {
|
||||
await rig.setup(
|
||||
rig.setup(
|
||||
'should block tool execution when hook returns block decision',
|
||||
{
|
||||
fakeResponsesPath: join(
|
||||
@@ -32,8 +32,10 @@ describe('Hooks System Integration', () => {
|
||||
'hooks-system.block-tool.responses',
|
||||
),
|
||||
settings: {
|
||||
hooks: {
|
||||
hooksConfig: {
|
||||
enabled: true,
|
||||
},
|
||||
hooks: {
|
||||
BeforeTool: [
|
||||
{
|
||||
matcher: 'write_file',
|
||||
@@ -75,8 +77,67 @@ describe('Hooks System Integration', () => {
|
||||
expect(hookTelemetryFound).toBeTruthy();
|
||||
});
|
||||
|
||||
it('should block tool execution and use stderr as reason when hook exits with code 2', async () => {
|
||||
rig.setup(
|
||||
'should block tool execution and use stderr as reason when hook exits with code 2',
|
||||
{
|
||||
fakeResponsesPath: join(
|
||||
import.meta.dirname,
|
||||
'hooks-system.block-tool.responses',
|
||||
),
|
||||
settings: {
|
||||
hooksConfig: {
|
||||
enabled: true,
|
||||
},
|
||||
hooks: {
|
||||
BeforeTool: [
|
||||
{
|
||||
matcher: 'write_file',
|
||||
hooks: [
|
||||
{
|
||||
type: 'command',
|
||||
// Exit with code 2 and write reason to stderr
|
||||
command:
|
||||
'node -e "process.stderr.write(\'File writing blocked by security policy\'); process.exit(2)"',
|
||||
timeout: 5000,
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
const result = await rig.run({
|
||||
args: 'Create a file called test.txt with content "Hello World"',
|
||||
});
|
||||
|
||||
// The hook should block the write_file tool
|
||||
const toolLogs = rig.readToolLogs();
|
||||
const writeFileCalls = toolLogs.filter(
|
||||
(t) =>
|
||||
t.toolRequest.name === 'write_file' && t.toolRequest.success === true,
|
||||
);
|
||||
|
||||
// Tool should not be called due to blocking hook
|
||||
expect(writeFileCalls).toHaveLength(0);
|
||||
|
||||
// Result should mention the blocking reason from stderr
|
||||
expect(result).toContain('File writing blocked by security policy');
|
||||
|
||||
// Verify hook telemetry shows exit code 2 and stderr
|
||||
const hookLogs = rig.readHookLogs();
|
||||
const blockHook = hookLogs.find((log) => log.hookCall.exit_code === 2);
|
||||
expect(blockHook).toBeDefined();
|
||||
expect(blockHook?.hookCall.stderr).toContain(
|
||||
'File writing blocked by security policy',
|
||||
);
|
||||
expect(blockHook?.hookCall.success).toBe(false);
|
||||
});
|
||||
|
||||
it('should allow tool execution when hook returns allow decision', async () => {
|
||||
await rig.setup(
|
||||
rig.setup(
|
||||
'should allow tool execution when hook returns allow decision',
|
||||
{
|
||||
fakeResponsesPath: join(
|
||||
@@ -84,8 +145,10 @@ describe('Hooks System Integration', () => {
|
||||
'hooks-system.allow-tool.responses',
|
||||
),
|
||||
settings: {
|
||||
hooks: {
|
||||
hooksConfig: {
|
||||
enabled: true,
|
||||
},
|
||||
hooks: {
|
||||
BeforeTool: [
|
||||
{
|
||||
matcher: 'write_file',
|
||||
@@ -126,14 +189,16 @@ describe('Hooks System Integration', () => {
|
||||
it('should add additional context from AfterTool hooks', async () => {
|
||||
const command =
|
||||
"node -e \"console.log(JSON.stringify({hookSpecificOutput: {hookEventName: 'AfterTool', additionalContext: 'Security scan: File content appears safe'}}))\"";
|
||||
await rig.setup('should add additional context from AfterTool hooks', {
|
||||
rig.setup('should add additional context from AfterTool hooks', {
|
||||
fakeResponsesPath: join(
|
||||
import.meta.dirname,
|
||||
'hooks-system.after-tool-context.responses',
|
||||
),
|
||||
settings: {
|
||||
hooks: {
|
||||
hooksConfig: {
|
||||
enabled: true,
|
||||
},
|
||||
hooks: {
|
||||
AfterTool: [
|
||||
{
|
||||
matcher: 'read_file',
|
||||
@@ -178,7 +243,7 @@ describe('Hooks System Integration', () => {
|
||||
it('should modify LLM requests with BeforeModel hooks', async () => {
|
||||
// Create a hook script that replaces the LLM request with a modified version
|
||||
// Note: Providing messages in the hook output REPLACES the entire conversation
|
||||
await rig.setup('should modify LLM requests with BeforeModel hooks', {
|
||||
rig.setup('should modify LLM requests with BeforeModel hooks', {
|
||||
fakeResponsesPath: join(
|
||||
import.meta.dirname,
|
||||
'hooks-system.before-model.responses',
|
||||
@@ -203,10 +268,12 @@ console.log(JSON.stringify({
|
||||
const scriptPath = join(rig.testDir!, 'before_model_hook.cjs');
|
||||
writeFileSync(scriptPath, hookScript);
|
||||
|
||||
await rig.setup('should modify LLM requests with BeforeModel hooks', {
|
||||
rig.setup('should modify LLM requests with BeforeModel hooks', {
|
||||
settings: {
|
||||
hooks: {
|
||||
hooksConfig: {
|
||||
enabled: true,
|
||||
},
|
||||
hooks: {
|
||||
BeforeModel: [
|
||||
{
|
||||
hooks: [
|
||||
@@ -248,13 +315,103 @@ console.log(JSON.stringify({
|
||||
expect(hookTelemetryFound[0].hookCall.stdout).toBeDefined();
|
||||
expect(hookTelemetryFound[0].hookCall.stderr).toBeDefined();
|
||||
});
|
||||
|
||||
it('should block model execution when BeforeModel hook returns deny decision', async () => {
|
||||
rig.setup(
|
||||
'should block model execution when BeforeModel hook returns deny decision',
|
||||
);
|
||||
const hookScript = `console.log(JSON.stringify({
|
||||
decision: "deny",
|
||||
reason: "Model execution blocked by security policy"
|
||||
}));`;
|
||||
const scriptPath = join(rig.testDir!, 'before_model_deny_hook.cjs');
|
||||
writeFileSync(scriptPath, hookScript);
|
||||
|
||||
rig.setup(
|
||||
'should block model execution when BeforeModel hook returns deny decision',
|
||||
{
|
||||
settings: {
|
||||
hooksConfig: {
|
||||
enabled: true,
|
||||
},
|
||||
hooks: {
|
||||
BeforeModel: [
|
||||
{
|
||||
hooks: [
|
||||
{
|
||||
type: 'command',
|
||||
command: `node "${scriptPath}"`,
|
||||
timeout: 5000,
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
const result = await rig.run({ args: 'Hello' });
|
||||
|
||||
// The hook should have blocked the request
|
||||
expect(result).toContain('Model execution blocked by security policy');
|
||||
|
||||
// Verify no API requests were made to the LLM
|
||||
const apiRequests = rig.readAllApiRequest();
|
||||
expect(apiRequests).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('should block model execution when BeforeModel hook returns block decision', async () => {
|
||||
rig.setup(
|
||||
'should block model execution when BeforeModel hook returns block decision',
|
||||
);
|
||||
const hookScript = `console.log(JSON.stringify({
|
||||
decision: "block",
|
||||
reason: "Model execution blocked by security policy"
|
||||
}));`;
|
||||
const scriptPath = join(rig.testDir!, 'before_model_block_hook.cjs');
|
||||
writeFileSync(scriptPath, hookScript);
|
||||
|
||||
rig.setup(
|
||||
'should block model execution when BeforeModel hook returns block decision',
|
||||
{
|
||||
settings: {
|
||||
hooksConfig: {
|
||||
enabled: true,
|
||||
},
|
||||
hooks: {
|
||||
BeforeModel: [
|
||||
{
|
||||
hooks: [
|
||||
{
|
||||
type: 'command',
|
||||
command: `node "${scriptPath}"`,
|
||||
timeout: 5000,
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
const result = await rig.run({ args: 'Hello' });
|
||||
|
||||
// The hook should have blocked the request
|
||||
expect(result).toContain('Model execution blocked by security policy');
|
||||
|
||||
// Verify no API requests were made to the LLM
|
||||
const apiRequests = rig.readAllApiRequest();
|
||||
expect(apiRequests).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('AfterModel Hooks - LLM Response Modification', () => {
|
||||
it.skipIf(process.platform === 'win32')(
|
||||
'should modify LLM responses with AfterModel hooks',
|
||||
async () => {
|
||||
await rig.setup('should modify LLM responses with AfterModel hooks', {
|
||||
rig.setup('should modify LLM responses with AfterModel hooks', {
|
||||
fakeResponsesPath: join(
|
||||
import.meta.dirname,
|
||||
'hooks-system.after-model.responses',
|
||||
@@ -284,10 +441,12 @@ console.log(JSON.stringify({
|
||||
const scriptPath = join(rig.testDir!, 'after_model_hook.cjs');
|
||||
writeFileSync(scriptPath, hookScript);
|
||||
|
||||
await rig.setup('should modify LLM responses with AfterModel hooks', {
|
||||
rig.setup('should modify LLM responses with AfterModel hooks', {
|
||||
settings: {
|
||||
hooks: {
|
||||
hooksConfig: {
|
||||
enabled: true,
|
||||
},
|
||||
hooks: {
|
||||
AfterModel: [
|
||||
{
|
||||
hooks: [
|
||||
@@ -319,41 +478,37 @@ console.log(JSON.stringify({
|
||||
|
||||
describe('BeforeToolSelection Hooks - Tool Configuration', () => {
|
||||
it('should modify tool selection with BeforeToolSelection hooks', async () => {
|
||||
await rig.setup(
|
||||
'should modify tool selection with BeforeToolSelection hooks',
|
||||
{
|
||||
fakeResponsesPath: join(
|
||||
import.meta.dirname,
|
||||
'hooks-system.before-tool-selection.responses',
|
||||
),
|
||||
},
|
||||
);
|
||||
rig.setup('should modify tool selection with BeforeToolSelection hooks', {
|
||||
fakeResponsesPath: join(
|
||||
import.meta.dirname,
|
||||
'hooks-system.before-tool-selection.responses',
|
||||
),
|
||||
});
|
||||
// Create inline hook command (works on both Unix and Windows)
|
||||
const hookCommand =
|
||||
"node -e \"console.log(JSON.stringify({hookSpecificOutput: {hookEventName: 'BeforeToolSelection', toolConfig: {mode: 'ANY', allowedFunctionNames: ['read_file', 'run_shell_command']}}}))\"";
|
||||
|
||||
await rig.setup(
|
||||
'should modify tool selection with BeforeToolSelection hooks',
|
||||
{
|
||||
settings: {
|
||||
debugMode: true,
|
||||
hooks: {
|
||||
enabled: true,
|
||||
BeforeToolSelection: [
|
||||
{
|
||||
hooks: [
|
||||
{
|
||||
type: 'command',
|
||||
command: hookCommand,
|
||||
timeout: 5000,
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
rig.setup('should modify tool selection with BeforeToolSelection hooks', {
|
||||
settings: {
|
||||
debugMode: true,
|
||||
hooksConfig: {
|
||||
enabled: true,
|
||||
},
|
||||
hooks: {
|
||||
BeforeToolSelection: [
|
||||
{
|
||||
hooks: [
|
||||
{
|
||||
type: 'command',
|
||||
command: hookCommand,
|
||||
timeout: 5000,
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
// Create a test file
|
||||
rig.createFile('new_file_data.txt', 'test data');
|
||||
@@ -382,7 +537,7 @@ console.log(JSON.stringify({
|
||||
|
||||
describe('BeforeAgent Hooks - Prompt Augmentation', () => {
|
||||
it('should augment prompts with BeforeAgent hooks', async () => {
|
||||
await rig.setup('should augment prompts with BeforeAgent hooks', {
|
||||
rig.setup('should augment prompts with BeforeAgent hooks', {
|
||||
fakeResponsesPath: join(
|
||||
import.meta.dirname,
|
||||
'hooks-system.before-agent.responses',
|
||||
@@ -401,10 +556,12 @@ console.log(JSON.stringify({
|
||||
const scriptPath = join(rig.testDir!, 'before_agent_hook.cjs');
|
||||
writeFileSync(scriptPath, hookScript);
|
||||
|
||||
await rig.setup('should augment prompts with BeforeAgent hooks', {
|
||||
rig.setup('should augment prompts with BeforeAgent hooks', {
|
||||
settings: {
|
||||
hooks: {
|
||||
hooksConfig: {
|
||||
enabled: true,
|
||||
},
|
||||
hooks: {
|
||||
BeforeAgent: [
|
||||
{
|
||||
hooks: [
|
||||
@@ -438,7 +595,7 @@ console.log(JSON.stringify({
|
||||
const hookCommand =
|
||||
'node -e "console.log(JSON.stringify({suppressOutput: false, systemMessage: \'Permission request logged by security hook\'}))"';
|
||||
|
||||
await rig.setup('should handle notification hooks for tool permissions', {
|
||||
rig.setup('should handle notification hooks for tool permissions', {
|
||||
fakeResponsesPath: join(
|
||||
import.meta.dirname,
|
||||
'hooks-system.notification.responses',
|
||||
@@ -449,8 +606,10 @@ console.log(JSON.stringify({
|
||||
approval: 'ASK', // Disable YOLO mode to show permission prompts
|
||||
confirmationRequired: ['run_shell_command'],
|
||||
},
|
||||
hooks: {
|
||||
hooksConfig: {
|
||||
enabled: true,
|
||||
},
|
||||
hooks: {
|
||||
Notification: [
|
||||
{
|
||||
matcher: 'ToolPermission',
|
||||
@@ -467,7 +626,7 @@ console.log(JSON.stringify({
|
||||
},
|
||||
});
|
||||
|
||||
const run = await rig.runInteractive({ yolo: false });
|
||||
const run = await rig.runInteractive({ approvalMode: 'default' });
|
||||
|
||||
// Send prompt that will trigger a permission request
|
||||
await run.type('Run the command "echo test"');
|
||||
@@ -534,14 +693,16 @@ console.log(JSON.stringify({
|
||||
const hook2Command =
|
||||
"node -e \"console.log(JSON.stringify({decision: 'allow', hookSpecificOutput: {hookEventName: 'BeforeAgent', additionalContext: 'Step 2: Security check completed.'}}))\"";
|
||||
|
||||
await rig.setup('should execute hooks sequentially when configured', {
|
||||
rig.setup('should execute hooks sequentially when configured', {
|
||||
fakeResponsesPath: join(
|
||||
import.meta.dirname,
|
||||
'hooks-system.sequential-execution.responses',
|
||||
),
|
||||
settings: {
|
||||
hooks: {
|
||||
hooksConfig: {
|
||||
enabled: true,
|
||||
},
|
||||
hooks: {
|
||||
BeforeAgent: [
|
||||
{
|
||||
sequential: true,
|
||||
@@ -594,7 +755,7 @@ console.log(JSON.stringify({
|
||||
|
||||
describe('Hook Input/Output Validation', () => {
|
||||
it('should provide correct input format to hooks', async () => {
|
||||
await rig.setup('should provide correct input format to hooks', {
|
||||
rig.setup('should provide correct input format to hooks', {
|
||||
fakeResponsesPath: join(
|
||||
import.meta.dirname,
|
||||
'hooks-system.input-validation.responses',
|
||||
@@ -618,10 +779,12 @@ try {
|
||||
const scriptPath = join(rig.testDir!, 'input_validation_hook.cjs');
|
||||
writeFileSync(scriptPath, hookScript);
|
||||
|
||||
await rig.setup('should provide correct input format to hooks', {
|
||||
rig.setup('should provide correct input format to hooks', {
|
||||
settings: {
|
||||
hooks: {
|
||||
hooksConfig: {
|
||||
enabled: true,
|
||||
},
|
||||
hooks: {
|
||||
BeforeTool: [
|
||||
{
|
||||
hooks: [
|
||||
@@ -653,6 +816,52 @@ try {
|
||||
const hookTelemetryFound = await rig.waitForTelemetryEvent('hook_call');
|
||||
expect(hookTelemetryFound).toBeTruthy();
|
||||
});
|
||||
|
||||
it('should treat mixed stdout (text + JSON) as system message and allow execution when exit code is 0', async () => {
|
||||
rig.setup(
|
||||
'should treat mixed stdout (text + JSON) as system message and allow execution when exit code is 0',
|
||||
{
|
||||
fakeResponsesPath: join(
|
||||
import.meta.dirname,
|
||||
'hooks-system.allow-tool.responses',
|
||||
),
|
||||
settings: {
|
||||
hooksConfig: {
|
||||
enabled: true,
|
||||
},
|
||||
hooks: {
|
||||
BeforeTool: [
|
||||
{
|
||||
matcher: 'write_file',
|
||||
hooks: [
|
||||
{
|
||||
type: 'command',
|
||||
// Output plain text then JSON.
|
||||
// This breaks JSON parsing, so it falls back to 'allow' with the whole stdout as systemMessage.
|
||||
command:
|
||||
"node -e \"console.log('Pollution'); console.log(JSON.stringify({decision: 'deny', reason: 'Should be ignored'}))\"",
|
||||
timeout: 5000,
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
const result = await rig.run({
|
||||
args: 'Create a file called approved.txt with content "Approved content"',
|
||||
});
|
||||
|
||||
// The hook logic fails to parse JSON, so it allows the tool.
|
||||
const foundWriteFile = await rig.waitForToolCall('write_file');
|
||||
expect(foundWriteFile).toBeTruthy();
|
||||
|
||||
// The entire stdout (including the JSON part) becomes the systemMessage
|
||||
expect(result).toContain('Pollution');
|
||||
expect(result).toContain('Should be ignored');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Multiple Event Types', () => {
|
||||
@@ -665,14 +874,16 @@ try {
|
||||
const beforeAgentCommand =
|
||||
"node -e \"console.log(JSON.stringify({decision: 'allow', hookSpecificOutput: {hookEventName: 'BeforeAgent', additionalContext: 'BeforeAgent: User request processed'}}))\"";
|
||||
|
||||
await rig.setup('should handle hooks for all major event types', {
|
||||
rig.setup('should handle hooks for all major event types', {
|
||||
fakeResponsesPath: join(
|
||||
import.meta.dirname,
|
||||
'hooks-system.multiple-events.responses',
|
||||
),
|
||||
settings: {
|
||||
hooks: {
|
||||
hooksConfig: {
|
||||
enabled: true,
|
||||
},
|
||||
hooks: {
|
||||
BeforeAgent: [
|
||||
{
|
||||
hooks: [
|
||||
@@ -768,7 +979,7 @@ try {
|
||||
|
||||
describe('Hook Error Handling', () => {
|
||||
it('should handle hook failures gracefully', async () => {
|
||||
await rig.setup('should handle hook failures gracefully', {
|
||||
rig.setup('should handle hook failures gracefully', {
|
||||
fakeResponsesPath: join(
|
||||
import.meta.dirname,
|
||||
'hooks-system.error-handling.responses',
|
||||
@@ -782,10 +993,12 @@ try {
|
||||
const workingCommand =
|
||||
"node -e \"console.log(JSON.stringify({decision: 'allow', reason: 'Working hook succeeded'}))\"";
|
||||
|
||||
await rig.setup('should handle hook failures gracefully', {
|
||||
rig.setup('should handle hook failures gracefully', {
|
||||
settings: {
|
||||
hooks: {
|
||||
hooksConfig: {
|
||||
enabled: true,
|
||||
},
|
||||
hooks: {
|
||||
BeforeTool: [
|
||||
{
|
||||
hooks: [
|
||||
@@ -830,14 +1043,16 @@ try {
|
||||
const hookCommand =
|
||||
"node -e \"console.log(JSON.stringify({decision: 'allow', reason: 'Telemetry test hook'}))\"";
|
||||
|
||||
await rig.setup('should generate telemetry events for hook executions', {
|
||||
rig.setup('should generate telemetry events for hook executions', {
|
||||
fakeResponsesPath: join(
|
||||
import.meta.dirname,
|
||||
'hooks-system.telemetry.responses',
|
||||
),
|
||||
settings: {
|
||||
hooks: {
|
||||
hooksConfig: {
|
||||
enabled: true,
|
||||
},
|
||||
hooks: {
|
||||
BeforeTool: [
|
||||
{
|
||||
hooks: [
|
||||
@@ -871,14 +1086,16 @@ try {
|
||||
const sessionStartCommand =
|
||||
"node -e \"console.log(JSON.stringify({decision: 'allow', systemMessage: 'Session starting on startup'}))\"";
|
||||
|
||||
await rig.setup('should fire SessionStart hook on app startup', {
|
||||
rig.setup('should fire SessionStart hook on app startup', {
|
||||
fakeResponsesPath: join(
|
||||
import.meta.dirname,
|
||||
'hooks-system.session-startup.responses',
|
||||
),
|
||||
settings: {
|
||||
hooks: {
|
||||
hooksConfig: {
|
||||
enabled: true,
|
||||
},
|
||||
hooks: {
|
||||
SessionStart: [
|
||||
{
|
||||
matcher: 'startup',
|
||||
@@ -936,7 +1153,7 @@ console.log(JSON.stringify({
|
||||
}
|
||||
}));`;
|
||||
|
||||
await rig.setup('should fire SessionStart hook and inject context', {
|
||||
rig.setup('should fire SessionStart hook and inject context', {
|
||||
fakeResponsesPath: join(
|
||||
import.meta.dirname,
|
||||
'hooks-system.session-startup.responses',
|
||||
@@ -946,10 +1163,12 @@ console.log(JSON.stringify({
|
||||
const scriptPath = join(rig.testDir!, 'session_start_context_hook.cjs');
|
||||
writeFileSync(scriptPath, hookScript);
|
||||
|
||||
await rig.setup('should fire SessionStart hook and inject context', {
|
||||
rig.setup('should fire SessionStart hook and inject context', {
|
||||
settings: {
|
||||
hooks: {
|
||||
hooksConfig: {
|
||||
enabled: true,
|
||||
},
|
||||
hooks: {
|
||||
SessionStart: [
|
||||
{
|
||||
matcher: 'startup',
|
||||
@@ -1011,7 +1230,7 @@ console.log(JSON.stringify({
|
||||
}
|
||||
}));`;
|
||||
|
||||
await rig.setup(
|
||||
rig.setup(
|
||||
'should fire SessionStart hook and display systemMessage in interactive mode',
|
||||
{
|
||||
fakeResponsesPath: join(
|
||||
@@ -1027,12 +1246,14 @@ console.log(JSON.stringify({
|
||||
);
|
||||
writeFileSync(scriptPath, hookScript);
|
||||
|
||||
await rig.setup(
|
||||
rig.setup(
|
||||
'should fire SessionStart hook and display systemMessage in interactive mode',
|
||||
{
|
||||
settings: {
|
||||
hooks: {
|
||||
hooksConfig: {
|
||||
enabled: true,
|
||||
},
|
||||
hooks: {
|
||||
SessionStart: [
|
||||
{
|
||||
matcher: 'startup',
|
||||
@@ -1091,7 +1312,7 @@ console.log(JSON.stringify({
|
||||
const sessionStartCommand =
|
||||
"node -e \"console.log(JSON.stringify({decision: 'allow', systemMessage: 'Session starting after clear'}))\"";
|
||||
|
||||
await rig.setup(
|
||||
rig.setup(
|
||||
'should fire SessionEnd and SessionStart hooks on /clear command',
|
||||
{
|
||||
fakeResponsesPath: join(
|
||||
@@ -1099,8 +1320,10 @@ console.log(JSON.stringify({
|
||||
'hooks-system.session-clear.responses',
|
||||
),
|
||||
settings: {
|
||||
hooks: {
|
||||
hooksConfig: {
|
||||
enabled: true,
|
||||
},
|
||||
hooks: {
|
||||
SessionEnd: [
|
||||
{
|
||||
matcher: '*',
|
||||
@@ -1265,14 +1488,16 @@ console.log(JSON.stringify({
|
||||
const preCompressCommand =
|
||||
"node -e \"console.log(JSON.stringify({decision: 'allow', systemMessage: 'PreCompress hook executed for automatic compression'}))\"";
|
||||
|
||||
await rig.setup('should fire PreCompress hook on automatic compression', {
|
||||
rig.setup('should fire PreCompress hook on automatic compression', {
|
||||
fakeResponsesPath: join(
|
||||
import.meta.dirname,
|
||||
'hooks-system.compress-auto.responses',
|
||||
),
|
||||
settings: {
|
||||
hooks: {
|
||||
hooksConfig: {
|
||||
enabled: true,
|
||||
},
|
||||
hooks: {
|
||||
PreCompress: [
|
||||
{
|
||||
matcher: 'auto',
|
||||
@@ -1330,14 +1555,16 @@ console.log(JSON.stringify({
|
||||
const sessionEndCommand =
|
||||
"node -e \"console.log(JSON.stringify({decision: 'allow', systemMessage: 'SessionEnd hook executed on exit'}))\"";
|
||||
|
||||
await rig.setup('should fire SessionEnd hook on graceful exit', {
|
||||
rig.setup('should fire SessionEnd hook on graceful exit', {
|
||||
fakeResponsesPath: join(
|
||||
import.meta.dirname,
|
||||
'hooks-system.session-startup.responses',
|
||||
),
|
||||
settings: {
|
||||
hooks: {
|
||||
hooksConfig: {
|
||||
enabled: true,
|
||||
},
|
||||
hooks: {
|
||||
SessionEnd: [
|
||||
{
|
||||
matcher: 'exit',
|
||||
@@ -1412,7 +1639,7 @@ console.log(JSON.stringify({
|
||||
|
||||
describe('Hook Disabling', () => {
|
||||
it('should not execute hooks disabled in settings file', async () => {
|
||||
await rig.setup('should not execute hooks disabled in settings file', {
|
||||
rig.setup('should not execute hooks disabled in settings file', {
|
||||
fakeResponsesPath: join(
|
||||
import.meta.dirname,
|
||||
'hooks-system.disabled-via-settings.responses',
|
||||
@@ -1432,10 +1659,13 @@ console.log(JSON.stringify({decision: "block", systemMessage: "Disabled hook sho
|
||||
writeFileSync(enabledPath, enabledHookScript);
|
||||
writeFileSync(disabledPath, disabledHookScript);
|
||||
|
||||
await rig.setup('should not execute hooks disabled in settings file', {
|
||||
rig.setup('should not execute hooks disabled in settings file', {
|
||||
settings: {
|
||||
hooks: {
|
||||
hooksConfig: {
|
||||
enabled: true,
|
||||
disabled: [`node "${disabledPath}"`], // Disable the second hook
|
||||
},
|
||||
hooks: {
|
||||
BeforeTool: [
|
||||
{
|
||||
hooks: [
|
||||
@@ -1452,7 +1682,6 @@ console.log(JSON.stringify({decision: "block", systemMessage: "Disabled hook sho
|
||||
],
|
||||
},
|
||||
],
|
||||
disabled: [`node "${disabledPath}"`], // Disable the second hook
|
||||
},
|
||||
},
|
||||
});
|
||||
@@ -1487,15 +1716,12 @@ console.log(JSON.stringify({decision: "block", systemMessage: "Disabled hook sho
|
||||
});
|
||||
|
||||
it('should respect disabled hooks across multiple operations', async () => {
|
||||
await rig.setup(
|
||||
'should respect disabled hooks across multiple operations',
|
||||
{
|
||||
fakeResponsesPath: join(
|
||||
import.meta.dirname,
|
||||
'hooks-system.disabled-via-command.responses',
|
||||
),
|
||||
},
|
||||
);
|
||||
rig.setup('should respect disabled hooks across multiple operations', {
|
||||
fakeResponsesPath: join(
|
||||
import.meta.dirname,
|
||||
'hooks-system.disabled-via-command.responses',
|
||||
),
|
||||
});
|
||||
|
||||
// Create two hook scripts - one that will be disabled, one that won't
|
||||
const activeHookScript = `const fs = require('fs');
|
||||
@@ -1510,33 +1736,32 @@ console.log(JSON.stringify({decision: "block", systemMessage: "Disabled hook sho
|
||||
writeFileSync(activePath, activeHookScript);
|
||||
writeFileSync(disabledPath, disabledHookScript);
|
||||
|
||||
await rig.setup(
|
||||
'should respect disabled hooks across multiple operations',
|
||||
{
|
||||
settings: {
|
||||
hooks: {
|
||||
enabled: true,
|
||||
BeforeTool: [
|
||||
{
|
||||
hooks: [
|
||||
{
|
||||
type: 'command',
|
||||
command: `node "${activePath}"`,
|
||||
timeout: 5000,
|
||||
},
|
||||
{
|
||||
type: 'command',
|
||||
command: `node "${disabledPath}"`,
|
||||
timeout: 5000,
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
disabled: [`node "${disabledPath}"`], // Disable the second hook
|
||||
},
|
||||
rig.setup('should respect disabled hooks across multiple operations', {
|
||||
settings: {
|
||||
hooksConfig: {
|
||||
enabled: true,
|
||||
disabled: [`node "${disabledPath}"`], // Disable the second hook,
|
||||
},
|
||||
hooks: {
|
||||
BeforeTool: [
|
||||
{
|
||||
hooks: [
|
||||
{
|
||||
type: 'command',
|
||||
command: `node "${activePath}"`,
|
||||
timeout: 5000,
|
||||
},
|
||||
{
|
||||
type: 'command',
|
||||
command: `node "${disabledPath}"`,
|
||||
timeout: 5000,
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
// First run - only active hook should execute
|
||||
const result1 = await rig.run({
|
||||
@@ -1587,9 +1812,7 @@ console.log(JSON.stringify({decision: "block", systemMessage: "Disabled hook sho
|
||||
describe('BeforeTool Hooks - Input Override', () => {
|
||||
it('should override tool input parameters via BeforeTool hook', async () => {
|
||||
// 1. First setup to get the test directory and prepare the hook script
|
||||
await rig.setup(
|
||||
'should override tool input parameters via BeforeTool hook',
|
||||
);
|
||||
rig.setup('should override tool input parameters via BeforeTool hook');
|
||||
|
||||
// Create a hook script that overrides the tool input
|
||||
const hookOutput = {
|
||||
@@ -1616,32 +1839,31 @@ console.log(JSON.stringify({decision: "block", systemMessage: "Disabled hook sho
|
||||
const commandPath = scriptPath.replace(/\\/g, '/');
|
||||
|
||||
// 2. Full setup with settings and fake responses
|
||||
await rig.setup(
|
||||
'should override tool input parameters via BeforeTool hook',
|
||||
{
|
||||
fakeResponsesPath: join(
|
||||
import.meta.dirname,
|
||||
'hooks-system.input-modification.responses',
|
||||
),
|
||||
settings: {
|
||||
hooks: {
|
||||
enabled: true,
|
||||
BeforeTool: [
|
||||
{
|
||||
matcher: 'write_file',
|
||||
hooks: [
|
||||
{
|
||||
type: 'command',
|
||||
command: `node "${commandPath}"`,
|
||||
timeout: 5000,
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
rig.setup('should override tool input parameters via BeforeTool hook', {
|
||||
fakeResponsesPath: join(
|
||||
import.meta.dirname,
|
||||
'hooks-system.input-modification.responses',
|
||||
),
|
||||
settings: {
|
||||
hooksConfig: {
|
||||
enabled: true,
|
||||
},
|
||||
hooks: {
|
||||
BeforeTool: [
|
||||
{
|
||||
matcher: 'write_file',
|
||||
hooks: [
|
||||
{
|
||||
type: 'command',
|
||||
command: `node "${commandPath}"`,
|
||||
timeout: 5000,
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
// Run the agent. The fake response will attempt to call write_file with
|
||||
// file_path="original.txt" and content="original content"
|
||||
@@ -1698,19 +1920,21 @@ console.log(JSON.stringify({decision: "block", systemMessage: "Disabled hook sho
|
||||
hookOutput,
|
||||
)}));`;
|
||||
|
||||
await rig.setup('should stop agent execution via BeforeTool hook');
|
||||
rig.setup('should stop agent execution via BeforeTool hook');
|
||||
const scriptPath = join(rig.testDir!, 'before_tool_stop_hook.js');
|
||||
writeFileSync(scriptPath, hookScript);
|
||||
const commandPath = scriptPath.replace(/\\/g, '/');
|
||||
|
||||
await rig.setup('should stop agent execution via BeforeTool hook', {
|
||||
rig.setup('should stop agent execution via BeforeTool hook', {
|
||||
fakeResponsesPath: join(
|
||||
import.meta.dirname,
|
||||
'hooks-system.before-tool-stop.responses',
|
||||
),
|
||||
settings: {
|
||||
hooks: {
|
||||
hooksConfig: {
|
||||
enabled: true,
|
||||
},
|
||||
hooks: {
|
||||
BeforeTool: [
|
||||
{
|
||||
matcher: 'write_file',
|
||||
|
||||
@@ -164,7 +164,7 @@ describe('run_shell_command', () => {
|
||||
const result = await rig.run({
|
||||
args: [`--allowed-tools=run_shell_command(${tool})`],
|
||||
stdin: prompt,
|
||||
yolo: false,
|
||||
approvalMode: 'default',
|
||||
});
|
||||
|
||||
const foundToolCall = await rig.waitForToolCall('run_shell_command', 15000);
|
||||
@@ -207,7 +207,7 @@ describe('run_shell_command', () => {
|
||||
const result = await rig.run({
|
||||
args: '--allowed-tools=run_shell_command',
|
||||
stdin: prompt,
|
||||
yolo: false,
|
||||
approvalMode: 'default',
|
||||
});
|
||||
|
||||
const foundToolCall = await rig.waitForToolCall('run_shell_command', 15000);
|
||||
@@ -231,8 +231,8 @@ describe('run_shell_command', () => {
|
||||
expect(toolCall.toolRequest.success).toBe(true);
|
||||
});
|
||||
|
||||
it('should succeed with --yolo mode', async () => {
|
||||
await rig.setup('should succeed with --yolo mode', {
|
||||
it('should succeed in yolo mode', async () => {
|
||||
await rig.setup('should succeed in yolo mode', {
|
||||
settings: { tools: { core: ['run_shell_command'] } },
|
||||
});
|
||||
|
||||
@@ -242,7 +242,7 @@ describe('run_shell_command', () => {
|
||||
|
||||
const result = await rig.run({
|
||||
args: prompt,
|
||||
yolo: true,
|
||||
approvalMode: 'yolo',
|
||||
});
|
||||
|
||||
const foundToolCall = await rig.waitForToolCall('run_shell_command', 15000);
|
||||
@@ -276,7 +276,7 @@ describe('run_shell_command', () => {
|
||||
const result = await rig.run({
|
||||
args: `--allowed-tools=ShellTool(${tool})`,
|
||||
stdin: prompt,
|
||||
yolo: false,
|
||||
approvalMode: 'default',
|
||||
});
|
||||
|
||||
const foundToolCall = await rig.waitForToolCall('run_shell_command', 15000);
|
||||
@@ -325,7 +325,7 @@ describe('run_shell_command', () => {
|
||||
'--allowed-tools=run_shell_command(ls)',
|
||||
],
|
||||
stdin: prompt,
|
||||
yolo: false,
|
||||
approvalMode: 'default',
|
||||
});
|
||||
|
||||
for (const expected in ['ls', tool]) {
|
||||
@@ -377,7 +377,7 @@ describe('run_shell_command', () => {
|
||||
const result = await rig.run({
|
||||
args: `--allowed-tools=run_shell_command(${allowedCommand})`,
|
||||
stdin: prompt,
|
||||
yolo: false,
|
||||
approvalMode: 'default',
|
||||
});
|
||||
|
||||
if (!result.toLowerCase().includes('fail')) {
|
||||
@@ -438,7 +438,7 @@ describe('run_shell_command', () => {
|
||||
await rig.run({
|
||||
args: `--allowed-tools=ShellTool(${chained.allowPattern})`,
|
||||
stdin: `${shellInjection}\n`,
|
||||
yolo: false,
|
||||
approvalMode: 'default',
|
||||
});
|
||||
|
||||
// CLI should refuse to execute the chained command without scheduling run_shell_command.
|
||||
@@ -470,7 +470,7 @@ describe('run_shell_command', () => {
|
||||
'--allowed-tools=run_shell_command',
|
||||
],
|
||||
stdin: prompt,
|
||||
yolo: false,
|
||||
approvalMode: 'default',
|
||||
});
|
||||
|
||||
const foundToolCall = await rig.waitForToolCall('run_shell_command', 15000);
|
||||
|
||||
Generated
+22
-33
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "@google/gemini-cli",
|
||||
"version": "0.26.0-nightly.20260115.6cb3ae4e0",
|
||||
"version": "0.27.0-nightly.20260121.97aac696f",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "@google/gemini-cli",
|
||||
"version": "0.26.0-nightly.20260115.6cb3ae4e0",
|
||||
"version": "0.27.0-nightly.20260121.97aac696f",
|
||||
"workspaces": [
|
||||
"packages/*"
|
||||
],
|
||||
@@ -2474,7 +2474,6 @@
|
||||
"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",
|
||||
@@ -2655,7 +2654,6 @@
|
||||
"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"
|
||||
}
|
||||
@@ -2689,7 +2687,6 @@
|
||||
"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"
|
||||
},
|
||||
@@ -3058,7 +3055,6 @@
|
||||
"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"
|
||||
@@ -3092,7 +3088,6 @@
|
||||
"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"
|
||||
@@ -3145,7 +3140,6 @@
|
||||
"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",
|
||||
@@ -4358,7 +4352,6 @@
|
||||
"integrity": "sha512-6mDvHUFSjyT2B2yeNx2nUgMxh9LtOWvkhIU3uePn2I2oyNymUAX1NIsdgviM4CH+JSrp2D2hsMvJOkxY+0wNRA==",
|
||||
"devOptional": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"csstype": "^3.0.2"
|
||||
}
|
||||
@@ -4636,7 +4629,6 @@
|
||||
"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",
|
||||
@@ -5641,7 +5633,6 @@
|
||||
"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"
|
||||
},
|
||||
@@ -6086,7 +6077,8 @@
|
||||
"version": "1.1.1",
|
||||
"resolved": "https://registry.npmjs.org/array-flatten/-/array-flatten-1.1.1.tgz",
|
||||
"integrity": "sha512-PCVAQswWemu6UdxsDFFX/+gVeYqKAod3D3UVm91jHwynguOwAvYPhx8nNlM++NqRcK6CxxpUafjmhIdKiHibqg==",
|
||||
"license": "MIT"
|
||||
"license": "MIT",
|
||||
"peer": true
|
||||
},
|
||||
"node_modules/array-includes": {
|
||||
"version": "3.1.9",
|
||||
@@ -7370,6 +7362,7 @@
|
||||
"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"
|
||||
},
|
||||
@@ -8689,7 +8682,6 @@
|
||||
"integrity": "sha512-GsGizj2Y1rCWDu6XoEekL3RLilp0voSePurjZIkxL3wlm5o5EC9VpgaP7lrCvjnkuLvzFBQWB3vWB3K5KQTveQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@eslint-community/eslint-utils": "^4.2.0",
|
||||
"@eslint-community/regexpp": "^4.12.1",
|
||||
@@ -9292,6 +9284,7 @@
|
||||
"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"
|
||||
}
|
||||
@@ -9301,6 +9294,7 @@
|
||||
"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"
|
||||
}
|
||||
@@ -9310,6 +9304,7 @@
|
||||
"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"
|
||||
}
|
||||
@@ -9563,6 +9558,7 @@
|
||||
"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",
|
||||
@@ -9581,6 +9577,7 @@
|
||||
"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"
|
||||
}
|
||||
@@ -9589,13 +9586,15 @@
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz",
|
||||
"integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==",
|
||||
"license": "MIT"
|
||||
"license": "MIT",
|
||||
"peer": true
|
||||
},
|
||||
"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"
|
||||
}
|
||||
@@ -10878,7 +10877,6 @@
|
||||
"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",
|
||||
@@ -14063,7 +14061,8 @@
|
||||
"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"
|
||||
"license": "MIT",
|
||||
"peer": true
|
||||
},
|
||||
"node_modules/path-type": {
|
||||
"version": "3.0.0",
|
||||
@@ -14640,7 +14639,6 @@
|
||||
"resolved": "https://registry.npmjs.org/react/-/react-19.2.0.tgz",
|
||||
"integrity": "sha512-tmbWg6W31tQLeB5cdIBOicJDJRR2KzXsV7uSK9iNfLWQ5bIZfxuPEHp7M8wiHyHnn0DD1i7w3Zmin0FtkrwoCQ==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
@@ -14651,7 +14649,6 @@
|
||||
"integrity": "sha512-ePrwPfxAnB+7hgnEr8vpKxL9cmnp7F322t8oqcPshbIQQhDKgFDW4tjhF2wjVbdXF9O/nyuy3sQWd9JGpiLPvA==",
|
||||
"devOptional": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"shell-quote": "^1.6.1",
|
||||
"ws": "^7"
|
||||
@@ -16911,7 +16908,6 @@
|
||||
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz",
|
||||
"integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
},
|
||||
@@ -17135,8 +17131,7 @@
|
||||
"resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz",
|
||||
"integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==",
|
||||
"dev": true,
|
||||
"license": "0BSD",
|
||||
"peer": true
|
||||
"license": "0BSD"
|
||||
},
|
||||
"node_modules/tsx": {
|
||||
"version": "4.20.3",
|
||||
@@ -17144,7 +17139,6 @@
|
||||
"integrity": "sha512-qjbnuR9Tr+FJOMBqJCW5ehvIo/buZq7vH7qD7JziU98h6l3qGy0a/yPFjwO+y0/T7GFpNgNAvEcPPVfyT8rrPQ==",
|
||||
"devOptional": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"esbuild": "~0.25.0",
|
||||
"get-tsconfig": "^4.7.5"
|
||||
@@ -17328,7 +17322,6 @@
|
||||
"integrity": "sha512-p1diW6TqL9L07nNxvRMM7hMMw4c5XOo/1ibL4aAIGmSAt9slTE1Xgw5KWuof2uTOvCg9BY7ZRi+GaF+7sfgPeQ==",
|
||||
"devOptional": true,
|
||||
"license": "Apache-2.0",
|
||||
"peer": true,
|
||||
"bin": {
|
||||
"tsc": "bin/tsc",
|
||||
"tsserver": "bin/tsserver"
|
||||
@@ -17491,6 +17484,7 @@
|
||||
"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"
|
||||
}
|
||||
@@ -17545,7 +17539,6 @@
|
||||
"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",
|
||||
@@ -17659,7 +17652,6 @@
|
||||
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz",
|
||||
"integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
},
|
||||
@@ -17672,7 +17664,6 @@
|
||||
"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",
|
||||
@@ -18377,7 +18368,6 @@
|
||||
"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"
|
||||
}
|
||||
@@ -18393,7 +18383,7 @@
|
||||
},
|
||||
"packages/a2a-server": {
|
||||
"name": "@google/gemini-cli-a2a-server",
|
||||
"version": "0.26.0-nightly.20260115.6cb3ae4e0",
|
||||
"version": "0.27.0-nightly.20260121.97aac696f",
|
||||
"dependencies": {
|
||||
"@a2a-js/sdk": "^0.3.8",
|
||||
"@google-cloud/storage": "^7.16.0",
|
||||
@@ -18703,7 +18693,7 @@
|
||||
},
|
||||
"packages/cli": {
|
||||
"name": "@google/gemini-cli",
|
||||
"version": "0.26.0-nightly.20260115.6cb3ae4e0",
|
||||
"version": "0.27.0-nightly.20260121.97aac696f",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"@agentclientprotocol/sdk": "^0.12.0",
|
||||
@@ -18807,7 +18797,7 @@
|
||||
},
|
||||
"packages/core": {
|
||||
"name": "@google/gemini-cli-core",
|
||||
"version": "0.26.0-nightly.20260115.6cb3ae4e0",
|
||||
"version": "0.27.0-nightly.20260121.97aac696f",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"@a2a-js/sdk": "^0.3.8",
|
||||
@@ -18944,7 +18934,6 @@
|
||||
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz",
|
||||
"integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
},
|
||||
@@ -18967,7 +18956,7 @@
|
||||
},
|
||||
"packages/test-utils": {
|
||||
"name": "@google/gemini-cli-test-utils",
|
||||
"version": "0.26.0-nightly.20260115.6cb3ae4e0",
|
||||
"version": "0.27.0-nightly.20260121.97aac696f",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"@google/gemini-cli-core": "file:../core",
|
||||
@@ -18984,7 +18973,7 @@
|
||||
},
|
||||
"packages/vscode-ide-companion": {
|
||||
"name": "gemini-cli-vscode-ide-companion",
|
||||
"version": "0.26.0-nightly.20260115.6cb3ae4e0",
|
||||
"version": "0.27.0-nightly.20260121.97aac696f",
|
||||
"license": "LICENSE",
|
||||
"dependencies": {
|
||||
"@modelcontextprotocol/sdk": "^1.23.0",
|
||||
|
||||
+2
-2
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@google/gemini-cli",
|
||||
"version": "0.26.0-nightly.20260115.6cb3ae4e0",
|
||||
"version": "0.27.0-nightly.20260121.97aac696f",
|
||||
"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.20260115.6cb3ae4e0"
|
||||
"sandboxImageUri": "us-docker.pkg.dev/gemini-code-dev/gemini-cli/sandbox:0.27.0-nightly.20260121.97aac696f"
|
||||
},
|
||||
"scripts": {
|
||||
"start": "cross-env NODE_ENV=development node scripts/start.js",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@google/gemini-cli-a2a-server",
|
||||
"version": "0.26.0-nightly.20260115.6cb3ae4e0",
|
||||
"version": "0.27.0-nightly.20260121.97aac696f",
|
||||
"description": "Gemini CLI A2A Server",
|
||||
"repository": {
|
||||
"type": "git",
|
||||
|
||||
@@ -575,7 +575,10 @@ export class Task {
|
||||
EDIT_TOOL_NAMES.has(request.name),
|
||||
);
|
||||
|
||||
if (restorableToolCalls.length > 0) {
|
||||
if (
|
||||
restorableToolCalls.length > 0 &&
|
||||
this.config.getCheckpointingEnabled()
|
||||
) {
|
||||
const gitService = await this.config.getGitService();
|
||||
if (gitService) {
|
||||
const { checkpointsToWrite, toolCallToCheckpointMap, errors } =
|
||||
|
||||
@@ -0,0 +1,110 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
||||
import { loadConfig } from './config.js';
|
||||
import type { ExtensionLoader } from '@google/gemini-cli-core';
|
||||
import type { Settings } from './settings.js';
|
||||
|
||||
const {
|
||||
mockLoadServerHierarchicalMemory,
|
||||
mockConfigConstructor,
|
||||
mockVerifyGitAvailability,
|
||||
} = vi.hoisted(() => ({
|
||||
mockLoadServerHierarchicalMemory: vi.fn().mockResolvedValue({
|
||||
memoryContent: '',
|
||||
fileCount: 0,
|
||||
filePaths: [],
|
||||
}),
|
||||
mockConfigConstructor: vi.fn(),
|
||||
mockVerifyGitAvailability: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('@google/gemini-cli-core', async () => ({
|
||||
Config: class MockConfig {
|
||||
constructor(params: unknown) {
|
||||
mockConfigConstructor(params);
|
||||
}
|
||||
initialize = vi.fn();
|
||||
refreshAuth = vi.fn();
|
||||
},
|
||||
loadServerHierarchicalMemory: mockLoadServerHierarchicalMemory,
|
||||
startupProfiler: {
|
||||
flush: vi.fn(),
|
||||
},
|
||||
FileDiscoveryService: vi.fn(),
|
||||
ApprovalMode: { DEFAULT: 'default', YOLO: 'yolo' },
|
||||
AuthType: {
|
||||
LOGIN_WITH_GOOGLE: 'login_with_google',
|
||||
USE_GEMINI: 'use_gemini',
|
||||
},
|
||||
GEMINI_DIR: '.gemini',
|
||||
DEFAULT_GEMINI_EMBEDDING_MODEL: 'models/embedding-001',
|
||||
DEFAULT_GEMINI_MODEL: 'models/gemini-1.5-flash',
|
||||
PREVIEW_GEMINI_MODEL: 'models/gemini-1.5-pro-latest',
|
||||
homedir: () => '/tmp',
|
||||
GitService: {
|
||||
verifyGitAvailability: mockVerifyGitAvailability,
|
||||
},
|
||||
}));
|
||||
|
||||
describe('loadConfig', () => {
|
||||
const mockSettings = {
|
||||
checkpointing: { enabled: true },
|
||||
};
|
||||
const mockExtensionLoader = {
|
||||
start: vi.fn(),
|
||||
getExtensions: vi.fn().mockReturnValue([]),
|
||||
} as unknown as ExtensionLoader;
|
||||
|
||||
beforeEach(() => {
|
||||
vi.resetAllMocks();
|
||||
process.env['GEMINI_API_KEY'] = 'test-key';
|
||||
// Reset the mock return value just in case
|
||||
mockLoadServerHierarchicalMemory.mockResolvedValue({
|
||||
memoryContent: '',
|
||||
fileCount: 0,
|
||||
filePaths: [],
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
delete process.env['GEMINI_API_KEY'];
|
||||
delete process.env['CHECKPOINTING'];
|
||||
});
|
||||
|
||||
it('should disable checkpointing if git is not installed', async () => {
|
||||
mockVerifyGitAvailability.mockResolvedValue(false);
|
||||
|
||||
await loadConfig(
|
||||
mockSettings as unknown as Settings,
|
||||
mockExtensionLoader,
|
||||
'test-task',
|
||||
);
|
||||
|
||||
expect(mockConfigConstructor).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
checkpointing: false,
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('should enable checkpointing if git is installed', async () => {
|
||||
mockVerifyGitAvailability.mockResolvedValue(true);
|
||||
|
||||
await loadConfig(
|
||||
mockSettings as unknown as Settings,
|
||||
mockExtensionLoader,
|
||||
'test-task',
|
||||
);
|
||||
|
||||
expect(mockConfigConstructor).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
checkpointing: true,
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -23,6 +23,7 @@ import {
|
||||
startupProfiler,
|
||||
PREVIEW_GEMINI_MODEL,
|
||||
homedir,
|
||||
GitService,
|
||||
} from '@google/gemini-cli-core';
|
||||
|
||||
import { logger } from '../utils/logger.js';
|
||||
@@ -41,6 +42,19 @@ export async function loadConfig(
|
||||
settings.folderTrust === true ||
|
||||
process.env['GEMINI_FOLDER_TRUST'] === 'true';
|
||||
|
||||
let checkpointing = process.env['CHECKPOINTING']
|
||||
? process.env['CHECKPOINTING'] === 'true'
|
||||
: settings.checkpointing?.enabled;
|
||||
|
||||
if (checkpointing) {
|
||||
if (!(await GitService.verifyGitAvailability())) {
|
||||
logger.warn(
|
||||
'[Config] Checkpointing is enabled but git is not installed. Disabling checkpointing.',
|
||||
);
|
||||
checkpointing = false;
|
||||
}
|
||||
}
|
||||
|
||||
const configParams: ConfigParameters = {
|
||||
sessionId: taskId,
|
||||
model: settings.general?.previewFeatures
|
||||
@@ -79,9 +93,7 @@ export async function loadConfig(
|
||||
folderTrust,
|
||||
trustedFolder: true,
|
||||
extensionLoader,
|
||||
checkpointing: process.env['CHECKPOINTING']
|
||||
? process.env['CHECKPOINTING'] === 'true'
|
||||
: settings.checkpointing?.enabled,
|
||||
checkpointing,
|
||||
previewFeatures: settings.general?.previewFeatures,
|
||||
interactive: true,
|
||||
enableInteractiveShell: true,
|
||||
|
||||
@@ -11,6 +11,30 @@ import { FatalError, writeToStderr } from '@google/gemini-cli-core';
|
||||
import { runExitCleanup } from './src/utils/cleanup.js';
|
||||
|
||||
// --- Global Entry Point ---
|
||||
|
||||
// Suppress known race condition error in node-pty on Windows
|
||||
// Tracking bug: https://github.com/microsoft/node-pty/issues/827
|
||||
process.on('uncaughtException', (error) => {
|
||||
if (
|
||||
process.platform === 'win32' &&
|
||||
error instanceof Error &&
|
||||
error.message === 'Cannot resize a pty that has already exited'
|
||||
) {
|
||||
// This error happens on Windows with node-pty when resizing a pty that has just exited.
|
||||
// It is a race condition in node-pty that we cannot prevent, so we silence it.
|
||||
return;
|
||||
}
|
||||
|
||||
// For other errors, we rely on the default behavior, but since we attached a listener,
|
||||
// we must manually replicate it.
|
||||
if (error instanceof Error) {
|
||||
writeToStderr(error.stack + '\n');
|
||||
} else {
|
||||
writeToStderr(String(error) + '\n');
|
||||
}
|
||||
process.exit(1);
|
||||
});
|
||||
|
||||
main().catch(async (error) => {
|
||||
await runExitCleanup();
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@google/gemini-cli",
|
||||
"version": "0.26.0-nightly.20260115.6cb3ae4e0",
|
||||
"version": "0.27.0-nightly.20260121.97aac696f",
|
||||
"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.20260115.6cb3ae4e0"
|
||||
"sandboxImageUri": "us-docker.pkg.dev/gemini-code-dev/gemini-cli/sandbox:0.27.0-nightly.20260121.97aac696f"
|
||||
},
|
||||
"dependencies": {
|
||||
"@agentclientprotocol/sdk": "^0.12.0",
|
||||
|
||||
@@ -54,15 +54,33 @@ describe('extensionsCommand', () => {
|
||||
extensionsCommand.builder(mockYargs);
|
||||
|
||||
expect(mockYargs.middleware).toHaveBeenCalled();
|
||||
expect(mockYargs.command).toHaveBeenCalledWith({ command: 'install' });
|
||||
expect(mockYargs.command).toHaveBeenCalledWith({ command: 'uninstall' });
|
||||
expect(mockYargs.command).toHaveBeenCalledWith({ command: 'list' });
|
||||
expect(mockYargs.command).toHaveBeenCalledWith({ command: 'update' });
|
||||
expect(mockYargs.command).toHaveBeenCalledWith({ command: 'disable' });
|
||||
expect(mockYargs.command).toHaveBeenCalledWith({ command: 'enable' });
|
||||
expect(mockYargs.command).toHaveBeenCalledWith({ command: 'link' });
|
||||
expect(mockYargs.command).toHaveBeenCalledWith({ command: 'new' });
|
||||
expect(mockYargs.command).toHaveBeenCalledWith({ command: 'validate' });
|
||||
expect(mockYargs.command).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ command: 'install' }),
|
||||
);
|
||||
expect(mockYargs.command).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ command: 'uninstall' }),
|
||||
);
|
||||
expect(mockYargs.command).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ command: 'list' }),
|
||||
);
|
||||
expect(mockYargs.command).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ command: 'update' }),
|
||||
);
|
||||
expect(mockYargs.command).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ command: 'disable' }),
|
||||
);
|
||||
expect(mockYargs.command).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ command: 'enable' }),
|
||||
);
|
||||
expect(mockYargs.command).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ command: 'link' }),
|
||||
);
|
||||
expect(mockYargs.command).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ command: 'new' }),
|
||||
);
|
||||
expect(mockYargs.command).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ command: 'validate' }),
|
||||
);
|
||||
expect(mockYargs.demandCommand).toHaveBeenCalledWith(1, expect.any(String));
|
||||
expect(mockYargs.version).toHaveBeenCalledWith(false);
|
||||
});
|
||||
|
||||
@@ -16,6 +16,7 @@ import { newCommand } from './extensions/new.js';
|
||||
import { validateCommand } from './extensions/validate.js';
|
||||
import { configureCommand } from './extensions/configure.js';
|
||||
import { initializeOutputListenersAndFlush } from '../gemini.js';
|
||||
import { defer } from '../deferred.js';
|
||||
|
||||
export const extensionsCommand: CommandModule = {
|
||||
command: 'extensions <command>',
|
||||
@@ -23,17 +24,20 @@ export const extensionsCommand: CommandModule = {
|
||||
describe: 'Manage Gemini CLI extensions.',
|
||||
builder: (yargs) =>
|
||||
yargs
|
||||
.middleware(() => initializeOutputListenersAndFlush())
|
||||
.command(installCommand)
|
||||
.command(uninstallCommand)
|
||||
.command(listCommand)
|
||||
.command(updateCommand)
|
||||
.command(disableCommand)
|
||||
.command(enableCommand)
|
||||
.command(linkCommand)
|
||||
.command(newCommand)
|
||||
.command(validateCommand)
|
||||
.command(configureCommand)
|
||||
.middleware((argv) => {
|
||||
initializeOutputListenersAndFlush();
|
||||
argv['isCommand'] = true;
|
||||
})
|
||||
.command(defer(installCommand, 'extensions'))
|
||||
.command(defer(uninstallCommand, 'extensions'))
|
||||
.command(defer(listCommand, 'extensions'))
|
||||
.command(defer(updateCommand, 'extensions'))
|
||||
.command(defer(disableCommand, 'extensions'))
|
||||
.command(defer(enableCommand, 'extensions'))
|
||||
.command(defer(linkCommand, 'extensions'))
|
||||
.command(defer(newCommand, 'extensions'))
|
||||
.command(defer(validateCommand, 'extensions'))
|
||||
.command(defer(configureCommand, 'extensions'))
|
||||
.demandCommand(1, 'You need at least one command before continuing.')
|
||||
.version(false),
|
||||
handler: () => {
|
||||
|
||||
@@ -0,0 +1,4 @@
|
||||
{
|
||||
"name": "hooks-example",
|
||||
"version": "1.0.0"
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
{
|
||||
"hooks": {
|
||||
"SessionStart": [
|
||||
{
|
||||
"hooks": [
|
||||
{
|
||||
"type": "command",
|
||||
"command": "node ${extensionPath}/scripts/on-start.js"
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2026 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
console.log(
|
||||
'Session Started! This is running from a script in the hooks-example extension.',
|
||||
);
|
||||
@@ -0,0 +1,35 @@
|
||||
# MCP Server Example
|
||||
|
||||
This is a basic example of an MCP (Model Context Protocol) server used as a
|
||||
Gemini CLI extension. It demonstrates how to expose tools and prompts to the
|
||||
Gemini CLI.
|
||||
|
||||
## Description
|
||||
|
||||
The contents of this directory are a valid MCP server implementation using the
|
||||
`@modelcontextprotocol/sdk`. It exposes:
|
||||
|
||||
- A tool `fetch_posts` that mock-fetches posts.
|
||||
- A prompt `poem-writer`.
|
||||
|
||||
## Structure
|
||||
|
||||
- `example.js`: The main server entry point.
|
||||
- `gemini-extension.json`: The configuration file that tells Gemini CLI how to
|
||||
use this extension.
|
||||
- `package.json`: Helper for dependencies.
|
||||
|
||||
## How to Use
|
||||
|
||||
1. Navigate to this directory:
|
||||
|
||||
```bash
|
||||
cd packages/cli/src/commands/extensions/examples/mcp-server
|
||||
```
|
||||
|
||||
2. Install dependencies:
|
||||
```bash
|
||||
npm install
|
||||
```
|
||||
|
||||
This example is typically used by `gemini extensions new`.
|
||||
@@ -1,135 +0,0 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
||||
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
|
||||
import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
|
||||
import { z } from 'zod';
|
||||
|
||||
// Mock the MCP server and transport
|
||||
const mockRegisterTool = vi.fn();
|
||||
const mockRegisterPrompt = vi.fn();
|
||||
const mockConnect = vi.fn();
|
||||
|
||||
vi.mock('@modelcontextprotocol/sdk/server/mcp.js', () => ({
|
||||
McpServer: vi.fn().mockImplementation(() => ({
|
||||
registerTool: mockRegisterTool,
|
||||
registerPrompt: mockRegisterPrompt,
|
||||
connect: mockConnect,
|
||||
})),
|
||||
}));
|
||||
|
||||
vi.mock('@modelcontextprotocol/sdk/server/stdio.js', () => ({
|
||||
StdioServerTransport: vi.fn(),
|
||||
}));
|
||||
|
||||
describe('MCP Server Example', () => {
|
||||
beforeEach(async () => {
|
||||
// Dynamically import the server setup after mocks are in place
|
||||
await import('./example.js');
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.clearAllMocks();
|
||||
vi.resetModules();
|
||||
});
|
||||
|
||||
it('should create an McpServer with the correct name and version', () => {
|
||||
expect(McpServer).toHaveBeenCalledWith({
|
||||
name: 'prompt-server',
|
||||
version: '1.0.0',
|
||||
});
|
||||
});
|
||||
|
||||
it('should register the "fetch_posts" tool', () => {
|
||||
expect(mockRegisterTool).toHaveBeenCalledWith(
|
||||
'fetch_posts',
|
||||
{
|
||||
description: 'Fetches a list of posts from a public API.',
|
||||
inputSchema: z.object({}).shape,
|
||||
},
|
||||
expect.any(Function),
|
||||
);
|
||||
});
|
||||
|
||||
it('should register the "poem-writer" prompt', () => {
|
||||
expect(mockRegisterPrompt).toHaveBeenCalledWith(
|
||||
'poem-writer',
|
||||
{
|
||||
title: 'Poem Writer',
|
||||
description: 'Write a nice haiku',
|
||||
argsSchema: expect.any(Object),
|
||||
},
|
||||
expect.any(Function),
|
||||
);
|
||||
});
|
||||
|
||||
it('should connect the server to an StdioServerTransport', () => {
|
||||
expect(StdioServerTransport).toHaveBeenCalled();
|
||||
expect(mockConnect).toHaveBeenCalledWith(expect.any(StdioServerTransport));
|
||||
});
|
||||
|
||||
describe('fetch_posts tool implementation', () => {
|
||||
it('should fetch posts and return a formatted response', async () => {
|
||||
const mockPosts = [
|
||||
{ id: 1, title: 'Post 1' },
|
||||
{ id: 2, title: 'Post 2' },
|
||||
];
|
||||
global.fetch = vi.fn().mockResolvedValue({
|
||||
json: vi.fn().mockResolvedValue(mockPosts),
|
||||
});
|
||||
|
||||
const toolFn = mockRegisterTool.mock.calls[0][2];
|
||||
const result = await toolFn();
|
||||
|
||||
expect(global.fetch).toHaveBeenCalledWith(
|
||||
'https://jsonplaceholder.typicode.com/posts',
|
||||
);
|
||||
expect(result).toEqual({
|
||||
content: [
|
||||
{
|
||||
type: 'text',
|
||||
text: JSON.stringify({ posts: mockPosts }),
|
||||
},
|
||||
],
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('poem-writer prompt implementation', () => {
|
||||
it('should generate a prompt with a title', () => {
|
||||
const promptFn = mockRegisterPrompt.mock.calls[0][2];
|
||||
const result = promptFn({ title: 'My Poem' });
|
||||
expect(result).toEqual({
|
||||
messages: [
|
||||
{
|
||||
role: 'user',
|
||||
content: {
|
||||
type: 'text',
|
||||
text: 'Write a haiku called My Poem. Note that a haiku is 5 syllables followed by 7 syllables followed by 5 syllables ',
|
||||
},
|
||||
},
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
it('should generate a prompt with a title and mood', () => {
|
||||
const promptFn = mockRegisterPrompt.mock.calls[0][2];
|
||||
const result = promptFn({ title: 'My Poem', mood: 'sad' });
|
||||
expect(result).toEqual({
|
||||
messages: [
|
||||
{
|
||||
role: 'user',
|
||||
content: {
|
||||
type: 'text',
|
||||
text: 'Write a haiku with the mood sad called My Poem. Note that a haiku is 5 syllables followed by 7 syllables followed by 5 syllables ',
|
||||
},
|
||||
},
|
||||
],
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -4,7 +4,7 @@
|
||||
"mcpServers": {
|
||||
"nodeServer": {
|
||||
"command": "node",
|
||||
"args": ["${extensionPath}${/}dist${/}example.js"],
|
||||
"args": ["${extensionPath}${/}example.js"],
|
||||
"cwd": "${extensionPath}"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,13 +4,6 @@
|
||||
"description": "Example MCP Server for Gemini CLI Extension",
|
||||
"type": "module",
|
||||
"main": "example.js",
|
||||
"scripts": {
|
||||
"build": "tsc"
|
||||
},
|
||||
"devDependencies": {
|
||||
"typescript": "~5.4.5",
|
||||
"@types/node": "^20.11.25"
|
||||
},
|
||||
"dependencies": {
|
||||
"@modelcontextprotocol/sdk": "^1.23.0",
|
||||
"zod": "^3.22.4"
|
||||
|
||||
@@ -1,13 +0,0 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2022",
|
||||
"module": "NodeNext",
|
||||
"moduleResolution": "NodeNext",
|
||||
"strict": true,
|
||||
"esModuleInterop": true,
|
||||
"skipLibCheck": true,
|
||||
"forceConsistentCasingInFileNames": true,
|
||||
"outDir": "./dist"
|
||||
},
|
||||
"include": ["example.ts"]
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
{
|
||||
"name": "skills-example",
|
||||
"version": "1.0.0"
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
---
|
||||
name: greeter
|
||||
description: A friendly greeter skill
|
||||
---
|
||||
|
||||
You are a friendly greeter. When the user says "hello" or asks for a greeting,
|
||||
you should reply with: "Greetings from the skills-example extension! 👋"
|
||||
@@ -14,7 +14,10 @@ export const hooksCommand: CommandModule = {
|
||||
describe: 'Manage Gemini CLI hooks.',
|
||||
builder: (yargs) =>
|
||||
yargs
|
||||
.middleware(() => initializeOutputListenersAndFlush())
|
||||
.middleware((argv) => {
|
||||
initializeOutputListenersAndFlush();
|
||||
argv['isCommand'] = true;
|
||||
})
|
||||
.command(migrateCommand)
|
||||
.demandCommand(1, 'You need at least one command before continuing.')
|
||||
.version(false),
|
||||
|
||||
@@ -511,8 +511,5 @@ describe('migrate command', () => {
|
||||
expect(debugLoggerLogSpy).toHaveBeenCalledWith(
|
||||
'\nMigration complete! Please review the migrated hooks in .gemini/settings.json',
|
||||
);
|
||||
expect(debugLoggerLogSpy).toHaveBeenCalledWith(
|
||||
'Note: Set hooks.enabled to true in your settings to enable the hook system.',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -230,7 +230,10 @@ 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)
|
||||
@@ -241,9 +244,6 @@ export async function handleMigrateFromClaude() {
|
||||
debugLogger.log(
|
||||
'\nMigration complete! Please review the migrated hooks in .gemini/settings.json',
|
||||
);
|
||||
debugLogger.log(
|
||||
'Note: Set hooks.enabled to true in your settings to enable the hook system.',
|
||||
);
|
||||
} catch (error) {
|
||||
debugLogger.error(`Error saving migrated hooks: ${getErrorMessage(error)}`);
|
||||
}
|
||||
|
||||
@@ -10,16 +10,20 @@ import { addCommand } from './mcp/add.js';
|
||||
import { removeCommand } from './mcp/remove.js';
|
||||
import { listCommand } from './mcp/list.js';
|
||||
import { initializeOutputListenersAndFlush } from '../gemini.js';
|
||||
import { defer } from '../deferred.js';
|
||||
|
||||
export const mcpCommand: CommandModule = {
|
||||
command: 'mcp',
|
||||
describe: 'Manage MCP servers',
|
||||
builder: (yargs: Argv) =>
|
||||
yargs
|
||||
.middleware(() => initializeOutputListenersAndFlush())
|
||||
.command(addCommand)
|
||||
.command(removeCommand)
|
||||
.command(listCommand)
|
||||
.middleware((argv) => {
|
||||
initializeOutputListenersAndFlush();
|
||||
argv['isCommand'] = true;
|
||||
})
|
||||
.command(defer(addCommand, 'mcp'))
|
||||
.command(defer(removeCommand, 'mcp'))
|
||||
.command(defer(listCommand, 'mcp'))
|
||||
.demandCommand(1, 'You need at least one command before continuing.')
|
||||
.version(false),
|
||||
handler: () => {
|
||||
|
||||
@@ -123,8 +123,13 @@ describe('mcp list command', () => {
|
||||
...defaultMergedSettings,
|
||||
mcpServers: {
|
||||
'stdio-server': { command: '/path/to/server', args: ['arg1'] },
|
||||
'sse-server': { url: 'https://example.com/sse' },
|
||||
'sse-server': { url: 'https://example.com/sse', type: 'sse' },
|
||||
'http-server': { httpUrl: 'https://example.com/http' },
|
||||
'http-server-by-default': { url: 'https://example.com/http' },
|
||||
'http-server-with-type': {
|
||||
url: 'https://example.com/http',
|
||||
type: 'http',
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
@@ -150,6 +155,16 @@ describe('mcp list command', () => {
|
||||
'http-server: https://example.com/http (http) - Connected',
|
||||
),
|
||||
);
|
||||
expect(debugLogger.log).toHaveBeenCalledWith(
|
||||
expect.stringContaining(
|
||||
'http-server-by-default: https://example.com/http (http) - Connected',
|
||||
),
|
||||
);
|
||||
expect(debugLogger.log).toHaveBeenCalledWith(
|
||||
expect.stringContaining(
|
||||
'http-server-with-type: https://example.com/http (http) - Connected',
|
||||
),
|
||||
);
|
||||
});
|
||||
|
||||
it('should display disconnected status when connection fails', async () => {
|
||||
|
||||
@@ -144,7 +144,8 @@ export async function listMcpServers(): Promise<void> {
|
||||
if (server.httpUrl) {
|
||||
serverInfo += `${server.httpUrl} (http)`;
|
||||
} else if (server.url) {
|
||||
serverInfo += `${server.url} (sse)`;
|
||||
const type = server.type || 'http';
|
||||
serverInfo += `${server.url} (${type})`;
|
||||
} else if (server.command) {
|
||||
serverInfo += `${server.command} ${server.args?.join(' ') || ''} (stdio)`;
|
||||
}
|
||||
|
||||
@@ -38,13 +38,19 @@ describe('skillsCommand', () => {
|
||||
skillsCommand.builder(mockYargs);
|
||||
|
||||
expect(mockYargs.middleware).toHaveBeenCalled();
|
||||
expect(mockYargs.command).toHaveBeenCalledWith({ command: 'list' });
|
||||
expect(mockYargs.command).toHaveBeenCalledWith({
|
||||
command: 'enable <name>',
|
||||
});
|
||||
expect(mockYargs.command).toHaveBeenCalledWith({
|
||||
command: 'disable <name>',
|
||||
});
|
||||
expect(mockYargs.command).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ command: 'list' }),
|
||||
);
|
||||
expect(mockYargs.command).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
command: 'enable <name>',
|
||||
}),
|
||||
);
|
||||
expect(mockYargs.command).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
command: 'disable <name>',
|
||||
}),
|
||||
);
|
||||
expect(mockYargs.demandCommand).toHaveBeenCalledWith(1, expect.any(String));
|
||||
expect(mockYargs.version).toHaveBeenCalledWith(false);
|
||||
});
|
||||
|
||||
@@ -11,6 +11,7 @@ import { disableCommand } from './skills/disable.js';
|
||||
import { installCommand } from './skills/install.js';
|
||||
import { uninstallCommand } from './skills/uninstall.js';
|
||||
import { initializeOutputListenersAndFlush } from '../gemini.js';
|
||||
import { defer } from '../deferred.js';
|
||||
|
||||
export const skillsCommand: CommandModule = {
|
||||
command: 'skills <command>',
|
||||
@@ -18,12 +19,15 @@ export const skillsCommand: CommandModule = {
|
||||
describe: 'Manage agent skills.',
|
||||
builder: (yargs) =>
|
||||
yargs
|
||||
.middleware(() => initializeOutputListenersAndFlush())
|
||||
.command(listCommand)
|
||||
.command(enableCommand)
|
||||
.command(disableCommand)
|
||||
.command(installCommand)
|
||||
.command(uninstallCommand)
|
||||
.middleware((argv) => {
|
||||
initializeOutputListenersAndFlush();
|
||||
argv['isCommand'] = true;
|
||||
})
|
||||
.command(defer(listCommand, 'skills'))
|
||||
.command(defer(enableCommand, 'skills'))
|
||||
.command(defer(disableCommand, 'skills'))
|
||||
.command(defer(installCommand, 'skills'))
|
||||
.command(defer(uninstallCommand, 'skills'))
|
||||
.demandCommand(1, 'You need at least one command before continuing.')
|
||||
.version(false),
|
||||
handler: () => {
|
||||
|
||||
@@ -34,6 +34,10 @@ vi.mock('./sandboxConfig.js', () => ({
|
||||
loadSandboxConfig: vi.fn(async () => undefined),
|
||||
}));
|
||||
|
||||
vi.mock('../commands/utils.js', () => ({
|
||||
exitCli: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('fs', async (importOriginal) => {
|
||||
const actualFs = await importOriginal<typeof import('fs')>();
|
||||
const pathMod = await import('node:path');
|
||||
@@ -134,7 +138,12 @@ vi.mock('@google/gemini-cli-core', async () => {
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock('./extension-manager.js');
|
||||
vi.mock('./extension-manager.js', () => {
|
||||
const ExtensionManager = vi.fn();
|
||||
ExtensionManager.prototype.loadExtensions = vi.fn();
|
||||
ExtensionManager.prototype.getExtensions = vi.fn().mockReturnValue([]);
|
||||
return { ExtensionManager };
|
||||
});
|
||||
|
||||
// Global setup to ensure clean environment for all tests in this file
|
||||
const originalArgv = process.argv;
|
||||
@@ -142,6 +151,11 @@ const originalGeminiModel = process.env['GEMINI_MODEL'];
|
||||
|
||||
beforeEach(() => {
|
||||
delete process.env['GEMINI_MODEL'];
|
||||
// Restore ExtensionManager mocks by re-assigning them
|
||||
ExtensionManager.prototype.getExtensions = vi.fn().mockReturnValue([]);
|
||||
ExtensionManager.prototype.loadExtensions = vi
|
||||
.fn()
|
||||
.mockResolvedValue(undefined);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
@@ -534,6 +548,42 @@ describe('parseArguments', () => {
|
||||
'Use whoami to write a poem in file poem.md about my username in pig latin and use wc to tell me how many lines are in the poem you wrote.',
|
||||
);
|
||||
});
|
||||
|
||||
it('should set isCommand to true for mcp command', async () => {
|
||||
process.argv = ['node', 'script.js', 'mcp', 'list'];
|
||||
const argv = await parseArguments(createTestMergedSettings());
|
||||
expect(argv.isCommand).toBe(true);
|
||||
});
|
||||
|
||||
it('should set isCommand to true for extensions command', async () => {
|
||||
process.argv = ['node', 'script.js', 'extensions', 'list'];
|
||||
// Extensions command uses experimental settings
|
||||
const settings = createTestMergedSettings({
|
||||
experimental: { extensionManagement: true },
|
||||
});
|
||||
const argv = await parseArguments(settings);
|
||||
expect(argv.isCommand).toBe(true);
|
||||
});
|
||||
|
||||
it('should set isCommand to true for skills command', async () => {
|
||||
process.argv = ['node', 'script.js', 'skills', 'list'];
|
||||
// Skills command enabled by default or via experimental
|
||||
const settings = createTestMergedSettings({
|
||||
experimental: { skills: true },
|
||||
});
|
||||
const argv = await parseArguments(settings);
|
||||
expect(argv.isCommand).toBe(true);
|
||||
});
|
||||
|
||||
it('should set isCommand to true for hooks command', async () => {
|
||||
process.argv = ['node', 'script.js', 'hooks', 'migrate'];
|
||||
// Hooks command enabled via tools settings
|
||||
const settings = createTestMergedSettings({
|
||||
tools: { enableHooks: true },
|
||||
});
|
||||
const argv = await parseArguments(settings);
|
||||
expect(argv.isCommand).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('loadCliConfig', () => {
|
||||
@@ -639,11 +689,31 @@ describe('loadCliConfig', () => {
|
||||
);
|
||||
expect(config.getApprovalMode()).toBe(ApprovalMode.DEFAULT);
|
||||
});
|
||||
|
||||
it('should be non-interactive when isCommand is set', async () => {
|
||||
process.argv = ['node', 'script.js', 'mcp', 'list'];
|
||||
const argv = await parseArguments(createTestMergedSettings());
|
||||
argv.isCommand = true; // explicitly set it as if middleware ran (it does in parseArguments but we want to be sure for this isolated test if we were mocking argv)
|
||||
|
||||
// reset tty for this test
|
||||
process.stdin.isTTY = true;
|
||||
|
||||
const settings = createTestMergedSettings();
|
||||
const config = await loadCliConfig(settings, 'test-session', argv);
|
||||
|
||||
expect(config.isInteractive()).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Hierarchical Memory Loading (config.ts) - Placeholder Suite', () => {
|
||||
beforeEach(() => {
|
||||
vi.resetAllMocks();
|
||||
// Restore ExtensionManager mocks that were reset
|
||||
ExtensionManager.prototype.getExtensions = vi.fn().mockReturnValue([]);
|
||||
ExtensionManager.prototype.loadExtensions = vi
|
||||
.fn()
|
||||
.mockResolvedValue(undefined);
|
||||
|
||||
vi.mocked(os.homedir).mockReturnValue('/mock/home/user');
|
||||
// Other common mocks would be reset here.
|
||||
});
|
||||
@@ -701,6 +771,63 @@ describe('Hierarchical Memory Loading (config.ts) - Placeholder Suite', () => {
|
||||
200, // maxDirs
|
||||
);
|
||||
});
|
||||
|
||||
it('should pass includeDirectories to loadServerHierarchicalMemory when loadMemoryFromIncludeDirectories is true', async () => {
|
||||
process.argv = ['node', 'script.js'];
|
||||
const includeDir = path.resolve(path.sep, 'path', 'to', 'include');
|
||||
const settings = createTestMergedSettings({
|
||||
context: {
|
||||
includeDirectories: [includeDir],
|
||||
loadMemoryFromIncludeDirectories: true,
|
||||
},
|
||||
});
|
||||
|
||||
const argv = await parseArguments(settings);
|
||||
await loadCliConfig(settings, 'session-id', argv);
|
||||
|
||||
expect(ServerConfig.loadServerHierarchicalMemory).toHaveBeenCalledWith(
|
||||
expect.any(String),
|
||||
[includeDir],
|
||||
false,
|
||||
expect.any(Object),
|
||||
expect.any(ExtensionManager),
|
||||
true,
|
||||
'tree',
|
||||
expect.objectContaining({
|
||||
respectGitIgnore: true,
|
||||
respectGeminiIgnore: true,
|
||||
}),
|
||||
200,
|
||||
);
|
||||
});
|
||||
|
||||
it('should NOT pass includeDirectories to loadServerHierarchicalMemory when loadMemoryFromIncludeDirectories is false', async () => {
|
||||
process.argv = ['node', 'script.js'];
|
||||
const settings = createTestMergedSettings({
|
||||
context: {
|
||||
includeDirectories: ['/path/to/include'],
|
||||
loadMemoryFromIncludeDirectories: false,
|
||||
},
|
||||
});
|
||||
|
||||
const argv = await parseArguments(settings);
|
||||
await loadCliConfig(settings, 'session-id', argv);
|
||||
|
||||
expect(ServerConfig.loadServerHierarchicalMemory).toHaveBeenCalledWith(
|
||||
expect.any(String),
|
||||
[],
|
||||
false,
|
||||
expect.any(Object),
|
||||
expect.any(ExtensionManager),
|
||||
true,
|
||||
'tree',
|
||||
expect.objectContaining({
|
||||
respectGitIgnore: true,
|
||||
respectGeminiIgnore: true,
|
||||
}),
|
||||
200,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('mergeMcpServers', () => {
|
||||
|
||||
@@ -83,6 +83,9 @@ export interface CliArgs {
|
||||
outputFormat: string | undefined;
|
||||
fakeResponses: string | undefined;
|
||||
recordResponses: string | undefined;
|
||||
rawOutput: boolean | undefined;
|
||||
acceptRawOutputRisk: boolean | undefined;
|
||||
isCommand: boolean | undefined;
|
||||
}
|
||||
|
||||
export async function parseArguments(
|
||||
@@ -95,7 +98,6 @@ export async function parseArguments(
|
||||
.usage(
|
||||
'Usage: gemini [options] [command]\n\nGemini CLI - Launch an interactive CLI, use -p/--prompt for non-interactive mode',
|
||||
)
|
||||
|
||||
.option('debug', {
|
||||
alias: 'd',
|
||||
type: 'boolean',
|
||||
@@ -248,6 +250,15 @@ export async function parseArguments(
|
||||
type: 'string',
|
||||
description: 'Path to a file to record model responses for testing.',
|
||||
hidden: true,
|
||||
})
|
||||
.option('raw-output', {
|
||||
type: 'boolean',
|
||||
description:
|
||||
'Disable sanitization of model output (e.g. allow ANSI escape sequences). WARNING: This can be a security risk if the model output is untrusted.',
|
||||
})
|
||||
.option('accept-raw-output-risk', {
|
||||
type: 'boolean',
|
||||
description: 'Suppress the security warning when using --raw-output.',
|
||||
}),
|
||||
)
|
||||
// Register MCP subcommands
|
||||
@@ -451,6 +462,7 @@ export async function loadCliConfig(
|
||||
workspaceDir: cwd,
|
||||
enabledExtensionOverrides: argv.extensions,
|
||||
eventEmitter: appEvents as EventEmitter<ExtensionEvents>,
|
||||
clientVersion: await getVersion(),
|
||||
});
|
||||
await extensionManager.loadExtensions();
|
||||
|
||||
@@ -464,7 +476,9 @@ export async function loadCliConfig(
|
||||
// Call the (now wrapper) loadHierarchicalGeminiMemory which calls the server's version
|
||||
const result = await loadServerHierarchicalMemory(
|
||||
cwd,
|
||||
[],
|
||||
settings.context?.loadMemoryFromIncludeDirectories || false
|
||||
? includeDirectories
|
||||
: [],
|
||||
debugMode,
|
||||
fileService,
|
||||
extensionManager,
|
||||
@@ -563,7 +577,7 @@ export async function loadCliConfig(
|
||||
const interactive =
|
||||
!!argv.promptInteractive ||
|
||||
!!argv.experimentalAcp ||
|
||||
(process.stdin.isTTY && !hasQuery && !argv.prompt);
|
||||
(process.stdin.isTTY && !hasQuery && !argv.prompt && !argv.isCommand);
|
||||
|
||||
const allowedTools = argv.allowedTools || settings.tools?.allowed || [];
|
||||
const allowedToolsSet = new Set(allowedTools);
|
||||
@@ -653,6 +667,7 @@ export async function loadCliConfig(
|
||||
|
||||
return new Config({
|
||||
sessionId,
|
||||
clientVersion: await getVersion(),
|
||||
embeddingModel: DEFAULT_GEMINI_EMBEDDING_MODEL,
|
||||
sandbox: sandboxConfig,
|
||||
targetDir: cwd,
|
||||
@@ -757,13 +772,16 @@ export async function loadCliConfig(
|
||||
retryFetchErrors: settings.general?.retryFetchErrors,
|
||||
ptyInfo: ptyInfo?.name,
|
||||
disableLLMCorrection: settings.tools?.disableLLMCorrection,
|
||||
rawOutput: argv.rawOutput,
|
||||
acceptRawOutputRisk: argv.acceptRawOutputRisk,
|
||||
modelConfigServiceConfig: settings.modelConfigs,
|
||||
// TODO: loading of hooks based on workspace trust
|
||||
enableHooks:
|
||||
(settings.tools?.enableHooks ?? true) &&
|
||||
(settings.hooks?.enabled ?? false),
|
||||
(settings.hooksConfig?.enabled ?? true),
|
||||
enableHooksUI: settings.tools?.enableHooks ?? true,
|
||||
hooks: settings.hooks || {},
|
||||
disabledHooks: settings.hooksConfig?.disabled || [],
|
||||
projectHooks: projectHooks || {},
|
||||
onModelChange: (model: string) => saveModelChange(loadedSettings, model),
|
||||
onReload: async () => {
|
||||
|
||||
@@ -76,6 +76,7 @@ interface ExtensionManagerParams {
|
||||
requestSetting: ((setting: ExtensionSetting) => Promise<string>) | null;
|
||||
workspaceDir: string;
|
||||
eventEmitter?: EventEmitter<ExtensionEvents>;
|
||||
clientVersion?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -105,6 +106,7 @@ export class ExtensionManager extends ExtensionLoader {
|
||||
telemetry: options.settings.telemetry,
|
||||
interactive: false,
|
||||
sessionId: randomUUID(),
|
||||
clientVersion: options.clientVersion ?? 'unknown',
|
||||
targetDir: options.workspaceDir,
|
||||
cwd: options.workspaceDir,
|
||||
model: '',
|
||||
@@ -611,7 +613,10 @@ Would you like to attempt to install via "git clone" instead?`,
|
||||
.filter((contextFilePath) => fs.existsSync(contextFilePath));
|
||||
|
||||
let hooks: { [K in HookEventName]?: HookDefinition[] } | undefined;
|
||||
if (this.settings.tools.enableHooks && this.settings.hooks.enabled) {
|
||||
if (
|
||||
this.settings.tools.enableHooks &&
|
||||
this.settings.hooksConfig.enabled
|
||||
) {
|
||||
hooks = await this.loadExtensionHooks(effectiveExtensionPath, {
|
||||
extensionPath: effectiveExtensionPath,
|
||||
workspacePath: this.workspaceDir,
|
||||
|
||||
@@ -815,6 +815,7 @@ describe('extension tests', () => {
|
||||
fs.mkdirSync(hooksDir);
|
||||
|
||||
const hooksConfig = {
|
||||
enabled: false,
|
||||
hooks: {
|
||||
BeforeTool: [
|
||||
{
|
||||
@@ -836,7 +837,7 @@ describe('extension tests', () => {
|
||||
);
|
||||
|
||||
const settings = loadSettings(tempWorkspaceDir).merged;
|
||||
settings.hooks.enabled = true;
|
||||
settings.hooksConfig.enabled = true;
|
||||
|
||||
extensionManager = new ExtensionManager({
|
||||
workspaceDir: tempWorkspaceDir,
|
||||
@@ -867,11 +868,11 @@ describe('extension tests', () => {
|
||||
fs.mkdirSync(hooksDir);
|
||||
fs.writeFileSync(
|
||||
path.join(hooksDir, 'hooks.json'),
|
||||
JSON.stringify({ hooks: { BeforeTool: [] } }),
|
||||
JSON.stringify({ hooks: { BeforeTool: [] }, enabled: false }),
|
||||
);
|
||||
|
||||
const settings = loadSettings(tempWorkspaceDir).merged;
|
||||
settings.hooks.enabled = false;
|
||||
settings.hooksConfig.enabled = false;
|
||||
|
||||
extensionManager = new ExtensionManager({
|
||||
workspaceDir: tempWorkspaceDir,
|
||||
|
||||
@@ -33,14 +33,17 @@ describe('keyBindings config', () => {
|
||||
expect(binding.key.length).toBeGreaterThan(0);
|
||||
|
||||
// Modifier properties should be boolean or undefined
|
||||
if (binding.ctrl !== undefined) {
|
||||
expect(typeof binding.ctrl).toBe('boolean');
|
||||
}
|
||||
if (binding.shift !== undefined) {
|
||||
expect(typeof binding.shift).toBe('boolean');
|
||||
}
|
||||
if (binding.command !== undefined) {
|
||||
expect(typeof binding.command).toBe('boolean');
|
||||
if (binding.alt !== undefined) {
|
||||
expect(typeof binding.alt).toBe('boolean');
|
||||
}
|
||||
if (binding.ctrl !== undefined) {
|
||||
expect(typeof binding.ctrl).toBe('boolean');
|
||||
}
|
||||
if (binding.cmd !== undefined) {
|
||||
expect(typeof binding.cmd).toBe('boolean');
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -73,9 +76,27 @@ describe('keyBindings config', () => {
|
||||
expect(dialogNavDown).toContainEqual({ key: 'down', shift: false });
|
||||
expect(dialogNavDown).toContainEqual({ key: 'j', shift: false });
|
||||
|
||||
// Verify physical home/end keys
|
||||
expect(defaultKeyBindings[Command.HOME]).toContainEqual({ key: 'home' });
|
||||
expect(defaultKeyBindings[Command.END]).toContainEqual({ key: 'end' });
|
||||
// Verify physical home/end keys for cursor movement
|
||||
expect(defaultKeyBindings[Command.HOME]).toContainEqual({
|
||||
key: 'home',
|
||||
ctrl: false,
|
||||
shift: false,
|
||||
});
|
||||
expect(defaultKeyBindings[Command.END]).toContainEqual({
|
||||
key: 'end',
|
||||
ctrl: false,
|
||||
shift: false,
|
||||
});
|
||||
|
||||
// Verify physical home/end keys for scrolling
|
||||
expect(defaultKeyBindings[Command.SCROLL_HOME]).toContainEqual({
|
||||
key: 'home',
|
||||
ctrl: true,
|
||||
});
|
||||
expect(defaultKeyBindings[Command.SCROLL_END]).toContainEqual({
|
||||
key: 'end',
|
||||
ctrl: true,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -76,7 +76,7 @@ export enum Command {
|
||||
TOGGLE_MARKDOWN = 'app.toggleMarkdown',
|
||||
TOGGLE_COPY_MODE = 'app.toggleCopyMode',
|
||||
TOGGLE_YOLO = 'app.toggleYolo',
|
||||
TOGGLE_AUTO_EDIT = 'app.toggleAutoEdit',
|
||||
CYCLE_APPROVAL_MODE = 'app.cycleApprovalMode',
|
||||
SHOW_MORE_LINES = 'app.showMoreLines',
|
||||
FOCUS_SHELL_INPUT = 'app.focusShellInput',
|
||||
UNFOCUS_SHELL_INPUT = 'app.unfocusShellInput',
|
||||
@@ -90,12 +90,14 @@ export enum Command {
|
||||
export interface KeyBinding {
|
||||
/** The key name (e.g., 'a', 'return', 'tab', 'escape') */
|
||||
key: string;
|
||||
/** Control key requirement: true=must be pressed, false=must not be pressed, undefined=ignore */
|
||||
ctrl?: boolean;
|
||||
/** Shift key requirement: true=must be pressed, false=must not be pressed, undefined=ignore */
|
||||
shift?: boolean;
|
||||
/** Command/meta key requirement: true=must be pressed, false=must not be pressed, undefined=ignore */
|
||||
command?: boolean;
|
||||
/** Alt/Option key requirement: true=must be pressed, false=must not be pressed, undefined=ignore */
|
||||
alt?: boolean;
|
||||
/** Control key requirement: true=must be pressed, false=must not be pressed, undefined=ignore */
|
||||
ctrl?: boolean;
|
||||
/** Command/Windows/Super key requirement: true=must be pressed, false=must not be pressed, undefined=ignore */
|
||||
cmd?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -117,61 +119,75 @@ export const defaultKeyBindings: KeyBindingConfig = {
|
||||
[Command.EXIT]: [{ key: 'd', ctrl: true }],
|
||||
|
||||
// Cursor Movement
|
||||
[Command.HOME]: [{ key: 'a', ctrl: true }, { key: 'home' }],
|
||||
[Command.END]: [{ key: 'e', ctrl: true }, { key: 'end' }],
|
||||
[Command.MOVE_UP]: [{ key: 'up', ctrl: false, command: false }],
|
||||
[Command.MOVE_DOWN]: [{ key: 'down', ctrl: false, command: false }],
|
||||
[Command.HOME]: [
|
||||
{ key: 'a', ctrl: true },
|
||||
{ key: 'home', shift: false, ctrl: false },
|
||||
],
|
||||
[Command.END]: [
|
||||
{ key: 'e', ctrl: true },
|
||||
{ key: 'end', shift: false, ctrl: false },
|
||||
],
|
||||
[Command.MOVE_UP]: [
|
||||
{ key: 'up', shift: false, alt: false, ctrl: false, cmd: false },
|
||||
],
|
||||
[Command.MOVE_DOWN]: [
|
||||
{ key: 'down', shift: false, alt: false, ctrl: false, cmd: false },
|
||||
],
|
||||
[Command.MOVE_LEFT]: [
|
||||
{ key: 'left', ctrl: false, command: false },
|
||||
{ key: 'left', shift: false, alt: false, ctrl: false, cmd: false },
|
||||
{ key: 'b', ctrl: true },
|
||||
],
|
||||
[Command.MOVE_RIGHT]: [
|
||||
{ key: 'right', ctrl: false, command: false },
|
||||
{ key: 'right', shift: false, alt: false, ctrl: false, cmd: false },
|
||||
{ key: 'f', ctrl: true },
|
||||
],
|
||||
[Command.MOVE_WORD_LEFT]: [
|
||||
{ key: 'left', ctrl: true },
|
||||
{ key: 'left', command: true },
|
||||
{ key: 'b', command: true },
|
||||
{ key: 'left', alt: true },
|
||||
{ key: 'b', alt: true },
|
||||
],
|
||||
[Command.MOVE_WORD_RIGHT]: [
|
||||
{ key: 'right', ctrl: true },
|
||||
{ key: 'right', command: true },
|
||||
{ key: 'f', command: true },
|
||||
{ key: 'right', alt: true },
|
||||
{ key: 'f', alt: 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: 'backspace', alt: true },
|
||||
{ key: 'w', ctrl: true },
|
||||
],
|
||||
[Command.DELETE_WORD_FORWARD]: [
|
||||
{ key: 'delete', ctrl: true },
|
||||
{ key: 'delete', command: true },
|
||||
{ key: 'delete', alt: 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 }],
|
||||
[Command.UNDO]: [{ key: 'z', shift: false, ctrl: true }],
|
||||
[Command.REDO]: [{ key: 'z', shift: true, ctrl: true }],
|
||||
|
||||
// Scrolling
|
||||
[Command.SCROLL_UP]: [{ key: 'up', shift: true }],
|
||||
[Command.SCROLL_DOWN]: [{ key: 'down', shift: true }],
|
||||
[Command.SCROLL_HOME]: [{ key: 'home' }],
|
||||
[Command.SCROLL_END]: [{ key: 'end' }],
|
||||
[Command.SCROLL_HOME]: [
|
||||
{ key: 'home', ctrl: true },
|
||||
{ key: 'home', shift: true },
|
||||
],
|
||||
[Command.SCROLL_END]: [
|
||||
{ key: 'end', ctrl: true },
|
||||
{ key: 'end', shift: true },
|
||||
],
|
||||
[Command.PAGE_UP]: [{ key: 'pageup' }],
|
||||
[Command.PAGE_DOWN]: [{ key: 'pagedown' }],
|
||||
|
||||
// History & Search
|
||||
[Command.HISTORY_UP]: [{ key: 'p', ctrl: true, shift: false }],
|
||||
[Command.HISTORY_DOWN]: [{ key: 'n', ctrl: true, shift: false }],
|
||||
[Command.HISTORY_UP]: [{ key: 'p', shift: false, ctrl: true }],
|
||||
[Command.HISTORY_DOWN]: [{ key: 'n', shift: false, ctrl: true }],
|
||||
[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' }],
|
||||
|
||||
@@ -191,14 +207,13 @@ export const defaultKeyBindings: KeyBindingConfig = {
|
||||
|
||||
// Suggestions & Completions
|
||||
[Command.ACCEPT_SUGGESTION]: [{ key: 'tab' }, { key: 'return', ctrl: false }],
|
||||
// Completion navigation (arrow or Ctrl+P/N)
|
||||
[Command.COMPLETION_UP]: [
|
||||
{ key: 'up', shift: false },
|
||||
{ key: 'p', ctrl: true, shift: false },
|
||||
{ key: 'p', shift: false, ctrl: true },
|
||||
],
|
||||
[Command.COMPLETION_DOWN]: [
|
||||
{ key: 'down', shift: false },
|
||||
{ key: 'n', ctrl: true, shift: false },
|
||||
{ key: 'n', shift: false, ctrl: true },
|
||||
],
|
||||
[Command.EXPAND_SUGGESTION]: [{ key: 'right' }],
|
||||
[Command.COLLAPSE_SUGGESTION]: [{ key: 'left' }],
|
||||
@@ -208,33 +223,34 @@ export const defaultKeyBindings: KeyBindingConfig = {
|
||||
[Command.SUBMIT]: [
|
||||
{
|
||||
key: 'return',
|
||||
ctrl: false,
|
||||
command: false,
|
||||
shift: false,
|
||||
alt: false,
|
||||
ctrl: false,
|
||||
cmd: false,
|
||||
},
|
||||
],
|
||||
// Split into multiple data-driven bindings
|
||||
// Now also includes shift+enter for multi-line input
|
||||
[Command.NEWLINE]: [
|
||||
{ key: 'return', ctrl: true },
|
||||
{ key: 'return', command: true },
|
||||
{ key: 'return', cmd: true },
|
||||
{ key: 'return', alt: true },
|
||||
{ key: 'return', shift: true },
|
||||
{ key: 'j', ctrl: true },
|
||||
],
|
||||
[Command.OPEN_EXTERNAL_EDITOR]: [{ key: 'x', ctrl: true }],
|
||||
[Command.PASTE_CLIPBOARD]: [
|
||||
{ key: 'v', ctrl: true },
|
||||
{ key: 'v', command: true },
|
||||
{ key: 'v', cmd: true },
|
||||
{ key: 'v', alt: true },
|
||||
],
|
||||
|
||||
// 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 }],
|
||||
[Command.TOGGLE_MARKDOWN]: [{ key: 'm', command: true }],
|
||||
[Command.TOGGLE_MARKDOWN]: [{ key: 'm', alt: true }],
|
||||
[Command.TOGGLE_COPY_MODE]: [{ key: 's', ctrl: true }],
|
||||
[Command.TOGGLE_YOLO]: [{ key: 'y', ctrl: true }],
|
||||
[Command.TOGGLE_AUTO_EDIT]: [{ key: 'tab', shift: true }],
|
||||
[Command.CYCLE_APPROVAL_MODE]: [{ key: 'tab', shift: true }],
|
||||
[Command.SHOW_MORE_LINES]: [{ key: 's', ctrl: true }],
|
||||
[Command.FOCUS_SHELL_INPUT]: [{ key: 'tab', shift: false }],
|
||||
[Command.UNFOCUS_SHELL_INPUT]: [{ key: 'tab' }],
|
||||
@@ -340,7 +356,7 @@ export const commandCategories: readonly CommandCategory[] = [
|
||||
Command.TOGGLE_MARKDOWN,
|
||||
Command.TOGGLE_COPY_MODE,
|
||||
Command.TOGGLE_YOLO,
|
||||
Command.TOGGLE_AUTO_EDIT,
|
||||
Command.CYCLE_APPROVAL_MODE,
|
||||
Command.SHOW_MORE_LINES,
|
||||
Command.FOCUS_SHELL_INPUT,
|
||||
Command.UNFOCUS_SHELL_INPUT,
|
||||
@@ -425,7 +441,8 @@ export const commandDescriptions: Readonly<Record<Command, string>> = {
|
||||
[Command.TOGGLE_MARKDOWN]: 'Toggle Markdown rendering.',
|
||||
[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.CYCLE_APPROVAL_MODE]:
|
||||
'Cycle through approval modes: default (prompt), auto_edit (auto-approve edits), and plan (read-only).',
|
||||
[Command.SHOW_MORE_LINES]:
|
||||
'Expand a height-constrained response to show additional lines when not in alternate buffer mode.',
|
||||
[Command.FOCUS_SHELL_INPUT]: 'Focus the shell input from the gemini input.',
|
||||
|
||||
@@ -25,11 +25,15 @@ vi.mock('@google/gemini-cli-core', async (importOriginal) => {
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock('command-exists', () => ({
|
||||
default: {
|
||||
sync: vi.fn(),
|
||||
},
|
||||
}));
|
||||
vi.mock('command-exists', () => {
|
||||
const sync = vi.fn();
|
||||
return {
|
||||
sync,
|
||||
default: {
|
||||
sync,
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock('node:os', async (importOriginal) => {
|
||||
const actual = await importOriginal();
|
||||
@@ -49,6 +53,8 @@ describe('loadSandboxConfig', () => {
|
||||
beforeEach(() => {
|
||||
vi.resetAllMocks();
|
||||
process.env = { ...originalEnv };
|
||||
delete process.env['SANDBOX'];
|
||||
delete process.env['GEMINI_SANDBOX'];
|
||||
mockedGetPackageJson.mockResolvedValue({
|
||||
config: { sandboxImageUri: 'default/image' },
|
||||
});
|
||||
|
||||
@@ -1961,6 +1961,57 @@ describe('Settings Loading and Merging', () => {
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('should migrate disableUpdateNag to enableAutoUpdateNotification in system and system defaults settings', () => {
|
||||
const systemSettingsContent = {
|
||||
general: {
|
||||
disableUpdateNag: true,
|
||||
},
|
||||
};
|
||||
const systemDefaultsContent = {
|
||||
general: {
|
||||
disableUpdateNag: false,
|
||||
},
|
||||
};
|
||||
|
||||
vi.mocked(fs.existsSync).mockReturnValue(true);
|
||||
(fs.readFileSync as Mock).mockImplementation(
|
||||
(p: fs.PathOrFileDescriptor) => {
|
||||
if (p === getSystemSettingsPath()) {
|
||||
return JSON.stringify(systemSettingsContent);
|
||||
}
|
||||
if (p === getSystemDefaultsPath()) {
|
||||
return JSON.stringify(systemDefaultsContent);
|
||||
}
|
||||
return '{}';
|
||||
},
|
||||
);
|
||||
|
||||
const settings = loadSettings(MOCK_WORKSPACE_DIR);
|
||||
|
||||
// Verify system settings were migrated
|
||||
expect(settings.system.settings.general).toHaveProperty(
|
||||
'enableAutoUpdateNotification',
|
||||
);
|
||||
expect(
|
||||
(settings.system.settings.general as Record<string, unknown>)[
|
||||
'enableAutoUpdateNotification'
|
||||
],
|
||||
).toBe(false);
|
||||
|
||||
// Verify system defaults settings were migrated
|
||||
expect(settings.systemDefaults.settings.general).toHaveProperty(
|
||||
'enableAutoUpdateNotification',
|
||||
);
|
||||
expect(
|
||||
(settings.systemDefaults.settings.general as Record<string, unknown>)[
|
||||
'enableAutoUpdateNotification'
|
||||
],
|
||||
).toBe(true);
|
||||
|
||||
// Merged should also reflect it (system overrides defaults, but both are migrated)
|
||||
expect(settings.merged.general?.enableAutoUpdateNotification).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('saveSettings', () => {
|
||||
@@ -2200,6 +2251,23 @@ describe('Settings Loading and Merging', () => {
|
||||
expect(loadedSettings.merged.admin?.mcp?.enabled).toBe(true);
|
||||
expect(loadedSettings.merged.admin?.extensions?.enabled).toBe(false);
|
||||
});
|
||||
|
||||
it('should set skills based on advancedFeaturesEnabled', () => {
|
||||
const loadedSettings = loadSettings();
|
||||
loadedSettings.setRemoteAdminSettings({
|
||||
cliFeatureSetting: {
|
||||
advancedFeaturesEnabled: true,
|
||||
},
|
||||
});
|
||||
expect(loadedSettings.merged.admin.skills?.enabled).toBe(true);
|
||||
|
||||
loadedSettings.setRemoteAdminSettings({
|
||||
cliFeatureSetting: {
|
||||
advancedFeaturesEnabled: false,
|
||||
},
|
||||
});
|
||||
expect(loadedSettings.merged.admin.skills?.enabled).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getDefaultsFromSchema', () => {
|
||||
|
||||
@@ -363,6 +363,10 @@ export class LoadedSettings {
|
||||
admin.extensions = { enabled: extensionsSetting.extensionsEnabled };
|
||||
}
|
||||
|
||||
if (cliFeatureSetting?.advancedFeaturesEnabled !== undefined) {
|
||||
admin.skills = { enabled: cliFeatureSetting.advancedFeaturesEnabled };
|
||||
}
|
||||
|
||||
this._remoteAdminSettings = { admin };
|
||||
this._merged = this.computeMergedSettings();
|
||||
}
|
||||
@@ -804,6 +808,8 @@ export function migrateDeprecatedSettings(
|
||||
|
||||
processScope(SettingScope.User);
|
||||
processScope(SettingScope.Workspace);
|
||||
processScope(SettingScope.System);
|
||||
processScope(SettingScope.SystemDefaults);
|
||||
|
||||
return anyModified;
|
||||
}
|
||||
|
||||
@@ -387,7 +387,7 @@ describe('SettingsSchema', () => {
|
||||
expect(setting).toBeDefined();
|
||||
expect(setting.type).toBe('boolean');
|
||||
expect(setting.category).toBe('Experimental');
|
||||
expect(setting.default).toBe(false);
|
||||
expect(setting.default).toBe(true);
|
||||
expect(setting.requiresRestart).toBe(true);
|
||||
expect(setting.showInDialog).toBe(false);
|
||||
expect(setting.description).toBe(
|
||||
@@ -395,8 +395,8 @@ describe('SettingsSchema', () => {
|
||||
);
|
||||
});
|
||||
|
||||
it('should have hooks.notifications setting in schema', () => {
|
||||
const setting = getSettingsSchema().hooks.properties.notifications;
|
||||
it('should have hooksConfig.notifications setting in schema', () => {
|
||||
const setting = getSettingsSchema().hooksConfig?.properties.notifications;
|
||||
expect(setting).toBeDefined();
|
||||
expect(setting.type).toBe('boolean');
|
||||
expect(setting.category).toBe('Advanced');
|
||||
|
||||
@@ -1126,7 +1126,7 @@ const SETTINGS_SCHEMA = {
|
||||
label: 'Disable LLM Correction',
|
||||
category: 'Tools',
|
||||
requiresRestart: true,
|
||||
default: false,
|
||||
default: true,
|
||||
description: oneLine`
|
||||
Disable LLM-based error correction for edit tools.
|
||||
When enabled, tools will fail immediately if exact string matches are not found, instead of attempting to self-correct.
|
||||
@@ -1429,7 +1429,7 @@ const SETTINGS_SCHEMA = {
|
||||
label: 'Event Driven Scheduler',
|
||||
category: 'Experimental',
|
||||
requiresRestart: true,
|
||||
default: false,
|
||||
default: true,
|
||||
description: 'Enables event-driven scheduler within the CLI session.',
|
||||
showInDialog: false,
|
||||
},
|
||||
@@ -1631,9 +1631,9 @@ const SETTINGS_SCHEMA = {
|
||||
},
|
||||
},
|
||||
|
||||
hooks: {
|
||||
hooksConfig: {
|
||||
type: 'object',
|
||||
label: 'Hooks',
|
||||
label: 'HooksConfig',
|
||||
category: 'Advanced',
|
||||
requiresRestart: false,
|
||||
default: {},
|
||||
@@ -1646,7 +1646,7 @@ const SETTINGS_SCHEMA = {
|
||||
label: 'Enable Hooks',
|
||||
category: 'Advanced',
|
||||
requiresRestart: false,
|
||||
default: false,
|
||||
default: true,
|
||||
description:
|
||||
'Canonical toggle for the hooks system. When disabled, no hooks will be executed.',
|
||||
showInDialog: false,
|
||||
@@ -1675,6 +1675,18 @@ const SETTINGS_SCHEMA = {
|
||||
description: 'Show visual indicators when hooks are executing.',
|
||||
showInDialog: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
hooks: {
|
||||
type: 'object',
|
||||
label: 'Hook Events',
|
||||
category: 'Advanced',
|
||||
requiresRestart: false,
|
||||
default: {},
|
||||
description: 'Event-specific hook configurations.',
|
||||
showInDialog: false,
|
||||
properties: {
|
||||
BeforeTool: {
|
||||
type: 'array',
|
||||
label: 'Before Tool Hooks',
|
||||
@@ -2117,10 +2129,6 @@ export const SETTINGS_SCHEMA_DEFINITIONS: Record<
|
||||
type: 'boolean',
|
||||
description: 'Whether to enable the agent.',
|
||||
},
|
||||
disabled: {
|
||||
type: 'boolean',
|
||||
description: 'Whether to disable the agent.',
|
||||
},
|
||||
},
|
||||
},
|
||||
CustomTheme: {
|
||||
|
||||
@@ -0,0 +1,217 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import {
|
||||
runDeferredCommand,
|
||||
defer,
|
||||
setDeferredCommand,
|
||||
type DeferredCommand,
|
||||
} from './deferred.js';
|
||||
import { ExitCodes } from '@google/gemini-cli-core';
|
||||
import type { ArgumentsCamelCase, CommandModule } from 'yargs';
|
||||
import type { MergedSettings } from './config/settings.js';
|
||||
import type { MockInstance } from 'vitest';
|
||||
|
||||
const { mockRunExitCleanup, mockDebugLogger } = vi.hoisted(() => ({
|
||||
mockRunExitCleanup: vi.fn(),
|
||||
mockDebugLogger: {
|
||||
log: vi.fn(),
|
||||
error: vi.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock('@google/gemini-cli-core', async () => {
|
||||
const actual = await vi.importActual('@google/gemini-cli-core');
|
||||
return {
|
||||
...actual,
|
||||
debugLogger: mockDebugLogger,
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock('./utils/cleanup.js', () => ({
|
||||
runExitCleanup: mockRunExitCleanup,
|
||||
}));
|
||||
|
||||
let mockExit: MockInstance;
|
||||
|
||||
describe('deferred', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
mockExit = vi
|
||||
.spyOn(process, 'exit')
|
||||
.mockImplementation(() => undefined as never);
|
||||
setDeferredCommand(undefined as unknown as DeferredCommand); // Reset deferred command
|
||||
});
|
||||
|
||||
const createMockSettings = (adminSettings: unknown = {}): MergedSettings =>
|
||||
({
|
||||
admin: adminSettings,
|
||||
}) as unknown as MergedSettings;
|
||||
|
||||
describe('runDeferredCommand', () => {
|
||||
it('should do nothing if no deferred command is set', async () => {
|
||||
await runDeferredCommand(createMockSettings());
|
||||
expect(mockDebugLogger.log).not.toHaveBeenCalled();
|
||||
expect(mockDebugLogger.error).not.toHaveBeenCalled();
|
||||
expect(mockExit).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should execute the deferred command if enabled', async () => {
|
||||
const mockHandler = vi.fn();
|
||||
setDeferredCommand({
|
||||
handler: mockHandler,
|
||||
argv: { _: [], $0: 'gemini' } as ArgumentsCamelCase,
|
||||
commandName: 'mcp',
|
||||
});
|
||||
|
||||
const settings = createMockSettings({ mcp: { enabled: true } });
|
||||
await runDeferredCommand(settings);
|
||||
expect(mockHandler).toHaveBeenCalled();
|
||||
expect(mockRunExitCleanup).toHaveBeenCalled();
|
||||
expect(mockExit).toHaveBeenCalledWith(ExitCodes.SUCCESS);
|
||||
});
|
||||
|
||||
it('should exit with FATAL_CONFIG_ERROR if MCP is disabled', async () => {
|
||||
setDeferredCommand({
|
||||
handler: vi.fn(),
|
||||
argv: {} as ArgumentsCamelCase,
|
||||
commandName: 'mcp',
|
||||
});
|
||||
|
||||
const settings = createMockSettings({ mcp: { enabled: false } });
|
||||
await runDeferredCommand(settings);
|
||||
|
||||
expect(mockDebugLogger.error).toHaveBeenCalledWith(
|
||||
'Error: MCP is disabled by your admin.',
|
||||
);
|
||||
expect(mockRunExitCleanup).toHaveBeenCalled();
|
||||
expect(mockExit).toHaveBeenCalledWith(ExitCodes.FATAL_CONFIG_ERROR);
|
||||
});
|
||||
|
||||
it('should exit with FATAL_CONFIG_ERROR if extensions are disabled', async () => {
|
||||
setDeferredCommand({
|
||||
handler: vi.fn(),
|
||||
argv: {} as ArgumentsCamelCase,
|
||||
commandName: 'extensions',
|
||||
});
|
||||
|
||||
const settings = createMockSettings({ extensions: { enabled: false } });
|
||||
await runDeferredCommand(settings);
|
||||
|
||||
expect(mockDebugLogger.error).toHaveBeenCalledWith(
|
||||
'Error: Extensions are disabled by your admin.',
|
||||
);
|
||||
expect(mockRunExitCleanup).toHaveBeenCalled();
|
||||
expect(mockExit).toHaveBeenCalledWith(ExitCodes.FATAL_CONFIG_ERROR);
|
||||
});
|
||||
|
||||
it('should exit with FATAL_CONFIG_ERROR if skills are disabled', async () => {
|
||||
setDeferredCommand({
|
||||
handler: vi.fn(),
|
||||
argv: {} as ArgumentsCamelCase,
|
||||
commandName: 'skills',
|
||||
});
|
||||
|
||||
const settings = createMockSettings({ skills: { enabled: false } });
|
||||
await runDeferredCommand(settings);
|
||||
|
||||
expect(mockDebugLogger.error).toHaveBeenCalledWith(
|
||||
'Error: Agent skills are disabled by your admin.',
|
||||
);
|
||||
expect(mockRunExitCleanup).toHaveBeenCalled();
|
||||
expect(mockExit).toHaveBeenCalledWith(ExitCodes.FATAL_CONFIG_ERROR);
|
||||
});
|
||||
|
||||
it('should execute if admin settings are undefined (default implicit enable)', async () => {
|
||||
const mockHandler = vi.fn();
|
||||
setDeferredCommand({
|
||||
handler: mockHandler,
|
||||
argv: {} as ArgumentsCamelCase,
|
||||
commandName: 'mcp',
|
||||
});
|
||||
|
||||
const settings = createMockSettings({}); // No admin settings
|
||||
await runDeferredCommand(settings);
|
||||
|
||||
expect(mockHandler).toHaveBeenCalled();
|
||||
expect(mockExit).toHaveBeenCalledWith(ExitCodes.SUCCESS);
|
||||
});
|
||||
});
|
||||
|
||||
describe('defer', () => {
|
||||
it('should wrap a command module and defer execution', async () => {
|
||||
const originalHandler = vi.fn();
|
||||
const commandModule: CommandModule = {
|
||||
command: 'test',
|
||||
describe: 'test command',
|
||||
handler: originalHandler,
|
||||
};
|
||||
|
||||
const deferredModule = defer(commandModule);
|
||||
expect(deferredModule.command).toBe(commandModule.command);
|
||||
|
||||
// Execute the wrapper handler
|
||||
const argv = { _: [], $0: 'gemini' } as ArgumentsCamelCase;
|
||||
await deferredModule.handler(argv);
|
||||
|
||||
// Should check that it set the deferred command, but didn't run original handler yet
|
||||
expect(originalHandler).not.toHaveBeenCalled();
|
||||
|
||||
// Now manually run it to verify it captured correctly
|
||||
await runDeferredCommand(createMockSettings());
|
||||
expect(originalHandler).toHaveBeenCalledWith(argv);
|
||||
expect(mockExit).toHaveBeenCalledWith(ExitCodes.SUCCESS);
|
||||
});
|
||||
|
||||
it('should use parentCommandName if provided', async () => {
|
||||
const commandModule: CommandModule = {
|
||||
command: 'subcommand',
|
||||
describe: 'sub command',
|
||||
handler: vi.fn(),
|
||||
};
|
||||
|
||||
const deferredModule = defer(commandModule, 'parent');
|
||||
await deferredModule.handler({} as ArgumentsCamelCase);
|
||||
|
||||
const deferredMcp = defer(commandModule, 'mcp');
|
||||
await deferredMcp.handler({} as ArgumentsCamelCase);
|
||||
|
||||
const mcpSettings = createMockSettings({ mcp: { enabled: false } });
|
||||
await runDeferredCommand(mcpSettings);
|
||||
|
||||
expect(mockDebugLogger.error).toHaveBeenCalledWith(
|
||||
'Error: MCP is disabled by your admin.',
|
||||
);
|
||||
});
|
||||
|
||||
it('should fallback to unknown if no parentCommandName is provided', async () => {
|
||||
const mockHandler = vi.fn();
|
||||
const commandModule: CommandModule = {
|
||||
command: ['foo', 'infoo'],
|
||||
describe: 'foo command',
|
||||
handler: mockHandler,
|
||||
};
|
||||
|
||||
const deferredModule = defer(commandModule);
|
||||
await deferredModule.handler({} as ArgumentsCamelCase);
|
||||
|
||||
// Verify it runs even if all known commands are disabled,
|
||||
// confirming it didn't capture 'mcp', 'extensions', or 'skills'
|
||||
// and defaulted to 'unknown' (or something else safe).
|
||||
const settings = createMockSettings({
|
||||
mcp: { enabled: false },
|
||||
extensions: { enabled: false },
|
||||
skills: { enabled: false },
|
||||
});
|
||||
|
||||
await runDeferredCommand(settings);
|
||||
|
||||
expect(mockHandler).toHaveBeenCalled();
|
||||
expect(mockExit).toHaveBeenCalledWith(ExitCodes.SUCCESS);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,78 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2026 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
import type { ArgumentsCamelCase, CommandModule } from 'yargs';
|
||||
import { debugLogger, ExitCodes } from '@google/gemini-cli-core';
|
||||
import { runExitCleanup } from './utils/cleanup.js';
|
||||
import type { MergedSettings } from './config/settings.js';
|
||||
import process from 'node:process';
|
||||
|
||||
export interface DeferredCommand {
|
||||
handler: (argv: ArgumentsCamelCase) => void | Promise<void>;
|
||||
argv: ArgumentsCamelCase;
|
||||
commandName: string;
|
||||
}
|
||||
|
||||
let deferredCommand: DeferredCommand | undefined;
|
||||
|
||||
export function setDeferredCommand(command: DeferredCommand) {
|
||||
deferredCommand = command;
|
||||
}
|
||||
|
||||
export async function runDeferredCommand(settings: MergedSettings) {
|
||||
if (!deferredCommand) {
|
||||
return;
|
||||
}
|
||||
|
||||
const adminSettings = settings.admin;
|
||||
const commandName = deferredCommand.commandName;
|
||||
|
||||
if (commandName === 'mcp' && adminSettings?.mcp?.enabled === false) {
|
||||
debugLogger.error('Error: MCP is disabled by your admin.');
|
||||
await runExitCleanup();
|
||||
process.exit(ExitCodes.FATAL_CONFIG_ERROR);
|
||||
}
|
||||
|
||||
if (
|
||||
commandName === 'extensions' &&
|
||||
adminSettings?.extensions?.enabled === false
|
||||
) {
|
||||
debugLogger.error('Error: Extensions are disabled by your admin.');
|
||||
await runExitCleanup();
|
||||
process.exit(ExitCodes.FATAL_CONFIG_ERROR);
|
||||
}
|
||||
|
||||
if (commandName === 'skills' && adminSettings?.skills?.enabled === false) {
|
||||
debugLogger.error('Error: Agent skills are disabled by your admin.');
|
||||
await runExitCleanup();
|
||||
process.exit(ExitCodes.FATAL_CONFIG_ERROR);
|
||||
}
|
||||
|
||||
await deferredCommand.handler(deferredCommand.argv);
|
||||
await runExitCleanup();
|
||||
process.exit(ExitCodes.SUCCESS);
|
||||
}
|
||||
|
||||
/**
|
||||
* Wraps a command's handler to defer its execution.
|
||||
* It stores the handler and arguments in a singleton `deferredCommand` variable.
|
||||
*/
|
||||
export function defer<T = object, U = object>(
|
||||
commandModule: CommandModule<T, U>,
|
||||
parentCommandName?: string,
|
||||
): CommandModule<T, U> {
|
||||
return {
|
||||
...commandModule,
|
||||
handler: (argv: ArgumentsCamelCase<U>) => {
|
||||
setDeferredCommand({
|
||||
handler: commandModule.handler as (
|
||||
argv: ArgumentsCamelCase,
|
||||
) => void | Promise<void>,
|
||||
argv: argv as unknown as ArgumentsCamelCase,
|
||||
commandName: parentCommandName || 'unknown',
|
||||
});
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -205,10 +205,14 @@ vi.mock('./config/sandboxConfig.js', () => ({
|
||||
vi.mock('./ui/utils/mouse.js', () => ({
|
||||
enableMouseEvents: vi.fn(),
|
||||
disableMouseEvents: vi.fn(),
|
||||
parseMouseEvent: vi.fn(),
|
||||
isIncompleteMouseSequence: vi.fn(),
|
||||
}));
|
||||
|
||||
const runNonInteractiveSpy = vi.hoisted(() => vi.fn());
|
||||
vi.mock('./nonInteractiveCli.js', () => ({
|
||||
runNonInteractive: runNonInteractiveSpy,
|
||||
}));
|
||||
|
||||
describe('gemini.tsx main function', () => {
|
||||
let originalEnvGeminiSandbox: string | undefined;
|
||||
let originalEnvSandbox: string | undefined;
|
||||
@@ -490,6 +494,9 @@ describe('gemini.tsx main function kitty protocol', () => {
|
||||
outputFormat: undefined,
|
||||
fakeResponses: undefined,
|
||||
recordResponses: undefined,
|
||||
rawOutput: undefined,
|
||||
acceptRawOutputRisk: undefined,
|
||||
isCommand: undefined,
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
@@ -559,6 +566,7 @@ describe('gemini.tsx main function kitty protocol', () => {
|
||||
getProjectRoot: () => '/',
|
||||
getRemoteAdminSettings: () => undefined,
|
||||
setTerminalBackground: vi.fn(),
|
||||
refreshAuth: vi.fn(),
|
||||
} as unknown as Config;
|
||||
|
||||
vi.mocked(loadCliConfig).mockResolvedValue(mockConfig);
|
||||
@@ -571,10 +579,13 @@ describe('gemini.tsx main function kitty protocol', () => {
|
||||
.spyOn(debugLogger, 'log')
|
||||
.mockImplementation(() => {});
|
||||
|
||||
process.env['GEMINI_API_KEY'] = 'test-key';
|
||||
try {
|
||||
await main();
|
||||
} catch (e) {
|
||||
if (!(e instanceof MockProcessExitError)) throw e;
|
||||
} finally {
|
||||
delete process.env['GEMINI_API_KEY'];
|
||||
}
|
||||
|
||||
if (flag === 'listExtensions') {
|
||||
@@ -646,18 +657,59 @@ describe('gemini.tsx main function kitty protocol', () => {
|
||||
refreshAuth: vi.fn(),
|
||||
getRemoteAdminSettings: () => undefined,
|
||||
setTerminalBackground: vi.fn(),
|
||||
getToolRegistry: () => ({ getAllTools: () => [] }),
|
||||
getModel: () => 'gemini-pro',
|
||||
getEmbeddingModel: () => 'embedding-model',
|
||||
getCoreTools: () => [],
|
||||
getApprovalMode: () => 'default',
|
||||
getPreviewFeatures: () => false,
|
||||
getTargetDir: () => '/',
|
||||
getUsageStatisticsEnabled: () => false,
|
||||
getTelemetryEnabled: () => false,
|
||||
getTelemetryTarget: () => 'none',
|
||||
getTelemetryOtlpEndpoint: () => '',
|
||||
getTelemetryOtlpProtocol: () => 'grpc',
|
||||
getTelemetryLogPromptsEnabled: () => false,
|
||||
getContinueOnFailedApiCall: () => false,
|
||||
getShellToolInactivityTimeout: () => 0,
|
||||
getTruncateToolOutputThreshold: () => 0,
|
||||
getUseRipgrep: () => false,
|
||||
getUseWriteTodos: () => false,
|
||||
getHooks: () => undefined,
|
||||
getExperiments: () => undefined,
|
||||
getFileFilteringRespectGitIgnore: () => true,
|
||||
getOutputFormat: () => 'text',
|
||||
getFolderTrust: () => false,
|
||||
getPendingIncludeDirectories: () => [],
|
||||
getWorkspaceContext: () => ({ getDirectories: () => ['/'] }),
|
||||
getModelAvailabilityService: () => ({
|
||||
reset: vi.fn(),
|
||||
resetTurn: vi.fn(),
|
||||
}),
|
||||
getBaseLlmClient: () => ({}),
|
||||
getGeminiClient: () => ({}),
|
||||
getContentGenerator: () => ({}),
|
||||
isTrustedFolder: () => true,
|
||||
isYoloModeDisabled: () => true,
|
||||
isPlanEnabled: () => false,
|
||||
isEventDrivenSchedulerEnabled: () => false,
|
||||
} as unknown as Config;
|
||||
|
||||
vi.mocked(loadCliConfig).mockResolvedValue(mockConfig);
|
||||
vi.mocked(loadSandboxConfig).mockResolvedValue({} as any); // eslint-disable-line @typescript-eslint/no-explicit-any
|
||||
vi.mocked(loadSandboxConfig).mockResolvedValue({
|
||||
command: 'docker',
|
||||
} as any); // eslint-disable-line @typescript-eslint/no-explicit-any
|
||||
vi.mocked(relaunchOnExitCode).mockImplementation(async (fn) => {
|
||||
await fn();
|
||||
});
|
||||
|
||||
process.env['GEMINI_API_KEY'] = 'test-key';
|
||||
try {
|
||||
await main();
|
||||
} catch (e) {
|
||||
if (!(e instanceof MockProcessExitError)) throw e;
|
||||
} finally {
|
||||
delete process.env['GEMINI_API_KEY'];
|
||||
}
|
||||
|
||||
expect(start_sandbox).toHaveBeenCalled();
|
||||
@@ -735,10 +787,13 @@ describe('gemini.tsx main function kitty protocol', () => {
|
||||
|
||||
vi.spyOn(themeManager, 'setActiveTheme').mockReturnValue(false);
|
||||
|
||||
process.env['GEMINI_API_KEY'] = 'test-key';
|
||||
try {
|
||||
await main();
|
||||
} catch (e) {
|
||||
if (!(e instanceof MockProcessExitError)) throw e;
|
||||
} finally {
|
||||
delete process.env['GEMINI_API_KEY'];
|
||||
}
|
||||
|
||||
expect(debugLoggerWarnSpy).toHaveBeenCalledWith(
|
||||
@@ -988,14 +1043,6 @@ describe('gemini.tsx main function kitty protocol', () => {
|
||||
vi.mock('./utils/readStdin.js', () => ({
|
||||
readStdin: vi.fn().mockResolvedValue('stdin-data'),
|
||||
}));
|
||||
const runNonInteractiveSpy = vi.hoisted(() => vi.fn());
|
||||
vi.mock('./nonInteractiveCli.js', () => ({
|
||||
runNonInteractive: runNonInteractiveSpy,
|
||||
}));
|
||||
runNonInteractiveSpy.mockClear();
|
||||
vi.mock('./validateNonInterActiveAuth.js', () => ({
|
||||
validateNonInteractiveAuth: vi.fn().mockResolvedValue({}),
|
||||
}));
|
||||
|
||||
// Mock stdin to be non-TTY
|
||||
Object.defineProperty(process.stdin, 'isTTY', {
|
||||
@@ -1003,10 +1050,13 @@ describe('gemini.tsx main function kitty protocol', () => {
|
||||
configurable: true,
|
||||
});
|
||||
|
||||
process.env['GEMINI_API_KEY'] = 'test-key';
|
||||
try {
|
||||
await main();
|
||||
} catch (e) {
|
||||
if (!(e instanceof MockProcessExitError)) throw e;
|
||||
} finally {
|
||||
delete process.env['GEMINI_API_KEY'];
|
||||
}
|
||||
|
||||
expect(readStdin).toHaveBeenCalled();
|
||||
@@ -1149,6 +1199,7 @@ describe('gemini.tsx main function exit codes', () => {
|
||||
storage: {
|
||||
getProjectTempDir: vi.fn().mockReturnValue('/tmp/gemini-test'),
|
||||
},
|
||||
refreshAuth: vi.fn(),
|
||||
} as unknown as Config);
|
||||
vi.mocked(loadSettings).mockReturnValue(
|
||||
createMockSettings({
|
||||
@@ -1167,12 +1218,15 @@ describe('gemini.tsx main function exit codes', () => {
|
||||
})),
|
||||
}));
|
||||
|
||||
process.env['GEMINI_API_KEY'] = 'test-key';
|
||||
try {
|
||||
await main();
|
||||
expect.fail('Should have thrown MockProcessExitError');
|
||||
} catch (e) {
|
||||
expect(e).toBeInstanceOf(MockProcessExitError);
|
||||
expect((e as MockProcessExitError).code).toBe(42);
|
||||
} finally {
|
||||
delete process.env['GEMINI_API_KEY'];
|
||||
}
|
||||
});
|
||||
|
||||
@@ -1217,6 +1271,7 @@ describe('gemini.tsx main function exit codes', () => {
|
||||
storage: {
|
||||
getProjectTempDir: vi.fn().mockReturnValue('/tmp/gemini-test'),
|
||||
},
|
||||
refreshAuth: vi.fn(),
|
||||
getRemoteAdminSettings: () => undefined,
|
||||
} as unknown as Config);
|
||||
vi.mocked(loadSettings).mockReturnValue(
|
||||
@@ -1230,14 +1285,93 @@ describe('gemini.tsx main function exit codes', () => {
|
||||
configurable: true,
|
||||
});
|
||||
|
||||
process.env['GEMINI_API_KEY'] = 'test-key';
|
||||
try {
|
||||
await main();
|
||||
expect.fail('Should have thrown MockProcessExitError');
|
||||
} catch (e) {
|
||||
expect(e).toBeInstanceOf(MockProcessExitError);
|
||||
expect((e as MockProcessExitError).code).toBe(42);
|
||||
} finally {
|
||||
delete process.env['GEMINI_API_KEY'];
|
||||
}
|
||||
});
|
||||
|
||||
it('should validate and refresh auth in non-interactive mode when no auth type is selected but env var is present', async () => {
|
||||
const { loadCliConfig, parseArguments } = await import(
|
||||
'./config/config.js'
|
||||
);
|
||||
const { loadSettings } = await import('./config/settings.js');
|
||||
const { AuthType } = await import('@google/gemini-cli-core');
|
||||
|
||||
const refreshAuthSpy = vi.fn();
|
||||
|
||||
vi.mocked(loadCliConfig).mockResolvedValue({
|
||||
isInteractive: () => false,
|
||||
getQuestion: () => 'test prompt',
|
||||
getSandbox: () => false,
|
||||
getDebugMode: () => false,
|
||||
getListExtensions: () => false,
|
||||
getListSessions: () => false,
|
||||
getDeleteSession: () => undefined,
|
||||
getMcpServers: () => ({}),
|
||||
getMcpClientManager: vi.fn(),
|
||||
initialize: vi.fn(),
|
||||
getIdeMode: () => false,
|
||||
getExperimentalZedIntegration: () => false,
|
||||
getScreenReader: () => false,
|
||||
getGeminiMdFileCount: () => 0,
|
||||
getPolicyEngine: vi.fn(),
|
||||
getMessageBus: () => ({ subscribe: vi.fn() }),
|
||||
getEnableHooks: () => false,
|
||||
getHookSystem: () => undefined,
|
||||
getToolRegistry: vi.fn(),
|
||||
getContentGeneratorConfig: vi.fn(),
|
||||
getModel: () => 'gemini-pro',
|
||||
getEmbeddingModel: () => 'embedding-001',
|
||||
getApprovalMode: () => 'default',
|
||||
getCoreTools: () => [],
|
||||
getTelemetryEnabled: () => false,
|
||||
getTelemetryLogPromptsEnabled: () => false,
|
||||
getFileFilteringRespectGitIgnore: () => true,
|
||||
getOutputFormat: () => 'text',
|
||||
getExtensions: () => [],
|
||||
getUsageStatisticsEnabled: () => false,
|
||||
setTerminalBackground: vi.fn(),
|
||||
storage: {
|
||||
getProjectTempDir: vi.fn().mockReturnValue('/tmp/gemini-test'),
|
||||
},
|
||||
refreshAuth: refreshAuthSpy,
|
||||
getRemoteAdminSettings: () => undefined,
|
||||
} as unknown as Config);
|
||||
|
||||
vi.mocked(loadSettings).mockReturnValue(
|
||||
createMockSettings({
|
||||
merged: { security: { auth: { selectedType: undefined } }, ui: {} },
|
||||
}),
|
||||
);
|
||||
vi.mocked(parseArguments).mockResolvedValue({} as unknown as CliArgs);
|
||||
|
||||
runNonInteractiveSpy.mockImplementation(() => Promise.resolve());
|
||||
|
||||
const processExitSpy = vi
|
||||
.spyOn(process, 'exit')
|
||||
.mockImplementation((code) => {
|
||||
throw new MockProcessExitError(code);
|
||||
});
|
||||
|
||||
process.env['GEMINI_API_KEY'] = 'test-key';
|
||||
try {
|
||||
await main();
|
||||
} catch (e) {
|
||||
if (!(e instanceof MockProcessExitError)) throw e;
|
||||
} finally {
|
||||
delete process.env['GEMINI_API_KEY'];
|
||||
processExitSpy.mockRestore();
|
||||
}
|
||||
|
||||
expect(refreshAuthSpy).toHaveBeenCalledWith(AuthType.USE_GEMINI);
|
||||
});
|
||||
});
|
||||
|
||||
describe('validateDnsResolutionOrder', () => {
|
||||
|
||||
@@ -96,6 +96,7 @@ import { isAlternateBufferEnabled } from './ui/hooks/useAlternateBuffer.js';
|
||||
|
||||
import { setupTerminalAndTheme } from './utils/terminalTheme.js';
|
||||
import { profiler } from './ui/components/DebugProfiler.js';
|
||||
import { runDeferredCommand } from './deferred.js';
|
||||
|
||||
const SLOW_RENDER_MS = 200;
|
||||
|
||||
@@ -372,12 +373,12 @@ export async function main() {
|
||||
// Refresh auth to fetch remote admin settings from CCPA and before entering
|
||||
// the sandbox because the sandbox will interfere with the Oauth2 web
|
||||
// redirect.
|
||||
if (
|
||||
settings.merged.security.auth.selectedType &&
|
||||
!settings.merged.security.auth.useExternal
|
||||
) {
|
||||
if (!settings.merged.security.auth.useExternal) {
|
||||
try {
|
||||
if (partialConfig.isInteractive()) {
|
||||
if (
|
||||
partialConfig.isInteractive() &&
|
||||
settings.merged.security.auth.selectedType
|
||||
) {
|
||||
const err = validateAuthMethod(
|
||||
settings.merged.security.auth.selectedType,
|
||||
);
|
||||
@@ -388,7 +389,7 @@ export async function main() {
|
||||
await partialConfig.refreshAuth(
|
||||
settings.merged.security.auth.selectedType,
|
||||
);
|
||||
} else {
|
||||
} else if (!partialConfig.isInteractive()) {
|
||||
const authType = await validateNonInteractiveAuth(
|
||||
settings.merged.security.auth.selectedType,
|
||||
settings.merged.security.auth.useExternal,
|
||||
@@ -410,6 +411,9 @@ export async function main() {
|
||||
settings.setRemoteAdminSettings(remoteAdminSettings);
|
||||
}
|
||||
|
||||
// Run deferred command now that we have admin settings.
|
||||
await runDeferredCommand(settings.merged);
|
||||
|
||||
// hop into sandbox if we are outside and sandboxing is enabled
|
||||
if (!process.env['SANDBOX']) {
|
||||
const memoryArgs = settings.merged.advanced.autoConfigureMemory
|
||||
@@ -724,6 +728,7 @@ function setWindowTitle(title: string, settings: LoadedSettings) {
|
||||
const windowTitle = computeTerminalTitle({
|
||||
streamingState: StreamingState.Idle,
|
||||
isConfirming: false,
|
||||
isSilentWorking: false,
|
||||
folderName: title,
|
||||
showThoughts: !!settings.merged.ui.showStatusInTitle,
|
||||
useDynamicTitle: settings.merged.ui.dynamicWindowTitle,
|
||||
|
||||
@@ -173,6 +173,8 @@ describe('runNonInteractive', () => {
|
||||
getModel: vi.fn().mockReturnValue('test-model'),
|
||||
getFolderTrust: vi.fn().mockReturnValue(false),
|
||||
isTrustedFolder: vi.fn().mockReturnValue(false),
|
||||
getRawOutput: vi.fn().mockReturnValue(false),
|
||||
getAcceptRawOutputRisk: vi.fn().mockReturnValue(false),
|
||||
} as unknown as Config;
|
||||
|
||||
mockSettings = {
|
||||
@@ -1745,6 +1747,121 @@ describe('runNonInteractive', () => {
|
||||
|
||||
// The key assertion: sendMessageStream should have been called ONLY ONCE (initial user input).
|
||||
expect(mockGeminiClient.sendMessageStream).toHaveBeenCalledTimes(1);
|
||||
|
||||
expect(processStderrSpy).toHaveBeenCalledWith(
|
||||
'Agent execution stopped: Stop reason from hook\n',
|
||||
);
|
||||
});
|
||||
|
||||
it('should write JSON output when a tool call returns STOP_EXECUTION error', async () => {
|
||||
vi.mocked(mockConfig.getOutputFormat).mockReturnValue(OutputFormat.JSON);
|
||||
vi.mocked(uiTelemetryService.getMetrics).mockReturnValue(
|
||||
MOCK_SESSION_METRICS,
|
||||
);
|
||||
|
||||
const toolCallEvent: ServerGeminiStreamEvent = {
|
||||
type: GeminiEventType.ToolCallRequest,
|
||||
value: {
|
||||
callId: 'stop-call',
|
||||
name: 'stopTool',
|
||||
args: {},
|
||||
isClientInitiated: false,
|
||||
prompt_id: 'prompt-id-stop-json',
|
||||
},
|
||||
};
|
||||
|
||||
mockCoreExecuteToolCall.mockResolvedValue({
|
||||
status: 'error',
|
||||
request: toolCallEvent.value,
|
||||
tool: {} as AnyDeclarativeTool,
|
||||
invocation: {} as AnyToolInvocation,
|
||||
response: {
|
||||
callId: 'stop-call',
|
||||
responseParts: [{ text: 'error occurred' }],
|
||||
errorType: ToolErrorType.STOP_EXECUTION,
|
||||
error: new Error('Stop reason'),
|
||||
resultDisplay: undefined,
|
||||
},
|
||||
});
|
||||
|
||||
const firstCallEvents: ServerGeminiStreamEvent[] = [
|
||||
{ type: GeminiEventType.Content, value: 'Partial content' },
|
||||
toolCallEvent,
|
||||
];
|
||||
|
||||
mockGeminiClient.sendMessageStream.mockReturnValue(
|
||||
createStreamFromEvents(firstCallEvents),
|
||||
);
|
||||
|
||||
await runNonInteractive({
|
||||
config: mockConfig,
|
||||
settings: mockSettings,
|
||||
input: 'Run stop tool',
|
||||
prompt_id: 'prompt-id-stop-json',
|
||||
});
|
||||
|
||||
expect(processStdoutSpy).toHaveBeenCalledWith(
|
||||
JSON.stringify(
|
||||
{
|
||||
session_id: 'test-session-id',
|
||||
response: 'Partial content',
|
||||
stats: MOCK_SESSION_METRICS,
|
||||
},
|
||||
null,
|
||||
2,
|
||||
),
|
||||
);
|
||||
});
|
||||
|
||||
it('should emit result event when a tool call returns STOP_EXECUTION error in streaming JSON mode', async () => {
|
||||
vi.mocked(mockConfig.getOutputFormat).mockReturnValue(
|
||||
OutputFormat.STREAM_JSON,
|
||||
);
|
||||
vi.mocked(uiTelemetryService.getMetrics).mockReturnValue(
|
||||
MOCK_SESSION_METRICS,
|
||||
);
|
||||
|
||||
const toolCallEvent: ServerGeminiStreamEvent = {
|
||||
type: GeminiEventType.ToolCallRequest,
|
||||
value: {
|
||||
callId: 'stop-call',
|
||||
name: 'stopTool',
|
||||
args: {},
|
||||
isClientInitiated: false,
|
||||
prompt_id: 'prompt-id-stop-stream',
|
||||
},
|
||||
};
|
||||
|
||||
mockCoreExecuteToolCall.mockResolvedValue({
|
||||
status: 'error',
|
||||
request: toolCallEvent.value,
|
||||
tool: {} as AnyDeclarativeTool,
|
||||
invocation: {} as AnyToolInvocation,
|
||||
response: {
|
||||
callId: 'stop-call',
|
||||
responseParts: [{ text: 'error occurred' }],
|
||||
errorType: ToolErrorType.STOP_EXECUTION,
|
||||
error: new Error('Stop reason'),
|
||||
resultDisplay: undefined,
|
||||
},
|
||||
});
|
||||
|
||||
const firstCallEvents: ServerGeminiStreamEvent[] = [toolCallEvent];
|
||||
|
||||
mockGeminiClient.sendMessageStream.mockReturnValue(
|
||||
createStreamFromEvents(firstCallEvents),
|
||||
);
|
||||
|
||||
await runNonInteractive({
|
||||
config: mockConfig,
|
||||
settings: mockSettings,
|
||||
input: 'Run stop tool',
|
||||
prompt_id: 'prompt-id-stop-stream',
|
||||
});
|
||||
|
||||
const output = getWrittenOutput();
|
||||
expect(output).toContain('"type":"result"');
|
||||
expect(output).toContain('"status":"success"');
|
||||
});
|
||||
|
||||
describe('Agent Execution Events', () => {
|
||||
@@ -1805,4 +1922,142 @@ describe('runNonInteractive', () => {
|
||||
expect(getWrittenOutput()).toBe('Final answer\n');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Output Sanitization', () => {
|
||||
const ANSI_SEQUENCE = '\u001B[31mRed Text\u001B[0m';
|
||||
const OSC_HYPERLINK =
|
||||
'\u001B]8;;http://example.com\u001B\\Link\u001B]8;;\u001B\\';
|
||||
const PLAIN_TEXT_RED = 'Red Text';
|
||||
const PLAIN_TEXT_LINK = 'Link';
|
||||
|
||||
it('should sanitize ANSI output by default', async () => {
|
||||
const events: ServerGeminiStreamEvent[] = [
|
||||
{ type: GeminiEventType.Content, value: ANSI_SEQUENCE },
|
||||
{ type: GeminiEventType.Content, value: ' ' },
|
||||
{ type: GeminiEventType.Content, value: OSC_HYPERLINK },
|
||||
{
|
||||
type: GeminiEventType.Finished,
|
||||
value: { reason: undefined, usageMetadata: { totalTokenCount: 10 } },
|
||||
},
|
||||
];
|
||||
mockGeminiClient.sendMessageStream.mockReturnValue(
|
||||
createStreamFromEvents(events),
|
||||
);
|
||||
|
||||
vi.mocked(mockConfig.getRawOutput).mockReturnValue(false);
|
||||
|
||||
await runNonInteractive({
|
||||
config: mockConfig,
|
||||
settings: mockSettings,
|
||||
input: 'Test input',
|
||||
prompt_id: 'prompt-id-sanitization',
|
||||
});
|
||||
|
||||
expect(getWrittenOutput()).toBe(`${PLAIN_TEXT_RED} ${PLAIN_TEXT_LINK}\n`);
|
||||
});
|
||||
|
||||
it('should allow ANSI output when rawOutput is true', async () => {
|
||||
const events: ServerGeminiStreamEvent[] = [
|
||||
{ type: GeminiEventType.Content, value: ANSI_SEQUENCE },
|
||||
{ type: GeminiEventType.Content, value: ' ' },
|
||||
{ type: GeminiEventType.Content, value: OSC_HYPERLINK },
|
||||
{
|
||||
type: GeminiEventType.Finished,
|
||||
value: { reason: undefined, usageMetadata: { totalTokenCount: 10 } },
|
||||
},
|
||||
];
|
||||
mockGeminiClient.sendMessageStream.mockReturnValue(
|
||||
createStreamFromEvents(events),
|
||||
);
|
||||
|
||||
vi.mocked(mockConfig.getRawOutput).mockReturnValue(true);
|
||||
vi.mocked(mockConfig.getAcceptRawOutputRisk).mockReturnValue(true);
|
||||
|
||||
await runNonInteractive({
|
||||
config: mockConfig,
|
||||
settings: mockSettings,
|
||||
input: 'Test input',
|
||||
prompt_id: 'prompt-id-raw',
|
||||
});
|
||||
|
||||
expect(getWrittenOutput()).toBe(`${ANSI_SEQUENCE} ${OSC_HYPERLINK}\n`);
|
||||
});
|
||||
|
||||
it('should allow ANSI output when only acceptRawOutputRisk is true', async () => {
|
||||
const events: ServerGeminiStreamEvent[] = [
|
||||
{ type: GeminiEventType.Content, value: ANSI_SEQUENCE },
|
||||
{
|
||||
type: GeminiEventType.Finished,
|
||||
value: { reason: undefined, usageMetadata: { totalTokenCount: 5 } },
|
||||
},
|
||||
];
|
||||
mockGeminiClient.sendMessageStream.mockReturnValue(
|
||||
createStreamFromEvents(events),
|
||||
);
|
||||
|
||||
vi.mocked(mockConfig.getRawOutput).mockReturnValue(false);
|
||||
vi.mocked(mockConfig.getAcceptRawOutputRisk).mockReturnValue(true);
|
||||
|
||||
await runNonInteractive({
|
||||
config: mockConfig,
|
||||
settings: mockSettings,
|
||||
input: 'Test input',
|
||||
prompt_id: 'prompt-id-accept-only',
|
||||
});
|
||||
|
||||
expect(getWrittenOutput()).toBe(`${ANSI_SEQUENCE}\n`);
|
||||
});
|
||||
|
||||
it('should warn when rawOutput is true and acceptRisk is false', async () => {
|
||||
const events: ServerGeminiStreamEvent[] = [
|
||||
{
|
||||
type: GeminiEventType.Finished,
|
||||
value: { reason: undefined, usageMetadata: { totalTokenCount: 0 } },
|
||||
},
|
||||
];
|
||||
mockGeminiClient.sendMessageStream.mockReturnValue(
|
||||
createStreamFromEvents(events),
|
||||
);
|
||||
|
||||
vi.mocked(mockConfig.getRawOutput).mockReturnValue(true);
|
||||
vi.mocked(mockConfig.getAcceptRawOutputRisk).mockReturnValue(false);
|
||||
|
||||
await runNonInteractive({
|
||||
config: mockConfig,
|
||||
settings: mockSettings,
|
||||
input: 'Test input',
|
||||
prompt_id: 'prompt-id-warn',
|
||||
});
|
||||
|
||||
expect(processStderrSpy).toHaveBeenCalledWith(
|
||||
expect.stringContaining('[WARNING] --raw-output is enabled'),
|
||||
);
|
||||
});
|
||||
|
||||
it('should not warn when rawOutput is true and acceptRisk is true', async () => {
|
||||
const events: ServerGeminiStreamEvent[] = [
|
||||
{
|
||||
type: GeminiEventType.Finished,
|
||||
value: { reason: undefined, usageMetadata: { totalTokenCount: 0 } },
|
||||
},
|
||||
];
|
||||
mockGeminiClient.sendMessageStream.mockReturnValue(
|
||||
createStreamFromEvents(events),
|
||||
);
|
||||
|
||||
vi.mocked(mockConfig.getRawOutput).mockReturnValue(true);
|
||||
vi.mocked(mockConfig.getAcceptRawOutputRisk).mockReturnValue(true);
|
||||
|
||||
await runNonInteractive({
|
||||
config: mockConfig,
|
||||
settings: mockSettings,
|
||||
input: 'Test input',
|
||||
prompt_id: 'prompt-id-no-warn',
|
||||
});
|
||||
|
||||
expect(processStderrSpy).not.toHaveBeenCalledWith(
|
||||
expect.stringContaining('[WARNING] --raw-output is enabled'),
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -33,6 +33,7 @@ import {
|
||||
|
||||
import type { Content, Part } from '@google/genai';
|
||||
import readline from 'node:readline';
|
||||
import stripAnsi from 'strip-ansi';
|
||||
|
||||
import { convertSessionToHistoryFormats } from './ui/hooks/useSessionBrowser.js';
|
||||
import { handleSlashCommand } from './nonInteractiveCliCommands.js';
|
||||
@@ -176,6 +177,16 @@ export async function runNonInteractive({
|
||||
try {
|
||||
consolePatcher.patch();
|
||||
|
||||
if (
|
||||
config.getRawOutput() &&
|
||||
!config.getAcceptRawOutputRisk() &&
|
||||
config.getOutputFormat() === OutputFormat.TEXT
|
||||
) {
|
||||
process.stderr.write(
|
||||
'[WARNING] --raw-output is enabled. Model output is not sanitized and may contain harmful ANSI sequences (e.g. for phishing or command injection). Use --accept-raw-output-risk to suppress this warning.\n',
|
||||
);
|
||||
}
|
||||
|
||||
// Setup stdin cancellation listener
|
||||
setupStdinCancellation();
|
||||
|
||||
@@ -285,19 +296,22 @@ export async function runNonInteractive({
|
||||
}
|
||||
|
||||
if (event.type === GeminiEventType.Content) {
|
||||
const isRaw =
|
||||
config.getRawOutput() || config.getAcceptRawOutputRisk();
|
||||
const output = isRaw ? event.value : stripAnsi(event.value);
|
||||
if (streamFormatter) {
|
||||
streamFormatter.emitEvent({
|
||||
type: JsonStreamEventType.MESSAGE,
|
||||
timestamp: new Date().toISOString(),
|
||||
role: 'assistant',
|
||||
content: event.value,
|
||||
content: output,
|
||||
delta: true,
|
||||
});
|
||||
} else if (config.getOutputFormat() === OutputFormat.JSON) {
|
||||
responseText += event.value;
|
||||
responseText += output;
|
||||
} else {
|
||||
if (event.value) {
|
||||
textOutput.write(event.value);
|
||||
textOutput.write(output);
|
||||
}
|
||||
}
|
||||
} else if (event.type === GeminiEventType.ToolCallRequest) {
|
||||
|
||||
@@ -14,7 +14,6 @@ import { KeypressProvider } from '../ui/contexts/KeypressContext.js';
|
||||
import { SettingsContext } from '../ui/contexts/SettingsContext.js';
|
||||
import { ShellFocusContext } from '../ui/contexts/ShellFocusContext.js';
|
||||
import { UIStateContext, type UIState } from '../ui/contexts/UIStateContext.js';
|
||||
import { StreamingState } from '../ui/types.js';
|
||||
import { ConfigContext } from '../ui/contexts/ConfigContext.js';
|
||||
import { calculateMainAreaWidth } from '../ui/utils/ui-sizing.js';
|
||||
import { VimModeProvider } from '../ui/contexts/VimModeContext.js';
|
||||
@@ -25,6 +24,8 @@ import {
|
||||
type UIActions,
|
||||
UIActionsContext,
|
||||
} from '../ui/contexts/UIActionsContext.js';
|
||||
import { type HistoryItemToolGroup, StreamingState } from '../ui/types.js';
|
||||
import { ToolActionsProvider } from '../ui/contexts/ToolActionsContext.js';
|
||||
|
||||
import { type Config } from '@google/gemini-cli-core';
|
||||
|
||||
@@ -166,6 +167,7 @@ const mockUIActions: UIActions = {
|
||||
handleFinalSubmit: vi.fn(),
|
||||
handleClearScreen: vi.fn(),
|
||||
handleProQuotaChoice: vi.fn(),
|
||||
handleValidationChoice: vi.fn(),
|
||||
setQueueErrorMessage: vi.fn(),
|
||||
popAllMessages: vi.fn(),
|
||||
handleApiKeySubmit: vi.fn(),
|
||||
@@ -238,6 +240,10 @@ export const renderWithProviders = (
|
||||
|
||||
const finalUIActions = { ...mockUIActions, ...uiActions };
|
||||
|
||||
const allToolCalls = (finalUiState.pendingHistoryItems || [])
|
||||
.filter((item): item is HistoryItemToolGroup => item.type === 'tool_group')
|
||||
.flatMap((item) => item.tools);
|
||||
|
||||
const renderResult = render(
|
||||
<ConfigContext.Provider value={config}>
|
||||
<SettingsContext.Provider value={finalSettings}>
|
||||
@@ -246,20 +252,22 @@ export const renderWithProviders = (
|
||||
<ShellFocusContext.Provider value={shellFocus}>
|
||||
<StreamingContext.Provider value={finalUiState.streamingState}>
|
||||
<UIActionsContext.Provider value={finalUIActions}>
|
||||
<KeypressProvider>
|
||||
<MouseProvider mouseEventsEnabled={mouseEventsEnabled}>
|
||||
<ScrollProvider>
|
||||
<Box
|
||||
width={terminalWidth}
|
||||
flexShrink={0}
|
||||
flexGrow={0}
|
||||
flexDirection="column"
|
||||
>
|
||||
{component}
|
||||
</Box>
|
||||
</ScrollProvider>
|
||||
</MouseProvider>
|
||||
</KeypressProvider>
|
||||
<ToolActionsProvider config={config} toolCalls={allToolCalls}>
|
||||
<KeypressProvider>
|
||||
<MouseProvider mouseEventsEnabled={mouseEventsEnabled}>
|
||||
<ScrollProvider>
|
||||
<Box
|
||||
width={terminalWidth}
|
||||
flexShrink={0}
|
||||
flexGrow={0}
|
||||
flexDirection="column"
|
||||
>
|
||||
{component}
|
||||
</Box>
|
||||
</ScrollProvider>
|
||||
</MouseProvider>
|
||||
</KeypressProvider>
|
||||
</ToolActionsProvider>
|
||||
</UIActionsContext.Provider>
|
||||
</StreamingContext.Provider>
|
||||
</ShellFocusContext.Provider>
|
||||
|
||||
@@ -20,6 +20,7 @@ import { cleanup } from 'ink-testing-library';
|
||||
import { act, useContext, type ReactElement } from 'react';
|
||||
import { AppContainer } from './AppContainer.js';
|
||||
import { SettingsContext } from './contexts/SettingsContext.js';
|
||||
import { type TrackedToolCall } from './hooks/useReactToolScheduler.js';
|
||||
import {
|
||||
type Config,
|
||||
makeFakeConfig,
|
||||
@@ -136,7 +137,7 @@ vi.mock('./hooks/useLoadingIndicator.js');
|
||||
vi.mock('./hooks/useFolderTrust.js');
|
||||
vi.mock('./hooks/useIdeTrustListener.js');
|
||||
vi.mock('./hooks/useMessageQueue.js');
|
||||
vi.mock('./hooks/useAutoAcceptIndicator.js');
|
||||
vi.mock('./hooks/useApprovalModeIndicator.js');
|
||||
vi.mock('./hooks/useGitBranchName.js');
|
||||
vi.mock('./contexts/VimModeContext.js');
|
||||
vi.mock('./contexts/SessionContext.js');
|
||||
@@ -164,7 +165,7 @@ import { useVim } from './hooks/vim.js';
|
||||
import { useFolderTrust } from './hooks/useFolderTrust.js';
|
||||
import { useIdeTrustListener } from './hooks/useIdeTrustListener.js';
|
||||
import { useMessageQueue } from './hooks/useMessageQueue.js';
|
||||
import { useAutoAcceptIndicator } from './hooks/useAutoAcceptIndicator.js';
|
||||
import { useApprovalModeIndicator } from './hooks/useApprovalModeIndicator.js';
|
||||
import { useGitBranchName } from './hooks/useGitBranchName.js';
|
||||
import { useVimMode } from './contexts/VimModeContext.js';
|
||||
import { useSessionStats } from './contexts/SessionContext.js';
|
||||
@@ -236,7 +237,7 @@ describe('AppContainer State Management', () => {
|
||||
const mockedUseFolderTrust = useFolderTrust as Mock;
|
||||
const mockedUseIdeTrustListener = useIdeTrustListener as Mock;
|
||||
const mockedUseMessageQueue = useMessageQueue as Mock;
|
||||
const mockedUseAutoAcceptIndicator = useAutoAcceptIndicator as Mock;
|
||||
const mockedUseApprovalModeIndicator = useApprovalModeIndicator as Mock;
|
||||
const mockedUseGitBranchName = useGitBranchName as Mock;
|
||||
const mockedUseVimMode = useVimMode as Mock;
|
||||
const mockedUseSessionStats = useSessionStats as Mock;
|
||||
@@ -335,7 +336,7 @@ describe('AppContainer State Management', () => {
|
||||
clearQueue: vi.fn(),
|
||||
getQueuedMessagesText: vi.fn().mockReturnValue(''),
|
||||
});
|
||||
mockedUseAutoAcceptIndicator.mockReturnValue(false);
|
||||
mockedUseApprovalModeIndicator.mockReturnValue(false);
|
||||
mockedUseGitBranchName.mockReturnValue('main');
|
||||
mockedUseVimMode.mockReturnValue({
|
||||
isVimEnabled: false,
|
||||
@@ -1274,8 +1275,12 @@ describe('AppContainer State Management', () => {
|
||||
pendingHistoryItems: [],
|
||||
thought: { subject: 'Executing shell command' },
|
||||
cancelOngoingRequest: vi.fn(),
|
||||
pendingToolCalls: [],
|
||||
handleApprovalModeChange: vi.fn(),
|
||||
activePtyId: 'pty-1',
|
||||
lastOutputTime: 0,
|
||||
loopDetectionConfirmationRequest: null,
|
||||
lastOutputTime: startTime + 100, // Trigger aggressive delay
|
||||
retryStatus: null,
|
||||
});
|
||||
|
||||
vi.spyOn(mockConfig, 'isInteractive').mockReturnValue(true);
|
||||
@@ -1309,6 +1314,136 @@ describe('AppContainer State Management', () => {
|
||||
unmount();
|
||||
});
|
||||
|
||||
it('should show Working… in title for redirected commands after 2 mins', async () => {
|
||||
const startTime = 1000000;
|
||||
vi.setSystemTime(startTime);
|
||||
|
||||
// Arrange: Set up mock settings with showStatusInTitle enabled
|
||||
const mockSettingsWithTitleEnabled = {
|
||||
...mockSettings,
|
||||
merged: {
|
||||
...mockSettings.merged,
|
||||
ui: {
|
||||
...mockSettings.merged.ui,
|
||||
showStatusInTitle: true,
|
||||
hideWindowTitle: false,
|
||||
},
|
||||
},
|
||||
} as unknown as LoadedSettings;
|
||||
|
||||
// Mock an active shell pty with redirection active
|
||||
mockedUseGeminiStream.mockReturnValue({
|
||||
streamingState: 'responding',
|
||||
submitQuery: vi.fn(),
|
||||
initError: null,
|
||||
pendingHistoryItems: [],
|
||||
thought: { subject: 'Executing shell command' },
|
||||
cancelOngoingRequest: vi.fn(),
|
||||
pendingToolCalls: [
|
||||
{
|
||||
request: {
|
||||
name: 'run_shell_command',
|
||||
args: { command: 'ls > out' },
|
||||
},
|
||||
status: 'executing',
|
||||
} as unknown as TrackedToolCall,
|
||||
],
|
||||
handleApprovalModeChange: vi.fn(),
|
||||
activePtyId: 'pty-1',
|
||||
loopDetectionConfirmationRequest: null,
|
||||
lastOutputTime: startTime,
|
||||
retryStatus: null,
|
||||
});
|
||||
|
||||
vi.spyOn(mockConfig, 'isInteractive').mockReturnValue(true);
|
||||
vi.spyOn(mockConfig, 'isInteractiveShellEnabled').mockReturnValue(true);
|
||||
|
||||
const { unmount } = renderAppContainer({
|
||||
settings: mockSettingsWithTitleEnabled,
|
||||
});
|
||||
|
||||
// Fast-forward time by 65 seconds - should still NOT be Action Required
|
||||
await act(async () => {
|
||||
await vi.advanceTimersByTimeAsync(65000);
|
||||
});
|
||||
|
||||
const titleWritesMid = mocks.mockStdout.write.mock.calls.filter(
|
||||
(call) => call[0].includes('\x1b]0;'),
|
||||
);
|
||||
expect(titleWritesMid[titleWritesMid.length - 1][0]).not.toContain(
|
||||
'✋ Action Required',
|
||||
);
|
||||
|
||||
// Fast-forward to 2 minutes (120000ms)
|
||||
await act(async () => {
|
||||
await vi.advanceTimersByTimeAsync(60000);
|
||||
});
|
||||
|
||||
const titleWritesEnd = mocks.mockStdout.write.mock.calls.filter(
|
||||
(call) => call[0].includes('\x1b]0;'),
|
||||
);
|
||||
expect(titleWritesEnd[titleWritesEnd.length - 1][0]).toContain(
|
||||
'⏲ Working…',
|
||||
);
|
||||
|
||||
unmount();
|
||||
});
|
||||
|
||||
it('should show Working… in title for silent non-redirected commands after 1 min', async () => {
|
||||
const startTime = 1000000;
|
||||
vi.setSystemTime(startTime);
|
||||
|
||||
// Arrange: Set up mock settings with showStatusInTitle enabled
|
||||
const mockSettingsWithTitleEnabled = {
|
||||
...mockSettings,
|
||||
merged: {
|
||||
...mockSettings.merged,
|
||||
ui: {
|
||||
...mockSettings.merged.ui,
|
||||
showStatusInTitle: true,
|
||||
hideWindowTitle: false,
|
||||
},
|
||||
},
|
||||
} as unknown as LoadedSettings;
|
||||
|
||||
// Mock an active shell pty with NO output since operation started (silent)
|
||||
mockedUseGeminiStream.mockReturnValue({
|
||||
streamingState: 'responding',
|
||||
submitQuery: vi.fn(),
|
||||
initError: null,
|
||||
pendingHistoryItems: [],
|
||||
thought: { subject: 'Executing shell command' },
|
||||
cancelOngoingRequest: vi.fn(),
|
||||
pendingToolCalls: [],
|
||||
handleApprovalModeChange: vi.fn(),
|
||||
activePtyId: 'pty-1',
|
||||
loopDetectionConfirmationRequest: null,
|
||||
lastOutputTime: startTime, // lastOutputTime <= operationStartTime
|
||||
retryStatus: null,
|
||||
});
|
||||
|
||||
vi.spyOn(mockConfig, 'isInteractive').mockReturnValue(true);
|
||||
vi.spyOn(mockConfig, 'isInteractiveShellEnabled').mockReturnValue(true);
|
||||
|
||||
const { unmount } = renderAppContainer({
|
||||
settings: mockSettingsWithTitleEnabled,
|
||||
});
|
||||
|
||||
// Fast-forward time by 65 seconds
|
||||
await act(async () => {
|
||||
await vi.advanceTimersByTimeAsync(65000);
|
||||
});
|
||||
|
||||
const titleWrites = mocks.mockStdout.write.mock.calls.filter((call) =>
|
||||
call[0].includes('\x1b]0;'),
|
||||
);
|
||||
const lastTitle = titleWrites[titleWrites.length - 1][0];
|
||||
// Should show Working… (⏲) instead of Action Required (✋)
|
||||
expect(lastTitle).toContain('⏲ Working…');
|
||||
|
||||
unmount();
|
||||
});
|
||||
|
||||
it('should NOT show Action Required in title if shell is streaming output', async () => {
|
||||
const startTime = 1000000;
|
||||
vi.setSystemTime(startTime);
|
||||
@@ -1327,7 +1462,7 @@ describe('AppContainer State Management', () => {
|
||||
} as unknown as LoadedSettings;
|
||||
|
||||
// Mock an active shell pty but not focused
|
||||
let lastOutputTime = 1000;
|
||||
let lastOutputTime = startTime + 1000;
|
||||
mockedUseGeminiStream.mockImplementation(() => ({
|
||||
streamingState: 'responding',
|
||||
submitQuery: vi.fn(),
|
||||
@@ -1353,7 +1488,7 @@ describe('AppContainer State Management', () => {
|
||||
});
|
||||
|
||||
// Update lastOutputTime to simulate new output
|
||||
lastOutputTime = 21000;
|
||||
lastOutputTime = startTime + 21000;
|
||||
mockedUseGeminiStream.mockImplementation(() => ({
|
||||
streamingState: 'responding',
|
||||
submitQuery: vi.fn(),
|
||||
@@ -1660,9 +1795,10 @@ describe('AppContainer State Management', () => {
|
||||
act(() => {
|
||||
handleGlobalKeypress({
|
||||
name: 'c',
|
||||
ctrl: false,
|
||||
meta: false,
|
||||
shift: false,
|
||||
alt: false,
|
||||
ctrl: false,
|
||||
cmd: false,
|
||||
...key,
|
||||
} as Key);
|
||||
});
|
||||
@@ -1870,9 +2006,10 @@ describe('AppContainer State Management', () => {
|
||||
act(() => {
|
||||
handleGlobalKeypress({
|
||||
name: 's',
|
||||
ctrl: true,
|
||||
meta: false,
|
||||
shift: false,
|
||||
alt: false,
|
||||
ctrl: true,
|
||||
cmd: false,
|
||||
insertable: false,
|
||||
sequence: '\x13',
|
||||
});
|
||||
@@ -1896,9 +2033,10 @@ describe('AppContainer State Management', () => {
|
||||
act(() => {
|
||||
handleGlobalKeypress({
|
||||
name: 's',
|
||||
ctrl: true,
|
||||
meta: false,
|
||||
shift: false,
|
||||
alt: false,
|
||||
ctrl: true,
|
||||
cmd: false,
|
||||
insertable: false,
|
||||
sequence: '\x13',
|
||||
});
|
||||
@@ -1910,9 +2048,10 @@ describe('AppContainer State Management', () => {
|
||||
act(() => {
|
||||
handleGlobalKeypress({
|
||||
name: 'any', // Any key should exit copy mode
|
||||
ctrl: false,
|
||||
meta: false,
|
||||
shift: false,
|
||||
alt: false,
|
||||
ctrl: false,
|
||||
cmd: false,
|
||||
insertable: true,
|
||||
sequence: 'a',
|
||||
});
|
||||
@@ -1930,9 +2069,10 @@ describe('AppContainer State Management', () => {
|
||||
act(() => {
|
||||
handleGlobalKeypress({
|
||||
name: 's',
|
||||
ctrl: true,
|
||||
meta: false,
|
||||
shift: false,
|
||||
alt: false,
|
||||
ctrl: true,
|
||||
cmd: false,
|
||||
insertable: false,
|
||||
sequence: '\x13',
|
||||
});
|
||||
@@ -1945,9 +2085,10 @@ describe('AppContainer State Management', () => {
|
||||
act(() => {
|
||||
handleGlobalKeypress({
|
||||
name: 'a',
|
||||
ctrl: false,
|
||||
meta: false,
|
||||
shift: false,
|
||||
alt: false,
|
||||
ctrl: false,
|
||||
cmd: false,
|
||||
insertable: true,
|
||||
sequence: 'a',
|
||||
});
|
||||
|
||||
@@ -25,9 +25,11 @@ import {
|
||||
type HistoryItem,
|
||||
ToolCallStatus,
|
||||
type HistoryItemWithoutId,
|
||||
type HistoryItemToolGroup,
|
||||
AuthState,
|
||||
} from './types.js';
|
||||
import { MessageType, StreamingState } from './types.js';
|
||||
import { ToolActionsProvider } from './contexts/ToolActionsContext.js';
|
||||
import {
|
||||
type EditorType,
|
||||
type Config,
|
||||
@@ -60,7 +62,9 @@ import {
|
||||
SessionStartSource,
|
||||
SessionEndReason,
|
||||
generateSummary,
|
||||
} from '@google/gemini-cli-core';
|
||||
|
||||
type AgentDefinition,
|
||||
type AgentsDiscoveredPayload} from '@google/gemini-cli-core';
|
||||
import { validateAuthMethod } from '../config/auth.js';
|
||||
import process from 'node:process';
|
||||
import { useHistory } from './hooks/useHistoryManager.js';
|
||||
@@ -92,6 +96,7 @@ import { useFocus } from './hooks/useFocus.js';
|
||||
import { useKeypress, type Key } from './hooks/useKeypress.js';
|
||||
import { keyMatchers, Command } from './keyMatchers.js';
|
||||
import { useLoadingIndicator } from './hooks/useLoadingIndicator.js';
|
||||
import { useShellInactivityStatus } from './hooks/useShellInactivityStatus.js';
|
||||
import { useFolderTrust } from './hooks/useFolderTrust.js';
|
||||
import { useIdeTrustListener } from './hooks/useIdeTrustListener.js';
|
||||
import { type IdeIntegrationNudgeResult } from './IdeIntegrationNudge.js';
|
||||
@@ -102,7 +107,7 @@ import { registerCleanup, runExitCleanup } from '../utils/cleanup.js';
|
||||
import { RELAUNCH_EXIT_CODE } from '../utils/processUtils.js';
|
||||
import type { SessionInfo } from '../utils/sessionUtils.js';
|
||||
import { useMessageQueue } from './hooks/useMessageQueue.js';
|
||||
import { useAutoAcceptIndicator } from './hooks/useAutoAcceptIndicator.js';
|
||||
import { useApprovalModeIndicator } from './hooks/useApprovalModeIndicator.js';
|
||||
import { useSessionStats } from './contexts/SessionContext.js';
|
||||
import { useGitBranchName } from './hooks/useGitBranchName.js';
|
||||
import {
|
||||
@@ -125,10 +130,12 @@ import { useHookDisplayState } from './hooks/useHookDisplayState.js';
|
||||
import {
|
||||
WARNING_PROMPT_DURATION_MS,
|
||||
QUEUE_ERROR_DISPLAY_DURATION_MS,
|
||||
SHELL_ACTION_REQUIRED_TITLE_DELAY_MS,
|
||||
} from './constants.js';
|
||||
import { LoginWithGoogleRestartDialog } from './auth/LoginWithGoogleRestartDialog.js';
|
||||
import { useInactivityTimer } from './hooks/useInactivityTimer.js';
|
||||
import {
|
||||
NewAgentsNotification,
|
||||
NewAgentsChoice,
|
||||
} from './components/NewAgentsNotification.js';
|
||||
|
||||
function isToolExecuting(pendingHistoryItems: HistoryItemWithoutId[]) {
|
||||
return pendingHistoryItems.some((item) => {
|
||||
@@ -203,6 +210,8 @@ export const AppContainer = (props: AppContainerProps) => {
|
||||
null,
|
||||
);
|
||||
|
||||
const [newAgents, setNewAgents] = useState<AgentDefinition[] | null>(null);
|
||||
|
||||
const [defaultBannerText, setDefaultBannerText] = useState('');
|
||||
const [warningBannerText, setWarningBannerText] = useState('');
|
||||
const [bannerVisible, setBannerVisible] = useState(true);
|
||||
@@ -369,14 +378,20 @@ export const AppContainer = (props: AppContainerProps) => {
|
||||
setAdminSettingsChanged(true);
|
||||
};
|
||||
|
||||
const handleAgentsDiscovered = (payload: AgentsDiscoveredPayload) => {
|
||||
setNewAgents(payload.agents);
|
||||
};
|
||||
|
||||
coreEvents.on(CoreEvent.SettingsChanged, handleSettingsChanged);
|
||||
coreEvents.on(CoreEvent.AdminSettingsChanged, handleAdminSettingsChanged);
|
||||
coreEvents.on(CoreEvent.AgentsDiscovered, handleAgentsDiscovered);
|
||||
return () => {
|
||||
coreEvents.off(CoreEvent.SettingsChanged, handleSettingsChanged);
|
||||
coreEvents.off(
|
||||
CoreEvent.AdminSettingsChanged,
|
||||
handleAdminSettingsChanged,
|
||||
);
|
||||
coreEvents.off(CoreEvent.AgentsDiscovered, handleAgentsDiscovered);
|
||||
};
|
||||
}, []);
|
||||
|
||||
@@ -495,7 +510,12 @@ export const AppContainer = (props: AppContainerProps) => {
|
||||
}
|
||||
}, [authState, authContext, setAuthState]);
|
||||
|
||||
const { proQuotaRequest, handleProQuotaChoice } = useQuotaAndFallback({
|
||||
const {
|
||||
proQuotaRequest,
|
||||
handleProQuotaChoice,
|
||||
validationRequest,
|
||||
handleValidationChoice,
|
||||
} = useQuotaAndFallback({
|
||||
config,
|
||||
historyManager,
|
||||
userTier,
|
||||
@@ -807,6 +827,7 @@ Logging in with Google... Restarting Gemini CLI to continue.
|
||||
pendingHistoryItems: pendingGeminiHistoryItems,
|
||||
thought,
|
||||
cancelOngoingRequest,
|
||||
pendingToolCalls,
|
||||
handleApprovalModeChange,
|
||||
activePtyId,
|
||||
loopDetectionConfirmationRequest,
|
||||
@@ -838,18 +859,20 @@ Logging in with Google... Restarting Gemini CLI to continue.
|
||||
lastOutputTimeRef.current = lastOutputTime;
|
||||
}, [lastOutputTime]);
|
||||
|
||||
const isShellAwaitingFocus =
|
||||
!!activePtyId &&
|
||||
!embeddedShellFocused &&
|
||||
config.isInteractiveShellEnabled();
|
||||
const showShellActionRequired = useInactivityTimer(
|
||||
isShellAwaitingFocus,
|
||||
const { shouldShowFocusHint, inactivityStatus } = useShellInactivityStatus({
|
||||
activePtyId,
|
||||
lastOutputTime,
|
||||
SHELL_ACTION_REQUIRED_TITLE_DELAY_MS,
|
||||
);
|
||||
streamingState,
|
||||
pendingToolCalls,
|
||||
embeddedShellFocused,
|
||||
isInteractiveShellEnabled: config.isInteractiveShellEnabled(),
|
||||
});
|
||||
|
||||
const shouldShowActionRequiredTitle = inactivityStatus === 'action_required';
|
||||
const shouldShowSilentWorkingTitle = inactivityStatus === 'silent_working';
|
||||
|
||||
// Auto-accept indicator
|
||||
const showAutoAcceptIndicator = useAutoAcceptIndicator({
|
||||
const showApprovalModeIndicator = useApprovalModeIndicator({
|
||||
config,
|
||||
addItem: historyManager.addItem,
|
||||
onApprovalModeChange: handleApprovalModeChange,
|
||||
@@ -1228,13 +1251,11 @@ Logging in with Google... Restarting Gemini CLI to continue.
|
||||
[handleSlashCommand, settings],
|
||||
);
|
||||
|
||||
const { elapsedTime, currentLoadingPhrase } = useLoadingIndicator(
|
||||
const { elapsedTime, currentLoadingPhrase } = useLoadingIndicator({
|
||||
streamingState,
|
||||
settings.merged.ui.customWittyPhrases,
|
||||
!!activePtyId && !embeddedShellFocused,
|
||||
lastOutputTime,
|
||||
shouldShowFocusHint,
|
||||
retryStatus,
|
||||
);
|
||||
});
|
||||
|
||||
const handleGlobalKeypress = useCallback(
|
||||
(key: Key) => {
|
||||
@@ -1364,7 +1385,8 @@ Logging in with Google... Restarting Gemini CLI to continue.
|
||||
const paddedTitle = computeTerminalTitle({
|
||||
streamingState,
|
||||
thoughtSubject: thought?.subject,
|
||||
isConfirming: !!confirmationRequest || showShellActionRequired,
|
||||
isConfirming: !!confirmationRequest || shouldShowActionRequiredTitle,
|
||||
isSilentWorking: shouldShowSilentWorkingTitle,
|
||||
folderName: basename(config.getTargetDir()),
|
||||
showThoughts: !!settings.merged.ui.showStatusInTitle,
|
||||
useDynamicTitle: settings.merged.ui.dynamicWindowTitle,
|
||||
@@ -1380,7 +1402,8 @@ Logging in with Google... Restarting Gemini CLI to continue.
|
||||
streamingState,
|
||||
thought,
|
||||
confirmationRequest,
|
||||
showShellActionRequired,
|
||||
shouldShowActionRequiredTitle,
|
||||
shouldShowSilentWorkingTitle,
|
||||
settings.merged.ui.showStatusInTitle,
|
||||
settings.merged.ui.dynamicWindowTitle,
|
||||
settings.merged.ui.hideWindowTitle,
|
||||
@@ -1471,6 +1494,7 @@ Logging in with Google... Restarting Gemini CLI to continue.
|
||||
showPrivacyNotice ||
|
||||
showIdeRestartPrompt ||
|
||||
!!proQuotaRequest ||
|
||||
!!validationRequest ||
|
||||
isSessionBrowserOpen ||
|
||||
isAuthDialogOpen ||
|
||||
authState === AuthState.AwaitingApiKeyInput;
|
||||
@@ -1480,6 +1504,16 @@ Logging in with Google... Restarting Gemini CLI to continue.
|
||||
[pendingSlashCommandHistoryItems, pendingGeminiHistoryItems],
|
||||
);
|
||||
|
||||
const allToolCalls = useMemo(
|
||||
() =>
|
||||
pendingHistoryItems
|
||||
.filter(
|
||||
(item): item is HistoryItemToolGroup => item.type === 'tool_group',
|
||||
)
|
||||
.flatMap((item) => item.tools),
|
||||
[pendingHistoryItems],
|
||||
);
|
||||
|
||||
const [geminiMdFileCount, setGeminiMdFileCount] = useState<number>(
|
||||
config.getGeminiMdFileCount(),
|
||||
);
|
||||
@@ -1584,10 +1618,11 @@ Logging in with Google... Restarting Gemini CLI to continue.
|
||||
activeHooks,
|
||||
messageQueue,
|
||||
queueErrorMessage,
|
||||
showAutoAcceptIndicator,
|
||||
showApprovalModeIndicator,
|
||||
currentModel,
|
||||
userTier,
|
||||
proQuotaRequest,
|
||||
validationRequest,
|
||||
contextFileNames,
|
||||
errorCount,
|
||||
availableTerminalHeight,
|
||||
@@ -1675,9 +1710,10 @@ Logging in with Google... Restarting Gemini CLI to continue.
|
||||
activeHooks,
|
||||
messageQueue,
|
||||
queueErrorMessage,
|
||||
showAutoAcceptIndicator,
|
||||
showApprovalModeIndicator,
|
||||
userTier,
|
||||
proQuotaRequest,
|
||||
validationRequest,
|
||||
contextFileNames,
|
||||
errorCount,
|
||||
availableTerminalHeight,
|
||||
@@ -1747,6 +1783,7 @@ Logging in with Google... Restarting Gemini CLI to continue.
|
||||
handleFinalSubmit,
|
||||
handleClearScreen,
|
||||
handleProQuotaChoice,
|
||||
handleValidationChoice,
|
||||
openSessionBrowser,
|
||||
closeSessionBrowser,
|
||||
handleResumeSession,
|
||||
@@ -1787,6 +1824,7 @@ Logging in with Google... Restarting Gemini CLI to continue.
|
||||
handleFinalSubmit,
|
||||
handleClearScreen,
|
||||
handleProQuotaChoice,
|
||||
handleValidationChoice,
|
||||
openSessionBrowser,
|
||||
closeSessionBrowser,
|
||||
handleResumeSession,
|
||||
@@ -1812,6 +1850,26 @@ Logging in with Google... Restarting Gemini CLI to continue.
|
||||
);
|
||||
}
|
||||
|
||||
if (newAgents) {
|
||||
const handleNewAgentsSelect = (choice: NewAgentsChoice) => {
|
||||
if (choice === NewAgentsChoice.ACKNOWLEDGE) {
|
||||
const registry = config.getAgentRegistry();
|
||||
newAgents.forEach((agent) => {
|
||||
// eslint-disable-next-line @typescript-eslint/no-floating-promises
|
||||
registry.acknowledgeAgent(agent);
|
||||
});
|
||||
}
|
||||
setNewAgents(null);
|
||||
};
|
||||
|
||||
return (
|
||||
<NewAgentsNotification
|
||||
agents={newAgents}
|
||||
onSelect={handleNewAgentsSelect}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<UIStateContext.Provider value={uiState}>
|
||||
<UIActionsContext.Provider value={uiActions}>
|
||||
@@ -1822,9 +1880,11 @@ Logging in with Google... Restarting Gemini CLI to continue.
|
||||
startupWarnings: props.startupWarnings || [],
|
||||
}}
|
||||
>
|
||||
<ShellFocusContext.Provider value={isFocused}>
|
||||
<App />
|
||||
</ShellFocusContext.Provider>
|
||||
<ToolActionsProvider config={config} toolCalls={allToolCalls}>
|
||||
<ShellFocusContext.Provider value={isFocused}>
|
||||
<App />
|
||||
</ShellFocusContext.Provider>
|
||||
</ToolActionsProvider>
|
||||
</AppContext.Provider>
|
||||
</ConfigContext.Provider>
|
||||
</UIActionsContext.Provider>
|
||||
|
||||
@@ -108,10 +108,10 @@ describe('ApiAuthDialog', () => {
|
||||
|
||||
keypressHandler({
|
||||
name: keyName,
|
||||
sequence,
|
||||
ctrl: false,
|
||||
meta: false,
|
||||
shift: false,
|
||||
ctrl: false,
|
||||
cmd: false,
|
||||
sequence,
|
||||
});
|
||||
|
||||
expect(expectedCall).toHaveBeenCalledWith(...args);
|
||||
@@ -137,9 +137,9 @@ describe('ApiAuthDialog', () => {
|
||||
|
||||
await keypressHandler({
|
||||
name: 'c',
|
||||
ctrl: true,
|
||||
meta: false,
|
||||
shift: false,
|
||||
ctrl: true,
|
||||
cmd: false,
|
||||
});
|
||||
|
||||
expect(clearApiKey).toHaveBeenCalled();
|
||||
|
||||
@@ -48,10 +48,10 @@ describe('LoginWithGoogleRestartDialog', () => {
|
||||
|
||||
keypressHandler({
|
||||
name: 'escape',
|
||||
sequence: '\u001b',
|
||||
ctrl: false,
|
||||
meta: false,
|
||||
shift: false,
|
||||
ctrl: false,
|
||||
cmd: false,
|
||||
sequence: '\u001b',
|
||||
});
|
||||
|
||||
expect(onDismiss).toHaveBeenCalledTimes(1);
|
||||
@@ -67,10 +67,10 @@ describe('LoginWithGoogleRestartDialog', () => {
|
||||
|
||||
keypressHandler({
|
||||
name: keyName,
|
||||
sequence: keyName,
|
||||
ctrl: false,
|
||||
meta: false,
|
||||
shift: false,
|
||||
ctrl: false,
|
||||
cmd: false,
|
||||
sequence: keyName,
|
||||
});
|
||||
|
||||
// Advance timers to trigger the setTimeout callback
|
||||
|
||||
@@ -149,7 +149,7 @@ describe('agentsCommand', () => {
|
||||
});
|
||||
// Add agent to disabled overrides so validation passes
|
||||
mockContext.services.settings.merged.agents.overrides['test-agent'] = {
|
||||
disabled: true,
|
||||
enabled: false,
|
||||
};
|
||||
|
||||
vi.mocked(enableAgent).mockReturnValue({
|
||||
@@ -264,7 +264,7 @@ describe('agentsCommand', () => {
|
||||
it('should show info message if agent is already disabled', async () => {
|
||||
mockConfig.getAgentRegistry().getAllAgentNames.mockReturnValue([]);
|
||||
mockContext.services.settings.merged.agents.overrides['test-agent'] = {
|
||||
disabled: true,
|
||||
enabled: false,
|
||||
};
|
||||
|
||||
const disableCommand = agentsCommand.subCommands?.find(
|
||||
|
||||
@@ -85,10 +85,10 @@ async function enableAction(
|
||||
const allAgents = agentRegistry.getAllAgentNames();
|
||||
const overrides = settings.merged.agents.overrides;
|
||||
const disabledAgents = Object.keys(overrides).filter(
|
||||
(name) => overrides[name]?.disabled === true,
|
||||
(name) => overrides[name]?.enabled === false,
|
||||
);
|
||||
|
||||
if (allAgents.includes(agentName)) {
|
||||
if (allAgents.includes(agentName) && !disabledAgents.includes(agentName)) {
|
||||
return {
|
||||
type: 'message',
|
||||
messageType: 'info',
|
||||
@@ -96,7 +96,7 @@ async function enableAction(
|
||||
};
|
||||
}
|
||||
|
||||
if (!disabledAgents.includes(agentName)) {
|
||||
if (!disabledAgents.includes(agentName) && !allAgents.includes(agentName)) {
|
||||
return {
|
||||
type: 'message',
|
||||
messageType: 'error',
|
||||
@@ -155,7 +155,7 @@ async function disableAction(
|
||||
const allAgents = agentRegistry.getAllAgentNames();
|
||||
const overrides = settings.merged.agents.overrides;
|
||||
const disabledAgents = Object.keys(overrides).filter(
|
||||
(name) => overrides[name]?.disabled === true,
|
||||
(name) => overrides[name]?.enabled === false,
|
||||
);
|
||||
|
||||
if (disabledAgents.includes(agentName)) {
|
||||
@@ -206,7 +206,7 @@ function completeAgentsToEnable(context: CommandContext, partialArg: string) {
|
||||
|
||||
const overrides = settings.merged.agents.overrides;
|
||||
const disabledAgents = Object.entries(overrides)
|
||||
.filter(([_, override]) => override?.disabled === true)
|
||||
.filter(([_, override]) => override?.enabled === false)
|
||||
.map(([name]) => name);
|
||||
|
||||
return disabledAgents.filter((name) => name.startsWith(partialArg));
|
||||
|
||||
@@ -43,6 +43,7 @@ describe('directoryCommand', () => {
|
||||
beforeEach(() => {
|
||||
mockWorkspaceContext = {
|
||||
addDirectory: vi.fn(),
|
||||
addDirectories: vi.fn().mockReturnValue({ added: [], failed: [] }),
|
||||
getDirectories: vi
|
||||
.fn()
|
||||
.mockReturnValue([
|
||||
@@ -125,9 +126,15 @@ describe('directoryCommand', () => {
|
||||
|
||||
it('should call addDirectory and show a success message for a single path', async () => {
|
||||
const newPath = path.normalize('/home/user/new-project');
|
||||
vi.mocked(mockWorkspaceContext.addDirectories).mockReturnValue({
|
||||
added: [newPath],
|
||||
failed: [],
|
||||
});
|
||||
if (!addCommand?.action) throw new Error('No action');
|
||||
await addCommand.action(mockContext, newPath);
|
||||
expect(mockWorkspaceContext.addDirectory).toHaveBeenCalledWith(newPath);
|
||||
expect(mockWorkspaceContext.addDirectories).toHaveBeenCalledWith([
|
||||
newPath,
|
||||
]);
|
||||
expect(mockContext.ui.addItem).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
type: MessageType.INFO,
|
||||
@@ -139,10 +146,16 @@ describe('directoryCommand', () => {
|
||||
it('should call addDirectory for each path and show a success message for multiple paths', async () => {
|
||||
const newPath1 = path.normalize('/home/user/new-project1');
|
||||
const newPath2 = path.normalize('/home/user/new-project2');
|
||||
vi.mocked(mockWorkspaceContext.addDirectories).mockReturnValue({
|
||||
added: [newPath1, newPath2],
|
||||
failed: [],
|
||||
});
|
||||
if (!addCommand?.action) throw new Error('No action');
|
||||
await addCommand.action(mockContext, `${newPath1},${newPath2}`);
|
||||
expect(mockWorkspaceContext.addDirectory).toHaveBeenCalledWith(newPath1);
|
||||
expect(mockWorkspaceContext.addDirectory).toHaveBeenCalledWith(newPath2);
|
||||
expect(mockWorkspaceContext.addDirectories).toHaveBeenCalledWith([
|
||||
newPath1,
|
||||
newPath2,
|
||||
]);
|
||||
expect(mockContext.ui.addItem).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
type: MessageType.INFO,
|
||||
@@ -153,10 +166,11 @@ describe('directoryCommand', () => {
|
||||
|
||||
it('should show an error if addDirectory throws an exception', async () => {
|
||||
const error = new Error('Directory does not exist');
|
||||
vi.mocked(mockWorkspaceContext.addDirectory).mockImplementation(() => {
|
||||
throw error;
|
||||
});
|
||||
const newPath = path.normalize('/home/user/invalid-project');
|
||||
vi.mocked(mockWorkspaceContext.addDirectories).mockReturnValue({
|
||||
added: [],
|
||||
failed: [{ path: newPath, error }],
|
||||
});
|
||||
if (!addCommand?.action) throw new Error('No action');
|
||||
await addCommand.action(mockContext, newPath);
|
||||
expect(mockContext.ui.addItem).toHaveBeenCalledWith(
|
||||
@@ -171,10 +185,16 @@ describe('directoryCommand', () => {
|
||||
if (!addCommand?.action) throw new Error('No action');
|
||||
vi.spyOn(trustedFolders, 'isFolderTrustEnabled').mockReturnValue(false);
|
||||
const newPath = path.normalize('/home/user/new-project');
|
||||
vi.mocked(mockWorkspaceContext.addDirectories).mockReturnValue({
|
||||
added: [newPath],
|
||||
failed: [],
|
||||
});
|
||||
|
||||
await addCommand.action(mockContext, newPath);
|
||||
|
||||
expect(mockWorkspaceContext.addDirectory).toHaveBeenCalledWith(newPath);
|
||||
expect(mockWorkspaceContext.addDirectories).toHaveBeenCalledWith([
|
||||
newPath,
|
||||
]);
|
||||
});
|
||||
|
||||
it('should show an info message for an already added directory', async () => {
|
||||
@@ -196,13 +216,10 @@ describe('directoryCommand', () => {
|
||||
const validPath = path.normalize('/home/user/valid-project');
|
||||
const invalidPath = path.normalize('/home/user/invalid-project');
|
||||
const error = new Error('Directory does not exist');
|
||||
vi.mocked(mockWorkspaceContext.addDirectory).mockImplementation(
|
||||
(p: string) => {
|
||||
if (p === invalidPath) {
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
);
|
||||
vi.mocked(mockWorkspaceContext.addDirectories).mockReturnValue({
|
||||
added: [validPath],
|
||||
failed: [{ path: invalidPath, error }],
|
||||
});
|
||||
|
||||
if (!addCommand?.action) throw new Error('No action');
|
||||
await addCommand.action(mockContext, `${validPath},${invalidPath}`);
|
||||
@@ -290,10 +307,16 @@ describe('directoryCommand', () => {
|
||||
if (!addCommand?.action) throw new Error('No action');
|
||||
mockIsPathTrusted.mockReturnValue(true);
|
||||
const newPath = path.normalize('/home/user/trusted-project');
|
||||
vi.mocked(mockWorkspaceContext.addDirectories).mockReturnValue({
|
||||
added: [newPath],
|
||||
failed: [],
|
||||
});
|
||||
|
||||
await addCommand.action(mockContext, newPath);
|
||||
|
||||
expect(mockWorkspaceContext.addDirectory).toHaveBeenCalledWith(newPath);
|
||||
expect(mockWorkspaceContext.addDirectories).toHaveBeenCalledWith([
|
||||
newPath,
|
||||
]);
|
||||
});
|
||||
|
||||
it('should show an error for an untrusted directory', async () => {
|
||||
@@ -303,7 +326,7 @@ describe('directoryCommand', () => {
|
||||
|
||||
await addCommand.action(mockContext, newPath);
|
||||
|
||||
expect(mockWorkspaceContext.addDirectory).not.toHaveBeenCalled();
|
||||
expect(mockWorkspaceContext.addDirectories).not.toHaveBeenCalled();
|
||||
expect(mockContext.ui.addItem).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
type: MessageType.ERROR,
|
||||
|
||||
@@ -17,6 +17,7 @@ import { refreshServerHierarchicalMemory } from '@google/gemini-cli-core';
|
||||
import {
|
||||
expandHomeDir,
|
||||
getDirectorySuggestions,
|
||||
batchAddDirectories,
|
||||
} from '../utils/directoryUtils.js';
|
||||
import type { Config } from '@google/gemini-cli-core';
|
||||
|
||||
@@ -193,14 +194,10 @@ export const directoryCommand: SlashCommand = {
|
||||
);
|
||||
}
|
||||
|
||||
for (const pathToAdd of trustedDirs) {
|
||||
try {
|
||||
workspaceContext.addDirectory(expandHomeDir(pathToAdd));
|
||||
added.push(pathToAdd);
|
||||
} catch (e) {
|
||||
const error = e as Error;
|
||||
errors.push(`Error adding '${pathToAdd}': ${error.message}`);
|
||||
}
|
||||
if (trustedDirs.length > 0) {
|
||||
const result = batchAddDirectories(workspaceContext, trustedDirs);
|
||||
added.push(...result.added);
|
||||
errors.push(...result.errors);
|
||||
}
|
||||
|
||||
if (undefinedTrustDirs.length > 0) {
|
||||
@@ -220,17 +217,9 @@ export const directoryCommand: SlashCommand = {
|
||||
};
|
||||
}
|
||||
} else {
|
||||
for (const pathToAdd of pathsToProcess) {
|
||||
try {
|
||||
workspaceContext.addDirectory(expandHomeDir(pathToAdd.trim()));
|
||||
added.push(pathToAdd.trim());
|
||||
} catch (e) {
|
||||
const error = e as Error;
|
||||
errors.push(
|
||||
`Error adding '${pathToAdd.trim()}': ${error.message}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
const result = batchAddDirectories(workspaceContext, pathsToProcess);
|
||||
added.push(...result.added);
|
||||
errors.push(...result.errors);
|
||||
}
|
||||
|
||||
await finishAddingDirectories(config, addItem, added, errors);
|
||||
|
||||
@@ -26,7 +26,7 @@ describe('hooksCommand', () => {
|
||||
};
|
||||
let mockSettings: {
|
||||
merged: {
|
||||
hooks?: {
|
||||
hooksConfig?: {
|
||||
disabled?: string[];
|
||||
};
|
||||
tools?: {
|
||||
@@ -58,7 +58,7 @@ describe('hooksCommand', () => {
|
||||
// Create mock settings
|
||||
mockSettings = {
|
||||
merged: {
|
||||
hooks: {
|
||||
hooksConfig: {
|
||||
disabled: [],
|
||||
},
|
||||
},
|
||||
@@ -273,7 +273,7 @@ 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 = [
|
||||
mockContext.services.settings.merged.hooksConfig.disabled = [
|
||||
'test-hook',
|
||||
'other-hook',
|
||||
];
|
||||
@@ -289,7 +289,7 @@ describe('hooksCommand', () => {
|
||||
|
||||
expect(mockContext.services.settings.setValue).toHaveBeenCalledWith(
|
||||
expect.any(String),
|
||||
'hooks.disabled',
|
||||
'hooksConfig.disabled',
|
||||
['other-hook'],
|
||||
);
|
||||
expect(mockHookSystem.setHookEnabled).toHaveBeenCalledWith(
|
||||
@@ -404,7 +404,7 @@ describe('hooksCommand', () => {
|
||||
});
|
||||
|
||||
it('should disable a hook and update settings', async () => {
|
||||
mockContext.services.settings.merged.hooks.disabled = [];
|
||||
mockContext.services.settings.merged.hooksConfig.disabled = [];
|
||||
|
||||
const disableCmd = hooksCommand.subCommands!.find(
|
||||
(cmd) => cmd.name === 'disable',
|
||||
@@ -417,7 +417,7 @@ describe('hooksCommand', () => {
|
||||
|
||||
expect(mockContext.services.settings.setValue).toHaveBeenCalledWith(
|
||||
expect.any(String),
|
||||
'hooks.disabled',
|
||||
'hooksConfig.disabled',
|
||||
['test-hook'],
|
||||
);
|
||||
expect(mockHookSystem.setHookEnabled).toHaveBeenCalledWith(
|
||||
@@ -433,7 +433,7 @@ describe('hooksCommand', () => {
|
||||
|
||||
it('should synchronize with hook system even if hook is already in disabled list', async () => {
|
||||
// Update the context's settings with the hook already disabled
|
||||
mockContext.services.settings.merged.hooks.disabled = ['test-hook'];
|
||||
mockContext.services.settings.merged.hooksConfig.disabled = ['test-hook'];
|
||||
|
||||
const disableCmd = hooksCommand.subCommands!.find(
|
||||
(cmd) => cmd.name === 'disable',
|
||||
@@ -458,7 +458,7 @@ describe('hooksCommand', () => {
|
||||
});
|
||||
|
||||
it('should handle error when disabling hook fails', async () => {
|
||||
mockContext.services.settings.merged.hooks.disabled = [];
|
||||
mockContext.services.settings.merged.hooksConfig.disabled = [];
|
||||
mockSettings.setValue.mockImplementationOnce(() => {
|
||||
throw new Error('Failed to save settings');
|
||||
});
|
||||
@@ -637,7 +637,7 @@ describe('hooksCommand', () => {
|
||||
|
||||
expect(mockContext.services.settings.setValue).toHaveBeenCalledWith(
|
||||
expect.any(String),
|
||||
'hooks.disabled',
|
||||
'hooksConfig.disabled',
|
||||
[],
|
||||
);
|
||||
expect(mockHookSystem.setHookEnabled).toHaveBeenCalledWith(
|
||||
@@ -761,7 +761,7 @@ describe('hooksCommand', () => {
|
||||
|
||||
expect(mockContext.services.settings.setValue).toHaveBeenCalledWith(
|
||||
expect.any(String),
|
||||
'hooks.disabled',
|
||||
'hooksConfig.disabled',
|
||||
['hook-1', 'hook-2', 'hook-3'],
|
||||
);
|
||||
expect(mockHookSystem.setHookEnabled).toHaveBeenCalledWith(
|
||||
|
||||
@@ -76,7 +76,7 @@ async function enableAction(
|
||||
|
||||
// Get current disabled hooks from settings
|
||||
const settings = context.services.settings;
|
||||
const disabledHooks = settings.merged.hooks.disabled;
|
||||
const disabledHooks = settings.merged.hooksConfig.disabled;
|
||||
// Remove from disabled list if present
|
||||
const newDisabledHooks = disabledHooks.filter(
|
||||
(name: string) => name !== hookName,
|
||||
@@ -87,10 +87,10 @@ async function enableAction(
|
||||
const scope = settings.workspace
|
||||
? SettingScope.Workspace
|
||||
: SettingScope.User;
|
||||
settings.setValue(scope, 'hooks.disabled', newDisabledHooks);
|
||||
settings.setValue(scope, 'hooksConfig.disabled', newDisabledHooks);
|
||||
|
||||
// Update core config so re-initialization (e.g. extension reload) respects the change
|
||||
config.updateDisabledHooks(settings.merged.hooks.disabled);
|
||||
config.updateDisabledHooks(settings.merged.hooksConfig.disabled);
|
||||
|
||||
// Enable in hook system
|
||||
hookSystem.setHookEnabled(hookName, true);
|
||||
@@ -145,7 +145,7 @@ async function disableAction(
|
||||
|
||||
// Get current disabled hooks from settings
|
||||
const settings = context.services.settings;
|
||||
const disabledHooks = settings.merged.hooks.disabled;
|
||||
const disabledHooks = settings.merged.hooksConfig.disabled;
|
||||
// Add to disabled list if not already present
|
||||
try {
|
||||
if (!disabledHooks.includes(hookName)) {
|
||||
@@ -154,11 +154,11 @@ async function disableAction(
|
||||
const scope = settings.workspace
|
||||
? SettingScope.Workspace
|
||||
: SettingScope.User;
|
||||
settings.setValue(scope, 'hooks.disabled', newDisabledHooks);
|
||||
settings.setValue(scope, 'hooksConfig.disabled', newDisabledHooks);
|
||||
}
|
||||
|
||||
// Update core config so re-initialization (e.g. extension reload) respects the change
|
||||
config.updateDisabledHooks(settings.merged.hooks.disabled);
|
||||
config.updateDisabledHooks(settings.merged.hooksConfig.disabled);
|
||||
|
||||
// Always disable in hook system to ensure in-memory state matches settings
|
||||
hookSystem.setHookEnabled(hookName, false);
|
||||
@@ -250,10 +250,10 @@ async function enableAllAction(
|
||||
const scope = settings.workspace
|
||||
? SettingScope.Workspace
|
||||
: SettingScope.User;
|
||||
settings.setValue(scope, 'hooks.disabled', []);
|
||||
settings.setValue(scope, 'hooksConfig.disabled', []);
|
||||
|
||||
// Update core config so re-initialization (e.g. extension reload) respects the change
|
||||
config.updateDisabledHooks(settings.merged.hooks.disabled);
|
||||
config.updateDisabledHooks(settings.merged.hooksConfig.disabled);
|
||||
|
||||
for (const hook of disabledHooks) {
|
||||
const hookName = getHookDisplayName(hook);
|
||||
@@ -323,10 +323,10 @@ async function disableAllAction(
|
||||
const scope = settings.workspace
|
||||
? SettingScope.Workspace
|
||||
: SettingScope.User;
|
||||
settings.setValue(scope, 'hooks.disabled', allHookNames);
|
||||
settings.setValue(scope, 'hooksConfig.disabled', allHookNames);
|
||||
|
||||
// Update core config so re-initialization (e.g. extension reload) respects the change
|
||||
config.updateDisabledHooks(settings.merged.hooks.disabled);
|
||||
config.updateDisabledHooks(settings.merged.hooksConfig.disabled);
|
||||
|
||||
for (const hook of enabledHooks) {
|
||||
const hookName = getHookDisplayName(hook);
|
||||
|
||||
@@ -62,7 +62,7 @@ describe('policiesCommand', () => {
|
||||
);
|
||||
});
|
||||
|
||||
it('should list active policies in correct format', async () => {
|
||||
it('should list policies grouped by mode', async () => {
|
||||
const mockRules = [
|
||||
{
|
||||
decision: PolicyDecision.DENY,
|
||||
@@ -99,13 +99,18 @@ describe('policiesCommand', () => {
|
||||
const call = vi.mocked(mockContext.ui.addItem).mock.calls[0];
|
||||
const content = (call[0] as { text: string }).text;
|
||||
|
||||
expect(content).toContain('### Normal Mode Policies');
|
||||
expect(content).toContain(
|
||||
'1. **DENY** tool: `dangerousTool` [Priority: 10]',
|
||||
'### Auto Edit Mode Policies (combined with normal mode policies)',
|
||||
);
|
||||
expect(content).toContain(
|
||||
'2. **ALLOW** all tools (args match: `safe`) [Source: `test.toml`]',
|
||||
'### Yolo Mode Policies (combined with normal mode policies)',
|
||||
);
|
||||
expect(content).toContain('3. **ASK_USER** all tools');
|
||||
expect(content).toContain(
|
||||
'**DENY** tool: `dangerousTool` [Priority: 10]',
|
||||
);
|
||||
expect(content).toContain('**ALLOW** all tools (args match: `safe`)');
|
||||
expect(content).toContain('**ASK_USER** all tools');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -4,12 +4,46 @@
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { ApprovalMode, type PolicyRule } from '@google/gemini-cli-core';
|
||||
import { CommandKind, type SlashCommand } from './types.js';
|
||||
import { MessageType } from '../types.js';
|
||||
|
||||
interface CategorizedRules {
|
||||
normal: PolicyRule[];
|
||||
autoEdit: PolicyRule[];
|
||||
yolo: PolicyRule[];
|
||||
}
|
||||
|
||||
const categorizeRulesByMode = (
|
||||
rules: readonly PolicyRule[],
|
||||
): CategorizedRules => {
|
||||
const result: CategorizedRules = {
|
||||
normal: [],
|
||||
autoEdit: [],
|
||||
yolo: [],
|
||||
};
|
||||
const ALL_MODES = Object.values(ApprovalMode);
|
||||
rules.forEach((rule) => {
|
||||
const modes = rule.modes?.length ? rule.modes : ALL_MODES;
|
||||
const modeSet = new Set(modes);
|
||||
if (modeSet.has(ApprovalMode.DEFAULT)) result.normal.push(rule);
|
||||
if (modeSet.has(ApprovalMode.AUTO_EDIT)) result.autoEdit.push(rule);
|
||||
if (modeSet.has(ApprovalMode.YOLO)) result.yolo.push(rule);
|
||||
});
|
||||
return result;
|
||||
};
|
||||
|
||||
const formatRule = (rule: PolicyRule, i: number) =>
|
||||
`${i + 1}. **${rule.decision.toUpperCase()}** ${rule.toolName ? `tool: \`${rule.toolName}\`` : 'all tools'}` +
|
||||
(rule.argsPattern ? ` (args match: \`${rule.argsPattern.source}\`)` : '') +
|
||||
(rule.priority !== undefined ? ` [Priority: ${rule.priority}]` : '');
|
||||
|
||||
const formatSection = (title: string, rules: PolicyRule[]) =>
|
||||
`### ${title}\n${rules.length ? rules.map(formatRule).join('\n') : '_No policies._'}\n\n`;
|
||||
|
||||
const listPoliciesCommand: SlashCommand = {
|
||||
name: 'list',
|
||||
description: 'List all active policies',
|
||||
description: 'List all active policies grouped by mode',
|
||||
kind: CommandKind.BUILT_IN,
|
||||
autoExecute: true,
|
||||
action: async (context) => {
|
||||
@@ -39,25 +73,25 @@ const listPoliciesCommand: SlashCommand = {
|
||||
return;
|
||||
}
|
||||
|
||||
const categorized = categorizeRulesByMode(rules);
|
||||
const normalRulesSet = new Set(categorized.normal);
|
||||
const uniqueAutoEdit = categorized.autoEdit.filter(
|
||||
(rule) => !normalRulesSet.has(rule),
|
||||
);
|
||||
const uniqueYolo = categorized.yolo.filter(
|
||||
(rule) => !normalRulesSet.has(rule),
|
||||
);
|
||||
|
||||
let content = '**Active Policies**\n\n';
|
||||
rules.forEach((rule, index) => {
|
||||
content += `${index + 1}. **${rule.decision.toUpperCase()}**`;
|
||||
if (rule.toolName) {
|
||||
content += ` tool: \`${rule.toolName}\``;
|
||||
} else {
|
||||
content += ` all tools`;
|
||||
}
|
||||
if (rule.argsPattern) {
|
||||
content += ` (args match: \`${rule.argsPattern.source}\`)`;
|
||||
}
|
||||
if (rule.priority !== undefined) {
|
||||
content += ` [Priority: ${rule.priority}]`;
|
||||
}
|
||||
if (rule.source) {
|
||||
content += ` [Source: \`${rule.source}\`]`;
|
||||
}
|
||||
content += '\n';
|
||||
});
|
||||
content += formatSection('Normal Mode Policies', categorized.normal);
|
||||
content += formatSection(
|
||||
'Auto Edit Mode Policies (combined with normal mode policies)',
|
||||
uniqueAutoEdit,
|
||||
);
|
||||
content += formatSection(
|
||||
'Yolo Mode Policies (combined with normal mode policies)',
|
||||
uniqueYolo,
|
||||
);
|
||||
|
||||
context.ui.addItem(
|
||||
{
|
||||
|
||||
@@ -234,7 +234,34 @@ describe('skillsCommand', () => {
|
||||
expect(context.ui.addItem).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
type: MessageType.INFO,
|
||||
text: 'Skill "skill1" disabled by adding it to the disabled list in workspace (/workspace) settings. Use "/skills reload" for it to take effect.',
|
||||
text: 'Skill "skill1" disabled by adding it to the disabled list in workspace (/workspace) settings. You can run "/skills reload" to refresh your current instance.',
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('should show reload guidance even if skill is already disabled', async () => {
|
||||
const disableCmd = skillsCommand.subCommands!.find(
|
||||
(s) => s.name === 'disable',
|
||||
)!;
|
||||
(
|
||||
context.services.settings as unknown as { merged: MergedSettings }
|
||||
).merged = createTestMergedSettings({
|
||||
skills: { enabled: true, disabled: ['skill1'] },
|
||||
});
|
||||
(
|
||||
context.services.settings as unknown as {
|
||||
workspace: { settings: { skills: { disabled: string[] } } };
|
||||
}
|
||||
).workspace.settings = {
|
||||
skills: { disabled: ['skill1'] },
|
||||
};
|
||||
|
||||
await disableCmd.action!(context, 'skill1');
|
||||
|
||||
expect(context.ui.addItem).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
type: MessageType.INFO,
|
||||
text: 'Skill "skill1" is already disabled. You can run "/skills reload" to refresh your current instance.',
|
||||
}),
|
||||
);
|
||||
});
|
||||
@@ -269,7 +296,7 @@ describe('skillsCommand', () => {
|
||||
expect(context.ui.addItem).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
type: MessageType.INFO,
|
||||
text: 'Skill "skill1" enabled by removing it from the disabled list in workspace (/workspace) and user (/user/settings.json) settings. Use "/skills reload" for it to take effect.',
|
||||
text: 'Skill "skill1" enabled by removing it from the disabled list in workspace (/workspace) and user (/user/settings.json) settings. You can run "/skills reload" to refresh your current instance.',
|
||||
}),
|
||||
);
|
||||
});
|
||||
@@ -308,7 +335,7 @@ describe('skillsCommand', () => {
|
||||
expect(context.ui.addItem).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
type: MessageType.INFO,
|
||||
text: 'Skill "skill1" enabled by removing it from the disabled list in workspace (/workspace) and user (/user/settings.json) settings. Use "/skills reload" for it to take effect.',
|
||||
text: 'Skill "skill1" enabled by removing it from the disabled list in workspace (/workspace) and user (/user/settings.json) settings. You can run "/skills reload" to refresh your current instance.',
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
@@ -112,8 +112,9 @@ async function disableAction(
|
||||
result,
|
||||
(label, path) => `${label} (${path})`,
|
||||
);
|
||||
if (result.status === 'success') {
|
||||
feedback += ' Use "/skills reload" for it to take effect.';
|
||||
if (result.status === 'success' || result.status === 'no-op') {
|
||||
feedback +=
|
||||
' You can run "/skills reload" to refresh your current instance.';
|
||||
}
|
||||
|
||||
context.ui.addItem({
|
||||
@@ -153,8 +154,9 @@ async function enableAction(
|
||||
result,
|
||||
(label, path) => `${label} (${path})`,
|
||||
);
|
||||
if (result.status === 'success') {
|
||||
feedback += ' Use "/skills reload" for it to take effect.';
|
||||
if (result.status === 'success' || result.status === 'no-op') {
|
||||
feedback +=
|
||||
' You can run "/skills reload" to refresh your current instance.';
|
||||
}
|
||||
|
||||
context.ui.addItem({
|
||||
|
||||
@@ -88,6 +88,7 @@ const mockConfig = {
|
||||
getModel: () => 'gemini-pro',
|
||||
getTargetDir: () => '/tmp',
|
||||
getDebugMode: () => false,
|
||||
getIdeMode: () => false,
|
||||
getGeminiMdFileCount: () => 0,
|
||||
getExperiments: () => ({
|
||||
flags: {},
|
||||
|
||||
+15
-6
@@ -5,23 +5,32 @@
|
||||
*/
|
||||
|
||||
import { render } from '../../test-utils/render.js';
|
||||
import { AutoAcceptIndicator } from './AutoAcceptIndicator.js';
|
||||
import { ApprovalModeIndicator } from './ApprovalModeIndicator.js';
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { ApprovalMode } from '@google/gemini-cli-core';
|
||||
|
||||
describe('AutoAcceptIndicator', () => {
|
||||
describe('ApprovalModeIndicator', () => {
|
||||
it('renders correctly for AUTO_EDIT mode', () => {
|
||||
const { lastFrame } = render(
|
||||
<AutoAcceptIndicator approvalMode={ApprovalMode.AUTO_EDIT} />,
|
||||
<ApprovalModeIndicator approvalMode={ApprovalMode.AUTO_EDIT} />,
|
||||
);
|
||||
const output = lastFrame();
|
||||
expect(output).toContain('accepting edits');
|
||||
expect(output).toContain('(shift + tab to toggle)');
|
||||
expect(output).toContain('(shift + tab to cycle)');
|
||||
});
|
||||
|
||||
it('renders correctly for PLAN mode', () => {
|
||||
const { lastFrame } = render(
|
||||
<ApprovalModeIndicator approvalMode={ApprovalMode.PLAN} />,
|
||||
);
|
||||
const output = lastFrame();
|
||||
expect(output).toContain('plan mode');
|
||||
expect(output).toContain('(shift + tab to cycle)');
|
||||
});
|
||||
|
||||
it('renders correctly for YOLO mode', () => {
|
||||
const { lastFrame } = render(
|
||||
<AutoAcceptIndicator approvalMode={ApprovalMode.YOLO} />,
|
||||
<ApprovalModeIndicator approvalMode={ApprovalMode.YOLO} />,
|
||||
);
|
||||
const output = lastFrame();
|
||||
expect(output).toContain('YOLO mode');
|
||||
@@ -30,7 +39,7 @@ describe('AutoAcceptIndicator', () => {
|
||||
|
||||
it('renders nothing for DEFAULT mode', () => {
|
||||
const { lastFrame } = render(
|
||||
<AutoAcceptIndicator approvalMode={ApprovalMode.DEFAULT} />,
|
||||
<ApprovalModeIndicator approvalMode={ApprovalMode.DEFAULT} />,
|
||||
);
|
||||
const output = lastFrame();
|
||||
expect(output).not.toContain('accepting edits');
|
||||
+8
-3
@@ -9,11 +9,11 @@ import { Box, Text } from 'ink';
|
||||
import { theme } from '../semantic-colors.js';
|
||||
import { ApprovalMode } from '@google/gemini-cli-core';
|
||||
|
||||
interface AutoAcceptIndicatorProps {
|
||||
interface ApprovalModeIndicatorProps {
|
||||
approvalMode: ApprovalMode;
|
||||
}
|
||||
|
||||
export const AutoAcceptIndicator: React.FC<AutoAcceptIndicatorProps> = ({
|
||||
export const ApprovalModeIndicator: React.FC<ApprovalModeIndicatorProps> = ({
|
||||
approvalMode,
|
||||
}) => {
|
||||
let textColor = '';
|
||||
@@ -24,7 +24,12 @@ export const AutoAcceptIndicator: React.FC<AutoAcceptIndicatorProps> = ({
|
||||
case ApprovalMode.AUTO_EDIT:
|
||||
textColor = theme.status.warning;
|
||||
textContent = 'accepting edits';
|
||||
subText = ' (shift + tab to toggle)';
|
||||
subText = ' (shift + tab to cycle)';
|
||||
break;
|
||||
case ApprovalMode.PLAN:
|
||||
textColor = theme.status.success;
|
||||
textContent = 'plan mode';
|
||||
subText = ' (shift + tab to cycle)';
|
||||
break;
|
||||
case ApprovalMode.YOLO:
|
||||
textColor = theme.status.error;
|
||||
@@ -41,8 +41,8 @@ vi.mock('./HookStatusDisplay.js', () => ({
|
||||
HookStatusDisplay: () => <Text>HookStatusDisplay</Text>,
|
||||
}));
|
||||
|
||||
vi.mock('./AutoAcceptIndicator.js', () => ({
|
||||
AutoAcceptIndicator: () => <Text>AutoAcceptIndicator</Text>,
|
||||
vi.mock('./ApprovalModeIndicator.js', () => ({
|
||||
ApprovalModeIndicator: () => <Text>ApprovalModeIndicator</Text>,
|
||||
}));
|
||||
|
||||
vi.mock('./ShellModeIndicator.js', () => ({
|
||||
@@ -95,12 +95,12 @@ const createMockUIState = (overrides: Partial<UIState> = {}): UIState =>
|
||||
({
|
||||
streamingState: null,
|
||||
contextFileNames: [],
|
||||
showAutoAcceptIndicator: ApprovalMode.DEFAULT,
|
||||
showApprovalModeIndicator: ApprovalMode.DEFAULT,
|
||||
messageQueue: [],
|
||||
showErrorDetails: false,
|
||||
constrainHeight: false,
|
||||
isInputActive: true,
|
||||
buffer: '',
|
||||
buffer: { text: '' },
|
||||
inputWidth: 80,
|
||||
suggestionsWidth: 40,
|
||||
userMessages: [],
|
||||
@@ -389,6 +389,7 @@ describe('Composer', () => {
|
||||
it('shows escape prompt when showEscapePrompt is true', () => {
|
||||
const uiState = createMockUIState({
|
||||
showEscapePrompt: true,
|
||||
history: [{ id: 1, type: 'user', text: 'test' }],
|
||||
});
|
||||
|
||||
const { lastFrame } = renderComposer(uiState);
|
||||
@@ -418,15 +419,15 @@ describe('Composer', () => {
|
||||
expect(lastFrame()).not.toContain('InputPrompt');
|
||||
});
|
||||
|
||||
it('shows AutoAcceptIndicator when approval mode is not default and shell mode is inactive', () => {
|
||||
it('shows ApprovalModeIndicator when approval mode is not default and shell mode is inactive', () => {
|
||||
const uiState = createMockUIState({
|
||||
showAutoAcceptIndicator: ApprovalMode.YOLO,
|
||||
showApprovalModeIndicator: ApprovalMode.YOLO,
|
||||
shellModeActive: false,
|
||||
});
|
||||
|
||||
const { lastFrame } = renderComposer(uiState);
|
||||
|
||||
expect(lastFrame()).toContain('AutoAcceptIndicator');
|
||||
expect(lastFrame()).toContain('ApprovalModeIndicator');
|
||||
});
|
||||
|
||||
it('shows ShellModeIndicator when shell mode is active', () => {
|
||||
|
||||
@@ -8,7 +8,7 @@ import { useState } from 'react';
|
||||
import { Box, useIsScreenReaderEnabled } from 'ink';
|
||||
import { LoadingIndicator } from './LoadingIndicator.js';
|
||||
import { StatusDisplay } from './StatusDisplay.js';
|
||||
import { AutoAcceptIndicator } from './AutoAcceptIndicator.js';
|
||||
import { ApprovalModeIndicator } from './ApprovalModeIndicator.js';
|
||||
import { ShellModeIndicator } from './ShellModeIndicator.js';
|
||||
import { DetailedMessagesDisplay } from './DetailedMessagesDisplay.js';
|
||||
import { RawMarkdownIndicator } from './RawMarkdownIndicator.js';
|
||||
@@ -42,7 +42,7 @@ export const Composer = () => {
|
||||
const [suggestionsVisible, setSuggestionsVisible] = useState(false);
|
||||
|
||||
const isAlternateBuffer = useAlternateBuffer();
|
||||
const { showAutoAcceptIndicator } = uiState;
|
||||
const { showApprovalModeIndicator } = uiState;
|
||||
const suggestionsPosition = isAlternateBuffer ? 'above' : 'below';
|
||||
const hideContextSummary =
|
||||
suggestionsVisible && suggestionsPosition === 'above';
|
||||
@@ -92,9 +92,9 @@ export const Composer = () => {
|
||||
<StatusDisplay hideContextSummary={hideContextSummary} />
|
||||
</Box>
|
||||
<Box paddingTop={isNarrow ? 1 : 0}>
|
||||
{showAutoAcceptIndicator !== ApprovalMode.DEFAULT &&
|
||||
{showApprovalModeIndicator !== ApprovalMode.DEFAULT &&
|
||||
!uiState.shellModeActive && (
|
||||
<AutoAcceptIndicator approvalMode={showAutoAcceptIndicator} />
|
||||
<ApprovalModeIndicator approvalMode={showApprovalModeIndicator} />
|
||||
)}
|
||||
{uiState.shellModeActive && <ShellModeIndicator />}
|
||||
{!uiState.renderMarkdown && <RawMarkdownIndicator />}
|
||||
@@ -131,7 +131,7 @@ export const Composer = () => {
|
||||
commandContext={uiState.commandContext}
|
||||
shellModeActive={uiState.shellModeActive}
|
||||
setShellModeActive={uiActions.setShellModeActive}
|
||||
approvalMode={showAutoAcceptIndicator}
|
||||
approvalMode={showApprovalModeIndicator}
|
||||
onEscapePromptChange={uiActions.onEscapePromptChange}
|
||||
focus={true}
|
||||
vimHandleInput={uiActions.vimHandleInput}
|
||||
|
||||
@@ -6,6 +6,7 @@
|
||||
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
||||
import { appEvents, AppEvent } from '../../utils/events.js';
|
||||
import { coreEvents } from '@google/gemini-cli-core';
|
||||
import {
|
||||
profiler,
|
||||
DebugProfiler,
|
||||
@@ -16,6 +17,7 @@ import { render } from '../../test-utils/render.js';
|
||||
import { useUIState, type UIState } from '../contexts/UIStateContext.js';
|
||||
import { FixedDeque } from 'mnemonist';
|
||||
import { debugState } from '../debug.js';
|
||||
import { act } from 'react';
|
||||
|
||||
vi.mock('../contexts/UIStateContext.js', () => ({
|
||||
useUIState: vi.fn(),
|
||||
@@ -266,4 +268,40 @@ describe('DebugProfiler Component', () => {
|
||||
expect(output).toContain('5 (idle)');
|
||||
expect(output).toContain('2 (flicker)');
|
||||
});
|
||||
|
||||
it('should report an action when a CoreEvent is emitted', async () => {
|
||||
vi.mocked(useUIState).mockReturnValue({
|
||||
showDebugProfiler: true,
|
||||
constrainHeight: false,
|
||||
} as unknown as UIState);
|
||||
|
||||
const reportActionSpy = vi.spyOn(profiler, 'reportAction');
|
||||
|
||||
const { unmount } = render(<DebugProfiler />);
|
||||
|
||||
act(() => {
|
||||
coreEvents.emitModelChanged('new-model');
|
||||
});
|
||||
|
||||
expect(reportActionSpy).toHaveBeenCalled();
|
||||
unmount();
|
||||
});
|
||||
|
||||
it('should report an action when an AppEvent is emitted', async () => {
|
||||
vi.mocked(useUIState).mockReturnValue({
|
||||
showDebugProfiler: true,
|
||||
constrainHeight: false,
|
||||
} as unknown as UIState);
|
||||
|
||||
const reportActionSpy = vi.spyOn(profiler, 'reportAction');
|
||||
|
||||
const { unmount } = render(<DebugProfiler />);
|
||||
|
||||
act(() => {
|
||||
appEvents.emit(AppEvent.SelectionWarning);
|
||||
});
|
||||
|
||||
expect(reportActionSpy).toHaveBeenCalled();
|
||||
unmount();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -11,7 +11,7 @@ import { theme } from '../semantic-colors.js';
|
||||
import { useUIState } from '../contexts/UIStateContext.js';
|
||||
import { debugState } from '../debug.js';
|
||||
import { appEvents, AppEvent } from '../../utils/events.js';
|
||||
import { debugLogger } from '@google/gemini-cli-core';
|
||||
import { coreEvents, CoreEvent, debugLogger } from '@google/gemini-cli-core';
|
||||
|
||||
// Frames that render at least this far before or after an action are considered
|
||||
// idle frames.
|
||||
@@ -160,9 +160,29 @@ export const DebugProfiler = () => {
|
||||
stdin.on('data', handler);
|
||||
stdout.on('resize', handler);
|
||||
|
||||
// Register handlers for all core and app events to ensure they are
|
||||
// considered "actions" and don't trigger spurious idle frame warnings.
|
||||
// These events are expected to trigger UI renders.
|
||||
for (const eventName of Object.values(CoreEvent)) {
|
||||
coreEvents.on(eventName, handler);
|
||||
}
|
||||
|
||||
for (const eventName of Object.values(AppEvent)) {
|
||||
appEvents.on(eventName, handler);
|
||||
}
|
||||
|
||||
return () => {
|
||||
stdin.off('data', handler);
|
||||
stdout.off('resize', handler);
|
||||
|
||||
for (const eventName of Object.values(CoreEvent)) {
|
||||
coreEvents.off(eventName, handler);
|
||||
}
|
||||
|
||||
for (const eventName of Object.values(AppEvent)) {
|
||||
appEvents.off(eventName, handler);
|
||||
}
|
||||
|
||||
profiler.profilersActive--;
|
||||
};
|
||||
}, []);
|
||||
|
||||
@@ -17,6 +17,7 @@ import { ApiAuthDialog } from '../auth/ApiAuthDialog.js';
|
||||
import { EditorSettingsDialog } from './EditorSettingsDialog.js';
|
||||
import { PrivacyNotice } from '../privacy/PrivacyNotice.js';
|
||||
import { ProQuotaDialog } from './ProQuotaDialog.js';
|
||||
import { ValidationDialog } from './ValidationDialog.js';
|
||||
import { runExitCleanup } from '../../utils/cleanup.js';
|
||||
import { RELAUNCH_EXIT_CODE } from '../../utils/processUtils.js';
|
||||
import { SessionBrowser } from './SessionBrowser.js';
|
||||
@@ -68,6 +69,16 @@ export const DialogManager = ({
|
||||
/>
|
||||
);
|
||||
}
|
||||
if (uiState.validationRequest) {
|
||||
return (
|
||||
<ValidationDialog
|
||||
validationLink={uiState.validationRequest.validationLink}
|
||||
validationDescription={uiState.validationRequest.validationDescription}
|
||||
learnMoreUrl={uiState.validationRequest.learnMoreUrl}
|
||||
onChoice={uiActions.handleValidationChoice}
|
||||
/>
|
||||
);
|
||||
}
|
||||
if (uiState.shouldShowIdePrompt) {
|
||||
return (
|
||||
<IdeIntegrationNudge
|
||||
|
||||
@@ -203,6 +203,15 @@ describe('<Footer />', () => {
|
||||
});
|
||||
|
||||
describe('footer configuration filtering (golden snapshots)', () => {
|
||||
beforeEach(() => {
|
||||
vi.stubEnv('SANDBOX', '');
|
||||
vi.stubEnv('SEATBELT_PROFILE', '');
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllEnvs();
|
||||
});
|
||||
|
||||
it('renders complete footer with all sections visible (baseline)', () => {
|
||||
const { lastFrame } = renderWithProviders(<Footer />, {
|
||||
width: 120,
|
||||
|
||||
@@ -1893,10 +1893,35 @@ describe('InputPrompt', () => {
|
||||
unmount();
|
||||
});
|
||||
|
||||
it('should submit /rewind on double ESC', async () => {
|
||||
it('should submit /rewind on double ESC when buffer is empty', async () => {
|
||||
const onEscapePromptChange = vi.fn();
|
||||
props.onEscapePromptChange = onEscapePromptChange;
|
||||
props.buffer.setText('');
|
||||
vi.mocked(props.buffer.setText).mockClear();
|
||||
|
||||
const { stdin, unmount } = renderWithProviders(
|
||||
<InputPrompt {...props} />,
|
||||
{
|
||||
uiState: {
|
||||
history: [{ id: 1, type: 'user', text: 'test' }],
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
await act(async () => {
|
||||
stdin.write('\x1B\x1B');
|
||||
vi.advanceTimersByTime(100);
|
||||
|
||||
expect(props.onSubmit).toHaveBeenCalledWith('/rewind');
|
||||
});
|
||||
unmount();
|
||||
});
|
||||
|
||||
it('should clear the buffer on esc esc if it has text', async () => {
|
||||
const onEscapePromptChange = vi.fn();
|
||||
props.onEscapePromptChange = onEscapePromptChange;
|
||||
props.buffer.setText('some text');
|
||||
vi.mocked(props.buffer.setText).mockClear();
|
||||
|
||||
const { stdin, unmount } = renderWithProviders(
|
||||
<InputPrompt {...props} />,
|
||||
@@ -1906,7 +1931,8 @@ describe('InputPrompt', () => {
|
||||
stdin.write('\x1B\x1B');
|
||||
vi.advanceTimersByTime(100);
|
||||
|
||||
expect(props.onSubmit).toHaveBeenCalledWith('/rewind');
|
||||
expect(props.buffer.setText).toHaveBeenCalledWith('');
|
||||
expect(props.onSubmit).not.toHaveBeenCalledWith('/rewind');
|
||||
});
|
||||
unmount();
|
||||
});
|
||||
|
||||
@@ -138,7 +138,7 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
|
||||
const kittyProtocol = useKittyKeyboardProtocol();
|
||||
const isShellFocused = useShellFocusState();
|
||||
const { setEmbeddedShellFocused } = useUIActions();
|
||||
const { mainAreaWidth, activePtyId } = useUIState();
|
||||
const { mainAreaWidth, activePtyId, history } = useUIState();
|
||||
const [justNavigatedHistory, setJustNavigatedHistory] = useState(false);
|
||||
const escPressCount = useRef(0);
|
||||
const [showEscapePrompt, setShowEscapePrompt] = useState(false);
|
||||
@@ -495,7 +495,7 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
|
||||
return;
|
||||
}
|
||||
|
||||
// Handle double ESC for rewind
|
||||
// Handle double ESC
|
||||
if (escPressCount.current === 0) {
|
||||
escPressCount.current = 1;
|
||||
setShowEscapePrompt(true);
|
||||
@@ -506,9 +506,16 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
|
||||
resetEscapeState();
|
||||
}, 500);
|
||||
} else {
|
||||
// Second ESC triggers rewind
|
||||
// Second ESC
|
||||
resetEscapeState();
|
||||
onSubmit('/rewind');
|
||||
if (buffer.text.length > 0) {
|
||||
buffer.setText('');
|
||||
resetCompletionState();
|
||||
} else {
|
||||
if (history.length > 0) {
|
||||
onSubmit('/rewind');
|
||||
}
|
||||
}
|
||||
}
|
||||
return;
|
||||
}
|
||||
@@ -844,8 +851,9 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
|
||||
completion.promptCompletion.text &&
|
||||
key.sequence &&
|
||||
key.sequence.length === 1 &&
|
||||
!key.alt &&
|
||||
!key.ctrl &&
|
||||
!key.meta
|
||||
!key.cmd
|
||||
) {
|
||||
completion.promptCompletion.clear();
|
||||
setExpandedSuggestionIndex(-1);
|
||||
@@ -880,6 +888,7 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
|
||||
onSubmit,
|
||||
activePtyId,
|
||||
setEmbeddedShellFocused,
|
||||
history,
|
||||
],
|
||||
);
|
||||
|
||||
|
||||
@@ -91,9 +91,10 @@ describe('MultiFolderTrustDialog', () => {
|
||||
await act(async () => {
|
||||
keypressCallback({
|
||||
name: 'escape',
|
||||
ctrl: false,
|
||||
meta: false,
|
||||
shift: false,
|
||||
alt: false,
|
||||
ctrl: false,
|
||||
cmd: false,
|
||||
sequence: '',
|
||||
insertable: false,
|
||||
});
|
||||
|
||||
@@ -0,0 +1,90 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2026 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { Box, Text } from 'ink';
|
||||
import { type AgentDefinition } from '@google/gemini-cli-core';
|
||||
import { theme } from '../semantic-colors.js';
|
||||
import {
|
||||
RadioButtonSelect,
|
||||
type RadioSelectItem,
|
||||
} from './shared/RadioButtonSelect.js';
|
||||
|
||||
export enum NewAgentsChoice {
|
||||
ACKNOWLEDGE = 'acknowledge',
|
||||
IGNORE = 'ignore',
|
||||
}
|
||||
|
||||
interface NewAgentsNotificationProps {
|
||||
agents: AgentDefinition[];
|
||||
onSelect: (choice: NewAgentsChoice) => void;
|
||||
}
|
||||
|
||||
export const NewAgentsNotification = ({
|
||||
agents,
|
||||
onSelect,
|
||||
}: NewAgentsNotificationProps) => {
|
||||
const options: Array<RadioSelectItem<NewAgentsChoice>> = [
|
||||
{
|
||||
label: 'Acknowledge and Enable',
|
||||
value: NewAgentsChoice.ACKNOWLEDGE,
|
||||
key: 'acknowledge',
|
||||
},
|
||||
{
|
||||
label: 'Do not enable (Ask again next time)',
|
||||
value: NewAgentsChoice.IGNORE,
|
||||
key: 'ignore',
|
||||
},
|
||||
];
|
||||
|
||||
// Limit display to 5 agents to avoid overflow, show count for rest
|
||||
const displayAgents = agents.slice(0, 5);
|
||||
const remaining = agents.length - 5;
|
||||
|
||||
return (
|
||||
<Box flexDirection="column" width="100%">
|
||||
<Box
|
||||
flexDirection="column"
|
||||
borderStyle="round"
|
||||
borderColor={theme.status.warning}
|
||||
padding={1}
|
||||
marginLeft={1}
|
||||
marginRight={1}
|
||||
>
|
||||
<Box flexDirection="column" marginBottom={1}>
|
||||
<Text bold color={theme.text.primary}>
|
||||
New Agents Discovered
|
||||
</Text>
|
||||
<Text color={theme.text.primary}>
|
||||
The following agents were found in this project. Please review them:
|
||||
</Text>
|
||||
<Box
|
||||
flexDirection="column"
|
||||
marginTop={1}
|
||||
marginBottom={1}
|
||||
borderStyle="single"
|
||||
padding={1}
|
||||
>
|
||||
{displayAgents.map((agent) => (
|
||||
<Box key={agent.name} flexDirection="column" marginBottom={1}>
|
||||
<Text bold>- {agent.name}</Text>
|
||||
<Text color="gray"> {agent.description}</Text>
|
||||
</Box>
|
||||
))}
|
||||
{remaining > 0 && (
|
||||
<Text color="gray">... and {remaining} more.</Text>
|
||||
)}
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
<RadioButtonSelect
|
||||
items={options}
|
||||
onSelect={onSelect}
|
||||
isFocused={true}
|
||||
/>
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user