Compare commits

...

31 Commits

Author SHA1 Message Date
Christine Betts ced2f2873d Warn user when we overwrite a command due to conflict with extensions 2026-01-21 18:01:12 -05:00
Ishaan Gupta 1033550f78 fix: /policy to display policies according to mode (#16772)
Co-authored-by: gemini-code-assist[bot] <176961590+gemini-code-assist[bot]@users.noreply.github.com>
Co-authored-by: Allen Hutchison <adh@google.com>
2026-01-21 21:33:57 +00:00
Pyush Sinha addecf2c74 fix(core): update token count and telemetry on /chat resume history load (#16279)
Co-authored-by: Tommaso Sciortino <sciortino@gmail.com>
2026-01-21 21:24:33 +00:00
Abhi c266b529ae refactor(cli): decouple UI from live tool execution via ToolActionsContext (#17183) 2026-01-21 21:16:30 +00:00
Tommaso Sciortino dce450b1e8 drain stdin on exit (#17241) 2026-01-21 21:13:10 +00:00
christine betts a91665db40 Remove unused slug from sidebar (#17229) 2026-01-21 20:00:10 +00:00
Sandy Tao f79124f96e feat(core): enable disableLLMCorrection by default (#17223) 2026-01-21 18:53:41 +00:00
Jerop Kipruto eb2af84bb9 feat(plan): add approval mode instructions to system prompt (#17151) 2026-01-21 18:44:35 +00:00
Tommaso Sciortino f190b87223 Support command/ctrl/alt backspace correctly (#17175) 2026-01-21 18:13:26 +00:00
Jenna Inouye e894871afc Docs: Marking for experimental features (#16760) 2026-01-21 18:11:42 +00:00
Gal Zahavi 45d554ae2f fix(core,cli): enable recursive directory access for (#17094) 2026-01-21 17:58:23 +00:00
N. Taylor Mullen acbef4cd31 feat(core): support dynamic variable substitution in system prompt override (#17042) 2026-01-21 17:43:50 +00:00
N. Taylor Mullen 80069b4a78 feat(cli): improve skill enablement/disablement verbiage (#17192) 2026-01-21 17:39:37 +00:00
Shreya Keshive d0cae4547e fix(admin): Ensure CLI commands run in non-interactive mode (#17218) 2026-01-21 17:38:20 +00:00
Bryan Morgan 7399c623d8 fix(github): improve label-workstream-rollup efficiency and fix bugs (#17219) 2026-01-21 12:25:48 -05:00
Vedant Mahajan 6b14dc8240 Migrate beforeTool and afterTool hooks to hookSystem (#17204)
Co-authored-by: Ishaan Gupta <ishaankone@gmail.com>
Co-authored-by: Tommaso Sciortino <sciortino@gmail.com>
2026-01-21 17:13:03 +00:00
g-samroberts 4d77934a83 Docs: Update changelogs for v.0.25.0 and v0.26.0-preview.0 releases. (#17215) 2026-01-21 17:04:13 +00:00
Bryan Morgan 9e3d36c19b fix(github): improve label-workstream-rollup efficiency with GraphQL (#17217) 2026-01-21 12:10:19 -05:00
Christian Gunderman c43b04b44c Run evals for all models. (#17123) 2026-01-21 16:38:37 +00:00
Bryan Morgan 8605d0d024 feat(workflows): support recursive workstream labeling and new IDs (#17207) 2026-01-21 11:15:38 -05:00
Jerop Kipruto c21c297133 feat(plan): refactor TestRig and eval helper to support configurable approval modes (#17171) 2026-01-21 15:43:48 +00:00
Adib234 0605e6e3e9 feat(plan): Extend Shift+Tab Mode Cycling to include Plan Mode (#17177) 2026-01-21 15:19:47 +00:00
Sehoon Shon b703c87a64 feat(skills): add code-reviewer skill (#17187) 2026-01-21 07:28:36 +00:00
christine betts a784c263be Remove other rewind reference in docs (#17149) 2026-01-21 05:26:48 +00:00
gemini-cli-robot 169d906fe3 chore(release): bump version to 0.27.0-nightly.20260121.97aac696f (#17181) 2026-01-21 05:23:58 +00:00
Abhi 525539fc13 feat(cli): implement event-driven tool execution scheduler (#17078) 2026-01-21 05:18:42 +00:00
Sehoon Shon 53e0c212cc chore(skills): update pr-creator skill workflow (#17180) 2026-01-21 05:09:53 +00:00
Sehoon Shon 7990073543 feat(cli): sanitize ANSI escape sequences in non-interactive output (#17172) 2026-01-21 04:58:37 +00:00
Sehoon Shon 367e7bf401 Update Attempt text to Retry when showing the retry happening to the … (#17178)
Co-authored-by: gemini-code-assist[bot] <176961590+gemini-code-assist[bot]@users.noreply.github.com>
2026-01-21 04:57:00 +00:00
Vedant Mahajan 3a4012f76a Remove unused modelHooks and toolHooks (#17115)
Co-authored-by: Tommaso Sciortino <sciortino@gmail.com>
2026-01-21 04:34:50 +00:00
Ishaan Gupta c9f0a48bdc remove fireAgent and beforeAgent hook (#16919)
Co-authored-by: Tommaso Sciortino <sciortino@gmail.com>
2026-01-21 04:32:07 +00:00
140 changed files with 5010 additions and 3626 deletions
+65
View File
@@ -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 -5
View File
@@ -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,14 +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. **Preflight Check**: Before creating the PR, run the workspace preflight
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.
5. **Create PR**: Use the `gh` CLI to create the PR. To avoid shell escaping
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
+8 -1
View File
@@ -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
+122 -42
View File
@@ -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}`);
}
+87
View File
@@ -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
View File
@@ -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
View File
@@ -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
-6
View File
@@ -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
+31 -31
View File
@@ -19,14 +19,14 @@ available combinations.
| Action | Keys |
| ------------------------------------------- | ------------------------------------------------------------ |
| Move the cursor to the start of the line. | `Ctrl + A`<br />`Home (no Ctrl, no Shift)` |
| Move the cursor to the end of the line. | `Ctrl + E`<br />`End (no Ctrl, no Shift)` |
| 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,12 +35,12 @@ 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
@@ -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 -->
+1 -1
View File
@@ -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
+32
View File
@@ -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 systemprompt 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
+1 -1
View File
@@ -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):
+2 -2
View File
@@ -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 -5
View File
@@ -202,7 +202,7 @@
]
},
{
"label": "Hooks",
"label": "Hooks (experimental)",
"items": [
{
"label": "Introduction",
@@ -270,10 +270,6 @@
{
"label": "Preview release",
"slug": "docs/changelogs/preview"
},
{
"label": "Changelog",
"slug": "docs/changelogs/releases"
}
]
},
+5 -1
View File
@@ -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>;
}
+1 -1
View File
@@ -626,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"');
+10 -10
View 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);
+22 -33
View File
@@ -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
View File
@@ -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 -1
View File
@@ -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",
+2 -2
View File
@@ -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",
+4 -1
View File
@@ -24,7 +24,10 @@ export const extensionsCommand: CommandModule = {
describe: 'Manage Gemini CLI extensions.',
builder: (yargs) =>
yargs
.middleware(() => initializeOutputListenersAndFlush())
.middleware((argv) => {
initializeOutputListenersAndFlush();
argv['isCommand'] = true;
})
.command(defer(installCommand, 'extensions'))
.command(defer(uninstallCommand, 'extensions'))
.command(defer(listCommand, 'extensions'))
+4 -1
View File
@@ -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),
+4 -1
View File
@@ -17,7 +17,10 @@ export const mcpCommand: CommandModule = {
describe: 'Manage MCP servers',
builder: (yargs: Argv) =>
yargs
.middleware(() => initializeOutputListenersAndFlush())
.middleware((argv) => {
initializeOutputListenersAndFlush();
argv['isCommand'] = true;
})
.command(defer(addCommand, 'mcp'))
.command(defer(removeCommand, 'mcp'))
.command(defer(listCommand, 'mcp'))
+4 -1
View File
@@ -19,7 +19,10 @@ export const skillsCommand: CommandModule = {
describe: 'Manage agent skills.',
builder: (yargs) =>
yargs
.middleware(() => initializeOutputListenersAndFlush())
.middleware((argv) => {
initializeOutputListenersAndFlush();
argv['isCommand'] = true;
})
.command(defer(listCommand, 'skills'))
.command(defer(enableCommand, 'skills'))
.command(defer(disableCommand, 'skills'))
+128 -1
View File
@@ -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', () => {
+18 -3
View File
@@ -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
@@ -465,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,
@@ -564,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);
@@ -759,6 +772,8 @@ 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:
@@ -68,6 +68,10 @@ import {
ExtensionSettingScope,
} from './extensions/extensionSettings.js';
import type { EventEmitter } from 'node:stream';
import { glob } from 'glob';
import { BuiltinCommandLoader } from '../services/BuiltinCommandLoader.js';
import { McpPromptLoader } from '../services/McpPromptLoader.js';
import { FileCommandLoader } from '../services/FileCommandLoader.js';
interface ExtensionManagerParams {
enabledExtensionOverrides?: string[];
@@ -236,6 +240,9 @@ Would you like to attempt to install via "git clone" instead?`,
newExtensionConfig = await this.loadExtensionConfig(localSourcePath);
const newExtensionName = newExtensionConfig.name;
await this.checkCommandConflicts(localSourcePath, newExtensionName);
const previous = this.getExtensions().find(
(installed) => installed.name === newExtensionName,
);
@@ -899,6 +906,84 @@ Would you like to attempt to install via "git clone" instead?`,
}
await this.maybeStartExtension(extension);
}
private async checkCommandConflicts(
localSourcePath: string,
extensionName: string,
): Promise<void> {
const abortController = new AbortController();
const signal = abortController.signal;
// 1. Get current commands
const currentLoaders = [
new McpPromptLoader(this.config ?? null),
new BuiltinCommandLoader(this.config ?? null),
new FileCommandLoader(this.config ?? null),
];
const currentCommandsResults = await Promise.allSettled(
currentLoaders.map((l) => l.loadCommands(signal)),
);
const currentCommandNames = new Set<string>();
for (const result of currentCommandsResults) {
if (result.status === 'fulfilled') {
result.value.forEach((cmd) => {
// If it's an update, don't count existing commands from the SAME extension as conflicts
if (cmd.extensionName !== extensionName) {
currentCommandNames.add(cmd.name);
}
});
}
}
// 2. Get commands from the new/updated extension
const extensionCommandsDir = path.join(localSourcePath, 'commands');
if (!fs.existsSync(extensionCommandsDir)) {
return;
}
const files = await glob('**/*.toml', {
cwd: extensionCommandsDir,
nodir: true,
dot: true,
follow: true,
});
const conflicts: Array<{
commandName: string;
renamedName: string;
}> = [];
for (const file of files) {
const relativePath = file.substring(0, file.length - 5); // length of '.toml'
const baseCommandName = relativePath
.split(path.sep)
.map((segment) => segment.replaceAll(':', '_'))
.join(':');
if (currentCommandNames.has(baseCommandName)) {
conflicts.push({
commandName: baseCommandName,
renamedName: `${extensionName}.${baseCommandName}`,
});
}
}
if (conflicts.length > 0) {
const conflictList = conflicts
.map(
(c) =>
` - '/${c.commandName}' (will be renamed to '/${c.renamedName}')`,
)
.join('\n');
const warning = `WARNING: Installing extension '${extensionName}' will cause the following command conflicts:\n${conflictList}\n\nDo you want to continue installation?`;
if (!(await this.requestConsent(warning))) {
throw new Error('Installation cancelled due to command conflicts.');
}
}
}
}
function filterMcpConfig(original: MCPServerConfig): MCPServerConfig {
+8 -5
View File
@@ -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');
}
}
}
+41 -36
View File
@@ -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;
}
/**
@@ -119,51 +121,54 @@ export const defaultKeyBindings: KeyBindingConfig = {
// Cursor Movement
[Command.HOME]: [
{ key: 'a', ctrl: true },
{ key: 'home', ctrl: false, shift: false },
{ key: 'home', shift: false, ctrl: false },
],
[Command.END]: [
{ key: 'e', ctrl: true },
{ key: 'end', ctrl: false, shift: false },
{ 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_UP]: [{ key: 'up', ctrl: false, command: false }],
[Command.MOVE_DOWN]: [{ key: 'down', ctrl: false, command: false }],
[Command.MOVE_LEFT]: [
{ key: 'left', ctrl: false, command: false },
{ key: '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 }],
@@ -180,10 +185,9 @@ export const defaultKeyBindings: KeyBindingConfig = {
[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' }],
@@ -203,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' }],
@@ -220,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' }],
@@ -352,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,
@@ -437,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.',
+11 -5
View File
@@ -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' },
});
+1 -1
View File
@@ -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.
+144 -10
View File
@@ -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', () => {
+6 -6
View File
@@ -373,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,
);
@@ -389,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,
+255
View File
@@ -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'),
);
});
});
});
+17 -3
View File
@@ -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) {
@@ -8,7 +8,7 @@ import { vi, describe, it, expect, beforeEach, afterEach } from 'vitest';
import { CommandService } from './CommandService.js';
import { type ICommandLoader } from './types.js';
import { CommandKind, type SlashCommand } from '../ui/commands/types.js';
import { debugLogger } from '@google/gemini-cli-core';
import { debugLogger, coreEvents } from '@google/gemini-cli-core';
const createMockCommand = (name: string, kind: CommandKind): SlashCommand => ({
name,
@@ -37,6 +37,7 @@ class MockCommandLoader implements ICommandLoader {
describe('CommandService', () => {
beforeEach(() => {
vi.spyOn(debugLogger, 'debug').mockImplementation(() => {});
CommandService.clearEmittedFeedbacksForTest();
});
afterEach(() => {
@@ -237,6 +238,32 @@ describe('CommandService', () => {
expect(syncExtension?.extensionName).toBe('git-helper');
});
it('should emit feedback when an extension command is renamed', async () => {
const builtinCommand = createMockCommand('deploy', CommandKind.BUILT_IN);
const extensionCommand = {
...createMockCommand('deploy', CommandKind.FILE),
extensionName: 'firebase',
description: '[firebase] Deploy to Firebase',
};
const mockLoader1 = new MockCommandLoader([builtinCommand]);
const mockLoader2 = new MockCommandLoader([extensionCommand]);
const emitFeedbackSpy = vi.spyOn(coreEvents, 'emitFeedback');
await CommandService.create(
[mockLoader1, mockLoader2],
new AbortController().signal,
);
expect(emitFeedbackSpy).toHaveBeenCalledWith(
'info',
expect.stringContaining(
"Extension command '/deploy' from 'firebase' was renamed to '/firebase.deploy'",
),
);
});
it('should handle user/project command override correctly', async () => {
const builtinCommand = createMockCommand('help', CommandKind.BUILT_IN);
const userCommand = createMockCommand('help', CommandKind.FILE);
+17 -1
View File
@@ -4,7 +4,7 @@
* SPDX-License-Identifier: Apache-2.0
*/
import { debugLogger } from '@google/gemini-cli-core';
import { debugLogger, coreEvents } from '@google/gemini-cli-core';
import type { SlashCommand } from '../ui/commands/types.js';
import type { ICommandLoader } from './types.js';
@@ -20,6 +20,16 @@ import type { ICommandLoader } from './types.js';
* system to be extended with new sources without modifying the service itself.
*/
export class CommandService {
private static emittedFeedbacks = new Set<string>();
/**
* Clears the set of emitted feedback messages.
* This should ONLY be used in tests to ensure isolation between test cases.
*/
static clearEmittedFeedbacksForTest(): void {
CommandService.emittedFeedbacks.clear();
}
/**
* Private constructor to enforce the use of the async factory.
* @param commands A readonly array of the fully loaded and de-duplicated commands.
@@ -77,6 +87,12 @@ export class CommandService {
suffix++;
}
const feedbackMsg = `Extension command '/${cmd.name}' from '${cmd.extensionName}' was renamed to '/${renamedName}' due to a conflict with an existing command.`;
if (!CommandService.emittedFeedbacks.has(feedbackMsg)) {
coreEvents.emitFeedback('info', feedbackMsg);
CommandService.emittedFeedbacks.add(feedbackMsg);
}
finalName = renamedName;
}
@@ -44,7 +44,7 @@ interface CommandDirectory {
* Defines the Zod schema for a command definition file. This serves as the
* single source of truth for both validation and type inference.
*/
const TomlCommandDefSchema = z.object({
export const TomlCommandDefSchema = z.object({
prompt: z.string({
required_error: "The 'prompt' field is required.",
invalid_type_error: "The 'prompt' field must be a string.",
+22 -15
View File
@@ -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';
@@ -239,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}>
@@ -247,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>
+22 -16
View File
@@ -136,7 +136,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 +164,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 +236,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 +335,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,
@@ -1660,9 +1660,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 +1871,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 +1898,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 +1913,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 +1934,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 +1950,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',
});
+21 -7
View File
@@ -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,
@@ -102,7 +104,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 {
@@ -854,7 +856,7 @@ Logging in with Google... Restarting Gemini CLI to continue.
);
// Auto-accept indicator
const showAutoAcceptIndicator = useAutoAcceptIndicator({
const showApprovalModeIndicator = useApprovalModeIndicator({
config,
addItem: historyManager.addItem,
onApprovalModeChange: handleApprovalModeChange,
@@ -1486,6 +1488,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(),
);
@@ -1590,7 +1602,7 @@ Logging in with Google... Restarting Gemini CLI to continue.
activeHooks,
messageQueue,
queueErrorMessage,
showAutoAcceptIndicator,
showApprovalModeIndicator,
currentModel,
userTier,
proQuotaRequest,
@@ -1682,7 +1694,7 @@ Logging in with Google... Restarting Gemini CLI to continue.
activeHooks,
messageQueue,
queueErrorMessage,
showAutoAcceptIndicator,
showApprovalModeIndicator,
userTier,
proQuotaRequest,
validationRequest,
@@ -1832,9 +1844,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
@@ -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);
@@ -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');
});
});
});
+53 -19
View File
@@ -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: {},
@@ -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');
@@ -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,7 +95,7 @@ const createMockUIState = (overrides: Partial<UIState> = {}): UIState =>
({
streamingState: null,
contextFileNames: [],
showAutoAcceptIndicator: ApprovalMode.DEFAULT,
showApprovalModeIndicator: ApprovalMode.DEFAULT,
messageQueue: [],
showErrorDetails: false,
constrainHeight: false,
@@ -419,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', () => {
+5 -5
View File
@@ -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}
@@ -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,
@@ -851,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);
@@ -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,
});
@@ -93,10 +93,10 @@ const createMockConfig = (overrides: Partial<Config> = {}): Config =>
const triggerKey = (
partialKey: Partial<{
name: string;
ctrl: boolean;
meta: boolean;
shift: boolean;
paste: boolean;
alt: boolean;
ctrl: boolean;
cmd: boolean;
insertable: boolean;
sequence: string;
}>,
@@ -108,9 +108,10 @@ const triggerKey = (
const key = {
name: '',
ctrl: false,
meta: false,
shift: false,
alt: false,
ctrl: false,
cmd: false,
insertable: false,
sequence: '',
...partialKey,
@@ -263,7 +264,13 @@ describe('SessionBrowser component', () => {
// Type the query "query".
for (const ch of ['q', 'u', 'e', 'r', 'y']) {
triggerKey({ sequence: ch, name: ch, ctrl: false, meta: false });
triggerKey({
sequence: ch,
name: ch,
alt: false,
ctrl: false,
cmd: false,
});
}
await waitFor(() => {
@@ -781,9 +781,10 @@ export const useSessionBrowserInput = (
state.setScrollOffset(0);
} else if (
key.sequence &&
key.sequence.length === 1 &&
!key.alt &&
!key.ctrl &&
!key.meta &&
key.sequence.length === 1
!key.cmd
) {
state.setSearchQuery((prev) => prev + key.sequence);
state.setActiveIndex(0);
@@ -53,7 +53,14 @@ describe('ShellInputPrompt', () => {
const handler = mockUseKeypress.mock.calls[0][0];
// Simulate keypress
handler({ name, sequence, ctrl: false, shift: false, meta: false });
handler({
name,
shift: false,
alt: false,
ctrl: false,
cmd: false,
sequence,
});
expect(mockWriteToPty).toHaveBeenCalledWith(1, sequence);
});
@@ -66,7 +73,7 @@ describe('ShellInputPrompt', () => {
const handler = mockUseKeypress.mock.calls[0][0];
handler({ name: key, ctrl: true, shift: true, meta: false });
handler({ name: key, shift: true, alt: false, ctrl: true, cmd: false });
expect(mockScrollPty).toHaveBeenCalledWith(1, direction);
});
@@ -78,10 +85,11 @@ describe('ShellInputPrompt', () => {
handler({
name: 'a',
sequence: 'a',
ctrl: false,
shift: false,
meta: false,
alt: false,
ctrl: false,
cmd: false,
sequence: 'a',
});
expect(mockWriteToPty).not.toHaveBeenCalled();
@@ -94,10 +102,11 @@ describe('ShellInputPrompt', () => {
handler({
name: 'a',
sequence: 'a',
ctrl: false,
shift: false,
meta: false,
alt: false,
ctrl: false,
cmd: false,
sequence: 'a',
});
expect(mockWriteToPty).not.toHaveBeenCalled();
@@ -15,6 +15,7 @@ export interface Suggestion {
description?: string;
matchedIndex?: number;
commandKind?: CommandKind;
extensionName?: string;
}
interface SuggestionsDisplayProps {
suggestions: Suggestion[];
@@ -65,8 +66,13 @@ export function SuggestionsDisplay({
[CommandKind.AGENT]: ' [Agent]',
};
const getFullLabel = (s: Suggestion) =>
s.label + (s.commandKind ? (COMMAND_KIND_SUFFIX[s.commandKind] ?? '') : '');
const getFullLabel = (s: Suggestion) => {
let label = s.label;
if (s.commandKind && COMMAND_KIND_SUFFIX[s.commandKind]) {
label += COMMAND_KIND_SUFFIX[s.commandKind];
}
return label;
};
const maxLabelLength = Math.max(
...suggestions.map((s) => getFullLabel(s).length),
@@ -35,6 +35,7 @@ describe('ToolConfirmationMessage Redirection', () => {
const { lastFrame } = renderWithProviders(
<ToolConfirmationMessage
callId="test-call-id"
confirmationDetails={confirmationDetails}
config={mockConfig}
availableTerminalHeight={30}
@@ -14,8 +14,26 @@ import {
renderWithProviders,
createMockSettings,
} from '../../../test-utils/render.js';
import { useToolActions } from '../../contexts/ToolActionsContext.js';
vi.mock('../../contexts/ToolActionsContext.js', async (importOriginal) => {
const actual =
await importOriginal<
typeof import('../../contexts/ToolActionsContext.js')
>();
return {
...actual,
useToolActions: vi.fn(),
};
});
describe('ToolConfirmationMessage', () => {
const mockConfirm = vi.fn();
vi.mocked(useToolActions).mockReturnValue({
confirm: mockConfirm,
cancel: vi.fn(),
});
const mockConfig = {
isTrustedFolder: () => true,
getIdeMode: () => false,
@@ -32,6 +50,7 @@ describe('ToolConfirmationMessage', () => {
const { lastFrame } = renderWithProviders(
<ToolConfirmationMessage
callId="test-call-id"
confirmationDetails={confirmationDetails}
config={mockConfig}
availableTerminalHeight={30}
@@ -56,6 +75,7 @@ describe('ToolConfirmationMessage', () => {
const { lastFrame } = renderWithProviders(
<ToolConfirmationMessage
callId="test-call-id"
confirmationDetails={confirmationDetails}
config={mockConfig}
availableTerminalHeight={30}
@@ -79,6 +99,7 @@ describe('ToolConfirmationMessage', () => {
const { lastFrame } = renderWithProviders(
<ToolConfirmationMessage
callId="test-call-id"
confirmationDetails={confirmationDetails}
config={mockConfig}
availableTerminalHeight={30}
@@ -161,6 +182,7 @@ describe('ToolConfirmationMessage', () => {
const { lastFrame } = renderWithProviders(
<ToolConfirmationMessage
callId="test-call-id"
confirmationDetails={details}
config={mockConfig}
availableTerminalHeight={30}
@@ -179,6 +201,7 @@ describe('ToolConfirmationMessage', () => {
const { lastFrame } = renderWithProviders(
<ToolConfirmationMessage
callId="test-call-id"
confirmationDetails={details}
config={mockConfig}
availableTerminalHeight={30}
@@ -211,6 +234,7 @@ describe('ToolConfirmationMessage', () => {
const { lastFrame } = renderWithProviders(
<ToolConfirmationMessage
callId="test-call-id"
confirmationDetails={editConfirmationDetails}
config={mockConfig}
availableTerminalHeight={30}
@@ -234,6 +258,7 @@ describe('ToolConfirmationMessage', () => {
const { lastFrame } = renderWithProviders(
<ToolConfirmationMessage
callId="test-call-id"
confirmationDetails={editConfirmationDetails}
config={mockConfig}
availableTerminalHeight={30}
@@ -5,20 +5,20 @@
*/
import type React from 'react';
import { useEffect, useState, useMemo } from 'react';
import { useMemo } from 'react';
import { Box, Text } from 'ink';
import { DiffRenderer } from './DiffRenderer.js';
import { RenderInline } from '../../utils/InlineMarkdownRenderer.js';
import type {
ToolCallConfirmationDetails,
Config,
} from '@google/gemini-cli-core';
import {
IdeClient,
type SerializableConfirmationDetails,
type ToolCallConfirmationDetails,
type Config,
ToolConfirmationOutcome,
hasRedirection,
debugLogger,
} from '@google/gemini-cli-core';
import type { RadioSelectItem } from '../shared/RadioButtonSelect.js';
import { useToolActions } from '../../contexts/ToolActionsContext.js';
import { RadioButtonSelect } from '../shared/RadioButtonSelect.js';
import { MaxSizedBox, MINIMUM_MAX_HEIGHT } from '../shared/MaxSizedBox.js';
import { useKeypress } from '../../hooks/useKeypress.js';
@@ -32,7 +32,10 @@ import {
} from '../../textConstants.js';
export interface ToolConfirmationMessageProps {
confirmationDetails: ToolCallConfirmationDetails;
callId: string;
confirmationDetails:
| ToolCallConfirmationDetails
| SerializableConfirmationDetails;
config: Config;
isFocused?: boolean;
availableTerminalHeight?: number;
@@ -42,52 +45,26 @@ export interface ToolConfirmationMessageProps {
export const ToolConfirmationMessage: React.FC<
ToolConfirmationMessageProps
> = ({
callId,
confirmationDetails,
config,
isFocused = true,
availableTerminalHeight,
terminalWidth,
}) => {
const { onConfirm } = confirmationDetails;
const { confirm } = useToolActions();
const settings = useSettings();
const allowPermanentApproval =
settings.merged.security.enablePermanentToolApproval;
const [ideClient, setIdeClient] = useState<IdeClient | null>(null);
const [isDiffingEnabled, setIsDiffingEnabled] = useState(false);
useEffect(() => {
let isMounted = true;
if (config.getIdeMode()) {
const getIdeClient = async () => {
const client = await IdeClient.getInstance();
if (isMounted) {
setIdeClient(client);
setIsDiffingEnabled(client?.isDiffingEnabled() ?? false);
}
};
// eslint-disable-next-line @typescript-eslint/no-floating-promises
getIdeClient();
}
return () => {
isMounted = false;
};
}, [config]);
const handleConfirm = async (outcome: ToolConfirmationOutcome) => {
if (confirmationDetails.type === 'edit') {
if (config.getIdeMode() && isDiffingEnabled) {
const cliOutcome =
outcome === ToolConfirmationOutcome.Cancel ? 'rejected' : 'accepted';
await ideClient?.resolveDiffFromCli(
confirmationDetails.filePath,
cliOutcome,
);
}
}
// eslint-disable-next-line @typescript-eslint/no-floating-promises
onConfirm(outcome);
const handleConfirm = (outcome: ToolConfirmationOutcome) => {
void confirm(callId, outcome).catch((error) => {
debugLogger.error(
`Failed to handle tool confirmation for ${callId}:`,
error,
);
});
};
const isTrustedFolder = config.isTrustedFolder();
@@ -96,7 +73,6 @@ export const ToolConfirmationMessage: React.FC<
(key) => {
if (!isFocused) return;
if (key.name === 'escape' || (key.ctrl && key.name === 'c')) {
// eslint-disable-next-line @typescript-eslint/no-floating-promises
handleConfirm(ToolConfirmationOutcome.Cancel);
}
},
@@ -132,7 +108,9 @@ export const ToolConfirmationMessage: React.FC<
});
}
}
if (!config.getIdeMode() || !isDiffingEnabled) {
// We hide "Modify with external editor" if IDE mode is active, assuming
// the IDE provides a better interface (diff view) for this.
if (!config.getIdeMode()) {
options.push({
label: 'Modify with external editor',
value: ToolConfirmationOutcome.ModifyWithEditor,
@@ -400,7 +378,6 @@ export const ToolConfirmationMessage: React.FC<
confirmationDetails,
isTrustedFolder,
config,
isDiffingEnabled,
availableTerminalHeight,
terminalWidth,
allowPermanentApproval,
@@ -39,6 +39,11 @@ describe('<ToolGroupMessage />', () => {
const toolCalls = [createToolCall()];
const { lastFrame, unmount } = renderWithProviders(
<ToolGroupMessage {...baseProps} toolCalls={toolCalls} />,
{
uiState: {
pendingHistoryItems: [{ type: 'tool_group', tools: toolCalls }],
},
},
);
expect(lastFrame()).toMatchSnapshot();
unmount();
@@ -67,6 +72,11 @@ describe('<ToolGroupMessage />', () => {
];
const { lastFrame, unmount } = renderWithProviders(
<ToolGroupMessage {...baseProps} toolCalls={toolCalls} />,
{
uiState: {
pendingHistoryItems: [{ type: 'tool_group', tools: toolCalls }],
},
},
);
expect(lastFrame()).toMatchSnapshot();
unmount();
@@ -89,6 +99,11 @@ describe('<ToolGroupMessage />', () => {
];
const { lastFrame, unmount } = renderWithProviders(
<ToolGroupMessage {...baseProps} toolCalls={toolCalls} />,
{
uiState: {
pendingHistoryItems: [{ type: 'tool_group', tools: toolCalls }],
},
},
);
expect(lastFrame()).toMatchSnapshot();
unmount();
@@ -105,6 +120,11 @@ describe('<ToolGroupMessage />', () => {
];
const { lastFrame, unmount } = renderWithProviders(
<ToolGroupMessage {...baseProps} toolCalls={toolCalls} />,
{
uiState: {
pendingHistoryItems: [{ type: 'tool_group', tools: toolCalls }],
},
},
);
expect(lastFrame()).toMatchSnapshot();
unmount();
@@ -133,6 +153,11 @@ describe('<ToolGroupMessage />', () => {
];
const { lastFrame, unmount } = renderWithProviders(
<ToolGroupMessage {...baseProps} toolCalls={toolCalls} />,
{
uiState: {
pendingHistoryItems: [{ type: 'tool_group', tools: toolCalls }],
},
},
);
expect(lastFrame()).toMatchSnapshot();
unmount();
@@ -160,6 +185,11 @@ describe('<ToolGroupMessage />', () => {
toolCalls={toolCalls}
availableTerminalHeight={10}
/>,
{
uiState: {
pendingHistoryItems: [{ type: 'tool_group', tools: toolCalls }],
},
},
);
expect(lastFrame()).toMatchSnapshot();
unmount();
@@ -173,6 +203,11 @@ describe('<ToolGroupMessage />', () => {
toolCalls={toolCalls}
isFocused={false}
/>,
{
uiState: {
pendingHistoryItems: [{ type: 'tool_group', tools: toolCalls }],
},
},
);
expect(lastFrame()).toMatchSnapshot();
unmount();
@@ -192,6 +227,11 @@ describe('<ToolGroupMessage />', () => {
toolCalls={toolCalls}
terminalWidth={40}
/>,
{
uiState: {
pendingHistoryItems: [{ type: 'tool_group', tools: toolCalls }],
},
},
);
expect(lastFrame()).toMatchSnapshot();
unmount();
@@ -200,6 +240,11 @@ describe('<ToolGroupMessage />', () => {
it('renders empty tool calls array', () => {
const { lastFrame, unmount } = renderWithProviders(
<ToolGroupMessage {...baseProps} toolCalls={[]} />,
{
uiState: {
pendingHistoryItems: [{ type: 'tool_group', tools: [] }],
},
},
);
expect(lastFrame()).toMatchSnapshot();
unmount();
@@ -225,6 +270,11 @@ describe('<ToolGroupMessage />', () => {
<Scrollable height={10} hasFocus={true} scrollToBottom={true}>
<ToolGroupMessage {...baseProps} toolCalls={toolCalls} />
</Scrollable>,
{
uiState: {
pendingHistoryItems: [{ type: 'tool_group', tools: toolCalls }],
},
},
);
expect(lastFrame()).toMatchSnapshot();
unmount();
@@ -242,6 +292,11 @@ describe('<ToolGroupMessage />', () => {
];
const { lastFrame, unmount } = renderWithProviders(
<ToolGroupMessage {...baseProps} toolCalls={toolCalls} />,
{
uiState: {
pendingHistoryItems: [{ type: 'tool_group', tools: toolCalls }],
},
},
);
expect(lastFrame()).toMatchSnapshot();
unmount();
@@ -270,6 +325,14 @@ describe('<ToolGroupMessage />', () => {
<ToolGroupMessage {...baseProps} toolCalls={toolCalls1} />
<ToolGroupMessage {...baseProps} toolCalls={toolCalls2} />
</Scrollable>,
{
uiState: {
pendingHistoryItems: [
{ type: 'tool_group', tools: toolCalls1 },
{ type: 'tool_group', tools: toolCalls2 },
],
},
},
);
expect(lastFrame()).toMatchSnapshot();
unmount();
@@ -281,6 +344,11 @@ describe('<ToolGroupMessage />', () => {
const toolCalls = [createToolCall({ status: ToolCallStatus.Pending })];
const { lastFrame, unmount } = renderWithProviders(
<ToolGroupMessage {...baseProps} toolCalls={toolCalls} />,
{
uiState: {
pendingHistoryItems: [{ type: 'tool_group', tools: toolCalls }],
},
},
);
// The snapshot will capture the visual appearance including border color
expect(lastFrame()).toMatchSnapshot();
@@ -296,6 +364,11 @@ describe('<ToolGroupMessage />', () => {
];
const { lastFrame, unmount } = renderWithProviders(
<ToolGroupMessage {...baseProps} toolCalls={toolCalls} />,
{
uiState: {
pendingHistoryItems: [{ type: 'tool_group', tools: toolCalls }],
},
},
);
expect(lastFrame()).toMatchSnapshot();
unmount();
@@ -312,6 +385,11 @@ describe('<ToolGroupMessage />', () => {
];
const { lastFrame, unmount } = renderWithProviders(
<ToolGroupMessage {...baseProps} toolCalls={toolCalls} />,
{
uiState: {
pendingHistoryItems: [{ type: 'tool_group', tools: toolCalls }],
},
},
);
expect(lastFrame()).toMatchSnapshot();
unmount();
@@ -340,6 +418,11 @@ describe('<ToolGroupMessage />', () => {
toolCalls={toolCalls}
availableTerminalHeight={20}
/>,
{
uiState: {
pendingHistoryItems: [{ type: 'tool_group', tools: toolCalls }],
},
},
);
expect(lastFrame()).toMatchSnapshot();
unmount();
@@ -374,6 +457,11 @@ describe('<ToolGroupMessage />', () => {
];
const { lastFrame, unmount } = renderWithProviders(
<ToolGroupMessage {...baseProps} toolCalls={toolCalls} />,
{
uiState: {
pendingHistoryItems: [{ type: 'tool_group', tools: toolCalls }],
},
},
);
// Should only show confirmation for the first tool
expect(lastFrame()).toMatchSnapshot();
@@ -399,7 +487,12 @@ describe('<ToolGroupMessage />', () => {
});
const { lastFrame, unmount } = renderWithProviders(
<ToolGroupMessage {...baseProps} toolCalls={toolCalls} />,
{ settings },
{
settings,
uiState: {
pendingHistoryItems: [{ type: 'tool_group', tools: toolCalls }],
},
},
);
expect(lastFrame()).toContain('Allow for all future sessions');
expect(lastFrame()).toMatchSnapshot();
@@ -425,7 +518,12 @@ describe('<ToolGroupMessage />', () => {
});
const { lastFrame, unmount } = renderWithProviders(
<ToolGroupMessage {...baseProps} toolCalls={toolCalls} />,
{ settings },
{
settings,
uiState: {
pendingHistoryItems: [{ type: 'tool_group', tools: toolCalls }],
},
},
);
expect(lastFrame()).not.toContain('Allow for all future sessions');
expect(lastFrame()).toMatchSnapshot();
@@ -157,6 +157,7 @@ export const ToolGroupMessage: React.FC<ToolGroupMessageProps> = ({
isConfirming &&
tool.confirmationDetails && (
<ToolConfirmationMessage
callId={tool.callId}
confirmationDetails={tool.confirmationDetails}
config={config}
isFocused={isFocused}
@@ -151,18 +151,20 @@ describe('TextInput', () => {
keypressHandler({
name: 'a',
sequence: 'a',
ctrl: false,
meta: false,
shift: false,
alt: false,
ctrl: false,
cmd: false,
sequence: 'a',
});
expect(mockBuffer.handleInput).toHaveBeenCalledWith({
name: 'a',
sequence: 'a',
ctrl: false,
meta: false,
shift: false,
alt: false,
ctrl: false,
cmd: false,
sequence: 'a',
});
expect(mockBuffer.text).toBe('a');
});
@@ -176,18 +178,20 @@ describe('TextInput', () => {
keypressHandler({
name: 'backspace',
sequence: '',
ctrl: false,
meta: false,
shift: false,
alt: false,
ctrl: false,
cmd: false,
sequence: '',
});
expect(mockBuffer.handleInput).toHaveBeenCalledWith({
name: 'backspace',
sequence: '',
ctrl: false,
meta: false,
shift: false,
alt: false,
ctrl: false,
cmd: false,
sequence: '',
});
expect(mockBuffer.text).toBe('tes');
});
@@ -201,10 +205,11 @@ describe('TextInput', () => {
keypressHandler({
name: 'left',
sequence: '',
ctrl: false,
meta: false,
shift: false,
alt: false,
ctrl: false,
cmd: false,
sequence: '',
});
// Cursor moves from end to before 't'
@@ -221,10 +226,11 @@ describe('TextInput', () => {
keypressHandler({
name: 'right',
sequence: '',
ctrl: false,
meta: false,
shift: false,
alt: false,
ctrl: false,
cmd: false,
sequence: '',
});
expect(mockBuffer.visualCursor[1]).toBe(3);
@@ -239,10 +245,11 @@ describe('TextInput', () => {
keypressHandler({
name: 'return',
sequence: '',
ctrl: false,
meta: false,
shift: false,
alt: false,
ctrl: false,
cmd: false,
sequence: '',
});
expect(onSubmit).toHaveBeenCalledWith('test');
@@ -257,10 +264,11 @@ describe('TextInput', () => {
keypressHandler({
name: 'escape',
sequence: '',
ctrl: false,
meta: false,
shift: false,
alt: false,
ctrl: false,
cmd: false,
sequence: '',
});
await vi.runAllTimersAsync();
@@ -1059,9 +1059,10 @@ describe('useTextBuffer', () => {
act(() =>
result.current.handleInput({
name: 'h',
ctrl: false,
meta: false,
shift: false,
alt: false,
ctrl: false,
cmd: false,
insertable: true,
sequence: 'h',
}),
@@ -1069,9 +1070,10 @@ describe('useTextBuffer', () => {
act(() =>
result.current.handleInput({
name: 'i',
ctrl: false,
meta: false,
shift: false,
alt: false,
ctrl: false,
cmd: false,
insertable: true,
sequence: 'i',
}),
@@ -1086,9 +1088,10 @@ describe('useTextBuffer', () => {
act(() =>
result.current.handleInput({
name: 'return',
ctrl: false,
meta: false,
shift: false,
alt: false,
ctrl: false,
cmd: false,
insertable: true,
sequence: '\r',
}),
@@ -1103,9 +1106,10 @@ describe('useTextBuffer', () => {
act(() =>
result.current.handleInput({
name: 'j',
ctrl: true,
meta: false,
shift: false,
alt: false,
ctrl: true,
cmd: false,
insertable: false,
sequence: '\n',
}),
@@ -1120,9 +1124,10 @@ describe('useTextBuffer', () => {
act(() =>
result.current.handleInput({
name: 'tab',
ctrl: false,
meta: false,
shift: false,
alt: false,
ctrl: false,
cmd: false,
insertable: false,
sequence: '\t',
}),
@@ -1137,9 +1142,10 @@ describe('useTextBuffer', () => {
act(() =>
result.current.handleInput({
name: 'tab',
ctrl: false,
meta: false,
shift: true,
alt: false,
ctrl: false,
cmd: false,
insertable: false,
sequence: '\u001b[9;2u',
}),
@@ -1159,9 +1165,10 @@ describe('useTextBuffer', () => {
act(() =>
result.current.handleInput({
name: 'backspace',
ctrl: false,
meta: false,
shift: false,
alt: false,
ctrl: false,
cmd: false,
insertable: false,
sequence: '\x7f',
}),
@@ -1183,25 +1190,28 @@ describe('useTextBuffer', () => {
act(() => {
result.current.handleInput({
name: 'backspace',
ctrl: false,
meta: false,
shift: false,
alt: false,
ctrl: false,
cmd: false,
insertable: false,
sequence: '\x7f',
});
result.current.handleInput({
name: 'backspace',
ctrl: false,
meta: false,
shift: false,
alt: false,
ctrl: false,
cmd: false,
insertable: false,
sequence: '\x7f',
});
result.current.handleInput({
name: 'backspace',
ctrl: false,
meta: false,
shift: false,
alt: false,
ctrl: false,
cmd: false,
insertable: false,
sequence: '\x7f',
});
@@ -1258,24 +1268,26 @@ describe('useTextBuffer', () => {
act(() =>
result.current.handleInput({
name: 'left',
ctrl: false,
meta: false,
shift: false,
alt: false,
ctrl: false,
cmd: false,
insertable: false,
sequence: '\x1b[D',
}),
); // cursor [0,1]
);
expect(getBufferState(result).cursor).toEqual([0, 1]);
act(() =>
result.current.handleInput({
name: 'right',
ctrl: false,
meta: false,
shift: false,
alt: false,
ctrl: false,
cmd: false,
insertable: false,
sequence: '\x1b[C',
}),
); // cursor [0,2]
);
expect(getBufferState(result).cursor).toEqual([0, 2]);
});
@@ -1288,9 +1300,10 @@ describe('useTextBuffer', () => {
act(() =>
result.current.handleInput({
name: '',
ctrl: false,
meta: false,
shift: false,
alt: false,
ctrl: false,
cmd: false,
insertable: true,
sequence: textWithAnsi,
}),
@@ -1305,9 +1318,10 @@ describe('useTextBuffer', () => {
act(() =>
result.current.handleInput({
name: 'return',
ctrl: false,
meta: false,
shift: true,
alt: false,
ctrl: false,
cmd: false,
insertable: true,
sequence: '\r',
}),
@@ -1509,13 +1523,13 @@ Contrary to popular belief, Lorem Ipsum is not simply random text. It has roots
describe('Input Sanitization', () => {
const createInput = (sequence: string) => ({
name: '',
ctrl: false,
meta: false,
shift: false,
alt: false,
ctrl: false,
cmd: false,
insertable: true,
sequence,
});
it.each([
{
input: '\x1B[31mHello\x1B[0m \x1B[32mWorld\x1B[0m',
@@ -1567,9 +1581,10 @@ Contrary to popular belief, Lorem Ipsum is not simply random text. It has roots
act(() =>
result.current.handleInput({
name: '',
ctrl: false,
meta: false,
shift: false,
alt: false,
ctrl: false,
cmd: false,
insertable: true,
sequence: largeTextWithUnsafe,
}),
@@ -1601,9 +1616,10 @@ Contrary to popular belief, Lorem Ipsum is not simply random text. It has roots
act(() =>
result.current.handleInput({
name: '',
ctrl: false,
meta: false,
shift: false,
alt: false,
ctrl: false,
cmd: false,
insertable: true,
sequence: largeTextWithAnsi,
}),
@@ -1625,9 +1641,10 @@ Contrary to popular belief, Lorem Ipsum is not simply random text. It has roots
act(() =>
result.current.handleInput({
name: '',
ctrl: false,
meta: false,
shift: false,
alt: false,
ctrl: false,
cmd: false,
insertable: true,
sequence: emojis,
}),
@@ -1816,9 +1833,10 @@ Contrary to popular belief, Lorem Ipsum is not simply random text. It has roots
act(() =>
result.current.handleInput({
name: 'return',
ctrl: false,
meta: false,
shift: false,
alt: false,
ctrl: false,
cmd: false,
insertable: true,
sequence: '\r',
}),
@@ -1837,9 +1855,10 @@ Contrary to popular belief, Lorem Ipsum is not simply random text. It has roots
act(() =>
result.current.handleInput({
name: 'f1',
ctrl: false,
meta: false,
shift: false,
alt: false,
ctrl: false,
cmd: false,
insertable: false,
sequence: '\u001bOP',
}),
+2 -2
View File
@@ -90,8 +90,8 @@ export const INFORMATIVE_TIPS = [
'Toggle the todo list display with Ctrl+T…',
'See full, untruncated responses with Ctrl+S…',
'Toggle auto-approval (YOLO mode) for all tools with Ctrl+Y…',
'Toggle auto-accepting edits approval mode with Shift+Tab…',
'Toggle Markdown rendering (raw markdown mode) with Option+M…',
'Cycle through approval modes (Default, Plan, Auto-Edit) with Shift+Tab…',
'Toggle Markdown rendering (raw markdown mode) with Alt+M…',
'Toggle shell mode by typing ! in an empty prompt…',
'Insert a newline with a backslash (\\) followed by Enter…',
'Navigate your prompt history with the Up and Down arrows…',
@@ -101,9 +101,9 @@ describe('KeypressContext', () => {
expect(keyHandler).toHaveBeenCalledWith(
expect.objectContaining({
name: 'return',
ctrl: false,
meta: false,
shift: false,
ctrl: false,
cmd: false,
}),
);
});
@@ -116,9 +116,9 @@ describe('KeypressContext', () => {
expect(keyHandler).toHaveBeenCalledWith(
expect.objectContaining({
name: 'return',
ctrl: false,
meta: false,
shift: true,
ctrl: false,
cmd: false,
}),
);
});
@@ -127,17 +127,17 @@ describe('KeypressContext', () => {
{
modifier: 'Shift',
sequence: '\x1b[57414;2u',
expected: { ctrl: false, meta: false, shift: true },
expected: { shift: true, ctrl: false, cmd: false },
},
{
modifier: 'Ctrl',
sequence: '\x1b[57414;5u',
expected: { ctrl: true, meta: false, shift: false },
expected: { shift: false, ctrl: true, cmd: false },
},
{
modifier: 'Alt',
sequence: '\x1b[57414;3u',
expected: { ctrl: false, meta: true, shift: false },
expected: { shift: false, alt: true, ctrl: false, cmd: false },
},
])(
'should handle numpad enter with $modifier modifier',
@@ -163,9 +163,9 @@ describe('KeypressContext', () => {
expect(keyHandler).toHaveBeenCalledWith(
expect.objectContaining({
name: 'j',
ctrl: true,
meta: false,
shift: false,
ctrl: true,
cmd: false,
}),
);
});
@@ -178,9 +178,10 @@ describe('KeypressContext', () => {
expect(keyHandler).toHaveBeenCalledWith(
expect.objectContaining({
name: 'return',
ctrl: false,
meta: true,
shift: false,
alt: true,
ctrl: false,
cmd: false,
}),
);
});
@@ -202,7 +203,13 @@ describe('KeypressContext', () => {
act(() => stdin.write('a'));
expect(keyHandler).toHaveBeenLastCalledWith(
expect.objectContaining({ name: 'a' }),
expect.objectContaining({
name: 'a',
shift: false,
alt: false,
ctrl: false,
cmd: false,
}),
);
act(() => stdin.write('\r'));
@@ -212,6 +219,10 @@ describe('KeypressContext', () => {
name: 'return',
sequence: '\r',
insertable: true,
shift: false,
alt: false,
ctrl: false,
cmd: false,
}),
);
});
@@ -228,6 +239,10 @@ describe('KeypressContext', () => {
expect(keyHandler).toHaveBeenLastCalledWith(
expect.objectContaining({
name: 'return',
shift: false,
alt: false,
ctrl: false,
cmd: false,
}),
);
});
@@ -245,6 +260,10 @@ describe('KeypressContext', () => {
expect(keyHandler).toHaveBeenCalledWith(
expect.objectContaining({
name: 'escape',
shift: false,
alt: false,
ctrl: false,
cmd: false,
}),
);
});
@@ -266,11 +285,21 @@ describe('KeypressContext', () => {
expect(keyHandler).toHaveBeenNthCalledWith(
1,
expect.objectContaining({ name: 'escape', meta: true }),
expect.objectContaining({
name: 'escape',
shift: false,
alt: true,
cmd: false,
}),
);
expect(keyHandler).toHaveBeenNthCalledWith(
2,
expect.objectContaining({ name: 'escape', meta: true }),
expect.objectContaining({
name: 'escape',
shift: false,
alt: true,
cmd: false,
}),
);
});
});
@@ -296,7 +325,9 @@ describe('KeypressContext', () => {
expect(keyHandler).toHaveBeenCalledWith(
expect.objectContaining({
name: 'escape',
meta: true,
shift: false,
alt: true,
cmd: false,
}),
);
});
@@ -318,17 +349,17 @@ describe('KeypressContext', () => {
{
name: 'Backspace',
inputSequence: '\x1b[127u',
expected: { name: 'backspace', meta: false },
expected: { name: 'backspace', alt: false, cmd: false },
},
{
name: 'Option+Backspace',
name: 'Alt+Backspace',
inputSequence: '\x1b[127;3u',
expected: { name: 'backspace', meta: true },
expected: { name: 'backspace', alt: true, cmd: false },
},
{
name: 'Ctrl+Backspace',
inputSequence: '\x1b[127;5u',
expected: { name: 'backspace', ctrl: true },
expected: { name: 'backspace', alt: false, ctrl: true, cmd: false },
},
{
name: 'Shift+Space',
@@ -612,14 +643,17 @@ describe('KeypressContext', () => {
{ sequence: `\x1b[27;5;9~`, expected: { name: 'tab', ctrl: true } },
{
sequence: `\x1b[27;6;9~`,
expected: { name: 'tab', ctrl: true, shift: true },
expected: { name: 'tab', shift: true, ctrl: true },
},
// XTerm Function Key
{ sequence: `\x1b[1;129A`, expected: { name: 'up' } },
{ sequence: `\x1b[1;2H`, expected: { name: 'home', shift: true } },
{ sequence: `\x1b[1;5F`, expected: { name: 'end', ctrl: true } },
{ sequence: `\x1b[1;1P`, expected: { name: 'f1' } },
{ sequence: `\x1b[1;3Q`, expected: { name: 'f2', meta: true } },
{
sequence: `\x1b[1;3Q`,
expected: { name: 'f2', alt: true, cmd: false },
},
// Tilde Function Keys
{ sequence: `\x1b[3~`, expected: { name: 'delete' } },
{ sequence: `\x1b[5~`, expected: { name: 'pageup' } },
@@ -637,33 +671,75 @@ describe('KeypressContext', () => {
// Legacy Arrows
{
sequence: `\x1b[A`,
expected: { name: 'up', ctrl: false, meta: false, shift: false },
expected: {
name: 'up',
shift: false,
alt: false,
ctrl: false,
cmd: false,
},
},
{
sequence: `\x1b[B`,
expected: { name: 'down', ctrl: false, meta: false, shift: false },
expected: {
name: 'down',
shift: false,
alt: false,
ctrl: false,
cmd: false,
},
},
{
sequence: `\x1b[C`,
expected: { name: 'right', ctrl: false, meta: false, shift: false },
expected: {
name: 'right',
shift: false,
alt: false,
ctrl: false,
cmd: false,
},
},
{
sequence: `\x1b[D`,
expected: { name: 'left', ctrl: false, meta: false, shift: false },
expected: {
name: 'left',
shift: false,
alt: false,
ctrl: false,
cmd: false,
},
},
// Legacy Home/End
{
sequence: `\x1b[H`,
expected: { name: 'home', ctrl: false, meta: false, shift: false },
expected: {
name: 'home',
shift: false,
alt: false,
ctrl: false,
cmd: false,
},
},
{
sequence: `\x1b[F`,
expected: { name: 'end', ctrl: false, meta: false, shift: false },
expected: {
name: 'end',
shift: false,
alt: false,
ctrl: false,
cmd: false,
},
},
{
sequence: `\x1b[5H`,
expected: { name: 'home', ctrl: true, meta: false, shift: false },
expected: {
name: 'home',
shift: false,
alt: false,
ctrl: true,
cmd: false,
},
},
])(
'should recognize sequence "$sequence" as $expected.name',
@@ -690,11 +766,23 @@ describe('KeypressContext', () => {
expect(keyHandler).toHaveBeenNthCalledWith(
1,
expect.objectContaining({ name: 'delete' }),
expect.objectContaining({
name: 'delete',
shift: false,
alt: false,
ctrl: false,
cmd: false,
}),
);
expect(keyHandler).toHaveBeenNthCalledWith(
2,
expect.objectContaining({ name: 'delete' }),
expect.objectContaining({
name: 'delete',
shift: false,
alt: false,
ctrl: false,
cmd: false,
}),
);
});
@@ -751,9 +839,10 @@ describe('KeypressContext', () => {
chunk: `\x1b[${keycode};3u`,
expected: {
name: key,
ctrl: false,
meta: true,
shift: false,
alt: true,
ctrl: false,
cmd: false,
},
};
} else if (terminal === 'MacTerminal') {
@@ -766,24 +855,26 @@ describe('KeypressContext', () => {
expected: {
sequence: `\x1b${key}`,
name: key,
ctrl: false,
meta: true,
shift: false,
alt: true,
ctrl: false,
cmd: false,
},
};
} else {
// iTerm2 and VSCode send accented characters (å, ø, µ)
// Note: µ (mu) is sent with meta:false on iTerm2/VSCode but
// gets converted to m with meta:true
// Note: µ (mu) is sent with alt:false on iTerm2/VSCode but
// gets converted to m with alt:true
return {
terminal,
key,
chunk: accentedChar,
expected: {
name: key,
ctrl: false,
meta: true, // Always expect meta:true after conversion
shift: false,
alt: true, // Always expect alt:true after conversion
ctrl: false,
cmd: false,
sequence: accentedChar,
},
};
@@ -825,7 +916,10 @@ describe('KeypressContext', () => {
expect(keyHandler).toHaveBeenCalledWith(
expect.objectContaining({
sequence: '\\',
meta: false,
shift: false,
alt: false,
ctrl: false,
cmd: false,
}),
);
});
@@ -858,6 +952,10 @@ describe('KeypressContext', () => {
expect.objectContaining({
name: 'undefined',
sequence: INCOMPLETE_KITTY_SEQUENCE,
shift: false,
alt: false,
ctrl: false,
cmd: false,
}),
);
});
@@ -876,6 +974,10 @@ describe('KeypressContext', () => {
expect(keyHandler).toHaveBeenCalledWith(
expect.objectContaining({
sequence: '\x1b[m',
shift: false,
alt: false,
ctrl: false,
cmd: false,
}),
);
});
@@ -1048,6 +1150,10 @@ describe('KeypressContext', () => {
expect.objectContaining({
name: 'a',
sequence: 'a',
shift: false,
alt: false,
ctrl: false,
cmd: false,
}),
);
});
@@ -1162,7 +1268,14 @@ describe('KeypressContext', () => {
});
expect(keyHandler).toHaveBeenCalledWith(
expect.objectContaining({ name: 'f12', sequence: '\u001b[24~' }),
expect.objectContaining({
name: 'f12',
sequence: '\u001b[24~',
shift: false,
alt: false,
ctrl: false,
cmd: false,
}),
);
});
});
@@ -251,9 +251,10 @@ function bufferPaste(keypressHandler: KeypressHandler): KeypressHandler {
if (buffer.length > 0) {
keypressHandler({
name: 'paste',
ctrl: false,
meta: false,
shift: false,
alt: false,
ctrl: false,
cmd: false,
insertable: true,
sequence: buffer,
});
@@ -300,9 +301,10 @@ function* emitKeys(
let escaped = false;
let name = undefined;
let ctrl = false;
let meta = false;
let shift = false;
let alt = false;
let ctrl = false;
let cmd = false;
let code = undefined;
let insertable = false;
@@ -353,9 +355,10 @@ function* emitKeys(
const decoded = Buffer.from(base64Data, 'base64').toString('utf-8');
keypressHandler({
name: 'paste',
ctrl: false,
meta: false,
shift: false,
alt: false,
ctrl: false,
cmd: false,
insertable: true,
sequence: decoded,
});
@@ -490,9 +493,10 @@ function* emitKeys(
}
// Parse the key modifier
ctrl = !!(modifier & 4);
meta = !!(modifier & 10); // use 10 to catch both alt (2) and meta (8).
shift = !!(modifier & 1);
alt = !!(modifier & 2);
ctrl = !!(modifier & 4);
cmd = !!(modifier & 8);
const keyInfo = KEY_INFO_MAP[code];
if (keyInfo) {
@@ -503,13 +507,16 @@ function* emitKeys(
if (keyInfo.ctrl) {
ctrl = true;
}
if (name === 'space' && !ctrl && !meta) {
if (name === 'space' && !ctrl && !cmd && !alt) {
sequence = ' ';
insertable = true;
}
} else {
name = 'undefined';
if ((ctrl || meta) && (code.endsWith('u') || code.endsWith('~'))) {
if (
(ctrl || cmd || alt) &&
(code.endsWith('u') || code.endsWith('~'))
) {
// CSI-u or tilde-coded functional keys: ESC [ <code> ; <mods> (u|~)
const codeNumber = parseInt(code.slice(1, -1), 10);
if (
@@ -523,26 +530,26 @@ function* emitKeys(
} else if (ch === '\r') {
// carriage return
name = 'return';
meta = escaped;
alt = escaped;
} else if (escaped && ch === '\n') {
// Alt+Enter (linefeed), should be consistent with carriage return
name = 'return';
meta = escaped;
alt = escaped;
} else if (ch === '\t') {
// tab
name = 'tab';
meta = escaped;
alt = escaped;
} else if (ch === '\b' || ch === '\x7f') {
// backspace or ctrl+h
name = 'backspace';
meta = escaped;
alt = escaped;
} else if (ch === ESC) {
// escape key
name = 'escape';
meta = escaped;
alt = escaped;
} else if (ch === ' ') {
name = 'space';
meta = escaped;
alt = escaped;
insertable = true;
} else if (!escaped && ch <= '\x1a') {
// ctrl+letter
@@ -552,29 +559,30 @@ function* emitKeys(
// Letter, number, shift+letter
name = ch.toLowerCase();
shift = /^[A-Z]$/.exec(ch) !== null;
meta = escaped;
alt = escaped;
insertable = true;
} else if (MAC_ALT_KEY_CHARACTER_MAP[ch] && process.platform === 'darwin') {
name = MAC_ALT_KEY_CHARACTER_MAP[ch];
meta = true;
alt = true;
} else if (sequence === `${ESC}${ESC}`) {
// Double escape
name = 'escape';
meta = true;
alt = true;
// Emit first escape key here, then continue processing
keypressHandler({
name: 'escape',
ctrl,
meta,
shift,
alt,
ctrl,
cmd,
insertable: false,
sequence: ESC,
});
} else if (escaped) {
// Escape sequence timeout
name = ch.length ? undefined : 'escape';
meta = true;
alt = true;
} else {
// Any other character is considered printable.
insertable = true;
@@ -586,9 +594,10 @@ function* emitKeys(
) {
keypressHandler({
name: name || '',
ctrl,
meta,
shift,
alt,
ctrl,
cmd,
insertable,
sequence,
});
@@ -599,9 +608,10 @@ function* emitKeys(
export interface Key {
name: string;
ctrl: boolean;
meta: boolean;
shift: boolean;
alt: boolean;
ctrl: boolean;
cmd: boolean; // Command/Windows/Super key
insertable: boolean;
sequence: string;
}
@@ -139,63 +139,63 @@ describe('MouseContext', () => {
sequence: '\x1b[<0;10;20M',
expected: {
name: 'left-press',
shift: false,
ctrl: false,
meta: false,
shift: false,
},
},
{
sequence: '\x1b[<0;10;20m',
expected: {
name: 'left-release',
shift: false,
ctrl: false,
meta: false,
shift: false,
},
},
{
sequence: '\x1b[<2;10;20M',
expected: {
name: 'right-press',
shift: false,
ctrl: false,
meta: false,
shift: false,
},
},
{
sequence: '\x1b[<1;10;20M',
expected: {
name: 'middle-press',
shift: false,
ctrl: false,
meta: false,
shift: false,
},
},
{
sequence: '\x1b[<64;10;20M',
expected: {
name: 'scroll-up',
shift: false,
ctrl: false,
meta: false,
shift: false,
},
},
{
sequence: '\x1b[<65;10;20M',
expected: {
name: 'scroll-down',
shift: false,
ctrl: false,
meta: false,
shift: false,
},
},
{
sequence: '\x1b[<32;10;20M',
expected: {
name: 'move',
shift: false,
ctrl: false,
meta: false,
shift: false,
},
},
{
@@ -208,7 +208,7 @@ describe('MouseContext', () => {
}, // Alt + left press
{
sequence: '\x1b[<20;10;20M',
expected: { name: 'left-press', ctrl: true, shift: true },
expected: { name: 'left-press', shift: true, ctrl: true },
}, // Ctrl + Shift + left press
{
sequence: '\x1b[<68;10;20M',
@@ -0,0 +1,180 @@
/**
* @license
* Copyright 2026 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import { act } from 'react';
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { renderHook } from '../../test-utils/render.js';
import { ToolActionsProvider, useToolActions } from './ToolActionsContext.js';
import {
type Config,
ToolConfirmationOutcome,
MessageBusType,
IdeClient,
type ToolCallConfirmationDetails,
} from '@google/gemini-cli-core';
import { ToolCallStatus, type IndividualToolCallDisplay } from '../types.js';
// Mock IdeClient
vi.mock('@google/gemini-cli-core', async (importOriginal) => {
const actual =
await importOriginal<typeof import('@google/gemini-cli-core')>();
return {
...actual,
IdeClient: {
getInstance: vi.fn(),
},
};
});
describe('ToolActionsContext', () => {
const mockMessageBus = {
publish: vi.fn(),
};
const mockConfig = {
getIdeMode: vi.fn().mockReturnValue(false),
getMessageBus: vi.fn().mockReturnValue(mockMessageBus),
} as unknown as Config;
const mockToolCalls: IndividualToolCallDisplay[] = [
{
callId: 'modern-call',
correlationId: 'corr-123',
name: 'test-tool',
description: 'desc',
status: ToolCallStatus.Confirming,
resultDisplay: undefined,
confirmationDetails: { type: 'info', title: 'title', prompt: 'prompt' },
},
{
callId: 'legacy-call',
name: 'legacy-tool',
description: 'desc',
status: ToolCallStatus.Confirming,
resultDisplay: undefined,
confirmationDetails: {
type: 'info',
title: 'legacy',
prompt: 'prompt',
onConfirm: vi.fn(),
} as ToolCallConfirmationDetails,
},
{
callId: 'edit-call',
name: 'edit-tool',
description: 'desc',
status: ToolCallStatus.Confirming,
resultDisplay: undefined,
confirmationDetails: {
type: 'edit',
title: 'edit',
fileName: 'f.txt',
filePath: '/f.txt',
fileDiff: 'diff',
originalContent: 'old',
newContent: 'new',
onConfirm: vi.fn(),
} as ToolCallConfirmationDetails,
},
];
beforeEach(() => {
vi.clearAllMocks();
});
const wrapper = ({ children }: { children: React.ReactNode }) => (
<ToolActionsProvider config={mockConfig} toolCalls={mockToolCalls}>
{children}
</ToolActionsProvider>
);
it('publishes to MessageBus for tools with correlationId (Modern Path)', async () => {
const { result } = renderHook(() => useToolActions(), { wrapper });
await result.current.confirm(
'modern-call',
ToolConfirmationOutcome.ProceedOnce,
);
expect(mockMessageBus.publish).toHaveBeenCalledWith({
type: MessageBusType.TOOL_CONFIRMATION_RESPONSE,
correlationId: 'corr-123',
confirmed: true,
requiresUserConfirmation: false,
outcome: ToolConfirmationOutcome.ProceedOnce,
payload: undefined,
});
});
it('calls onConfirm for legacy tools (Legacy Path)', async () => {
const { result } = renderHook(() => useToolActions(), { wrapper });
const legacyDetails = mockToolCalls[1]
.confirmationDetails as ToolCallConfirmationDetails;
await result.current.confirm(
'legacy-call',
ToolConfirmationOutcome.ProceedOnce,
);
if (legacyDetails && 'onConfirm' in legacyDetails) {
expect(legacyDetails.onConfirm).toHaveBeenCalledWith(
ToolConfirmationOutcome.ProceedOnce,
undefined,
);
} else {
throw new Error('Expected onConfirm to be present');
}
expect(mockMessageBus.publish).not.toHaveBeenCalled();
});
it('handles cancel by calling confirm with Cancel outcome', async () => {
const { result } = renderHook(() => useToolActions(), { wrapper });
await result.current.cancel('modern-call');
expect(mockMessageBus.publish).toHaveBeenCalledWith(
expect.objectContaining({
outcome: ToolConfirmationOutcome.Cancel,
confirmed: false,
}),
);
});
it('resolves IDE diffs for edit tools when in IDE mode', async () => {
const mockIdeClient = {
isDiffingEnabled: vi.fn().mockReturnValue(true),
resolveDiffFromCli: vi.fn(),
} as unknown as IdeClient;
vi.mocked(IdeClient.getInstance).mockResolvedValue(mockIdeClient);
vi.mocked(mockConfig.getIdeMode).mockReturnValue(true);
const { result } = renderHook(() => useToolActions(), { wrapper });
// Wait for IdeClient initialization in useEffect
await act(async () => {
await vi.waitFor(() => expect(IdeClient.getInstance).toHaveBeenCalled());
// Give React a chance to update state
await new Promise((resolve) => setTimeout(resolve, 0));
});
await result.current.confirm(
'edit-call',
ToolConfirmationOutcome.ProceedOnce,
);
expect(mockIdeClient.resolveDiffFromCli).toHaveBeenCalledWith(
'/f.txt',
'accepted',
);
const editDetails = mockToolCalls[2]
.confirmationDetails as ToolCallConfirmationDetails;
if (editDetails && 'onConfirm' in editDetails) {
expect(editDetails.onConfirm).toHaveBeenCalled();
} else {
throw new Error('Expected onConfirm to be present');
}
});
});
@@ -0,0 +1,142 @@
/**
* @license
* Copyright 2026 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import type React from 'react';
import {
createContext,
useContext,
useCallback,
useState,
useEffect,
} from 'react';
import {
IdeClient,
ToolConfirmationOutcome,
MessageBusType,
type Config,
type ToolConfirmationPayload,
type ToolCallConfirmationDetails,
debugLogger,
} from '@google/gemini-cli-core';
import type { IndividualToolCallDisplay } from '../types.js';
interface ToolActionsContextValue {
confirm: (
callId: string,
outcome: ToolConfirmationOutcome,
payload?: ToolConfirmationPayload,
) => Promise<void>;
cancel: (callId: string) => Promise<void>;
}
const ToolActionsContext = createContext<ToolActionsContextValue | null>(null);
export const useToolActions = () => {
const context = useContext(ToolActionsContext);
if (!context) {
throw new Error('useToolActions must be used within a ToolActionsProvider');
}
return context;
};
interface ToolActionsProviderProps {
children: React.ReactNode;
config: Config;
toolCalls: IndividualToolCallDisplay[];
}
export const ToolActionsProvider: React.FC<ToolActionsProviderProps> = (
props: ToolActionsProviderProps,
) => {
const { children, config, toolCalls } = props;
// Hoist IdeClient logic here to keep UI pure
const [ideClient, setIdeClient] = useState<IdeClient | null>(null);
useEffect(() => {
let isMounted = true;
if (config.getIdeMode()) {
IdeClient.getInstance()
.then((client) => {
if (isMounted) setIdeClient(client);
})
.catch((error) => {
debugLogger.error('Failed to get IdeClient instance:', error);
});
}
return () => {
isMounted = false;
};
}, [config]);
const confirm = useCallback(
async (
callId: string,
outcome: ToolConfirmationOutcome,
payload?: ToolConfirmationPayload,
) => {
const tool = toolCalls.find((t) => t.callId === callId);
if (!tool) {
debugLogger.warn(`ToolActions: Tool ${callId} not found`);
return;
}
const details = tool.confirmationDetails;
// 1. Handle Side Effects (IDE Diff)
if (
details?.type === 'edit' &&
ideClient?.isDiffingEnabled() &&
'filePath' in details // Check for safety
) {
const cliOutcome =
outcome === ToolConfirmationOutcome.Cancel ? 'rejected' : 'accepted';
await ideClient.resolveDiffFromCli(details.filePath, cliOutcome);
}
// 2. Dispatch
// PATH A: Event Bus (Modern)
if (tool.correlationId) {
await config.getMessageBus().publish({
type: MessageBusType.TOOL_CONFIRMATION_RESPONSE,
correlationId: tool.correlationId,
confirmed: outcome !== ToolConfirmationOutcome.Cancel,
requiresUserConfirmation: false,
outcome,
payload,
});
return;
}
// PATH B: Legacy Callback (Adapter or Old Scheduler)
if (
details &&
'onConfirm' in details &&
typeof details.onConfirm === 'function'
) {
await (details as ToolCallConfirmationDetails).onConfirm(
outcome,
payload,
);
return;
}
debugLogger.warn(`ToolActions: No confirmation mechanism for ${callId}`);
},
[config, toolCalls, ideClient],
);
const cancel = useCallback(
async (callId: string) => {
await confirm(callId, ToolConfirmationOutcome.Cancel);
},
[confirm],
);
return (
<ToolActionsContext.Provider value={{ confirm, cancel }}>
{children}
</ToolActionsContext.Provider>
);
};
@@ -106,7 +106,7 @@ export interface UIState {
activeHooks: ActiveHook[];
messageQueue: string[];
queueErrorMessage: string | null;
showAutoAcceptIndicator: ApprovalMode;
showApprovalModeIndicator: ApprovalMode;
// Quota-related state
userTier: UserTierId | undefined;
proQuotaRequest: ProQuotaDialogRequest | null;
+1 -1
View File
@@ -69,7 +69,7 @@ export function keyToAnsi(key: Key): string | null {
}
// If it's a simple character, return it.
if (!key.ctrl && !key.meta && key.sequence) {
if (!key.ctrl && !key.cmd && key.sequence) {
return key.sequence;
}
@@ -195,6 +195,33 @@ describe('toolMapping', () => {
expect(displayTool.confirmationDetails).toEqual(confirmationDetails);
});
it('maps correlationId and serializable confirmation details', () => {
const serializableDetails = {
type: 'edit' as const,
title: 'Confirm Edit',
fileName: 'file.txt',
filePath: '/path/file.txt',
fileDiff: 'diff',
originalContent: 'old',
newContent: 'new',
};
const toolCall: WaitingToolCall = {
status: 'awaiting_approval',
request: mockRequest,
tool: mockTool,
invocation: mockInvocation,
confirmationDetails: serializableDetails,
correlationId: 'corr-123',
};
const result = mapToDisplay(toolCall);
const displayTool = result.tools[0];
expect(displayTool.correlationId).toBe('corr-123');
expect(displayTool.confirmationDetails).toEqual(serializableDetails);
});
it('maps error tool call missing tool definition', () => {
// e.g. "TOOL_NOT_REGISTERED" errors
const toolCall: ToolCall = {
+10 -12
View File
@@ -8,6 +8,7 @@ import {
type ToolCall,
type Status as CoreStatus,
type ToolCallConfirmationDetails,
type SerializableConfirmationDetails,
type ToolResultDisplay,
debugLogger,
} from '@google/gemini-cli-core';
@@ -72,10 +73,13 @@ export function mapToDisplay(
};
let resultDisplay: ToolResultDisplay | undefined = undefined;
let confirmationDetails: ToolCallConfirmationDetails | undefined =
undefined;
let confirmationDetails:
| ToolCallConfirmationDetails
| SerializableConfirmationDetails
| undefined = undefined;
let outputFile: string | undefined = undefined;
let ptyId: number | undefined = undefined;
let correlationId: string | undefined = undefined;
switch (call.status) {
case 'success':
@@ -87,16 +91,9 @@ export function mapToDisplay(
resultDisplay = call.response.resultDisplay;
break;
case 'awaiting_approval':
// Only map if it's the legacy callback-based details.
// Serializable details will be handled in a later milestone.
if (
call.confirmationDetails &&
'onConfirm' in call.confirmationDetails &&
typeof call.confirmationDetails.onConfirm === 'function'
) {
confirmationDetails =
call.confirmationDetails as ToolCallConfirmationDetails;
}
correlationId = call.correlationId;
// Pass through details. Context handles dispatch (callback vs bus).
confirmationDetails = call.confirmationDetails;
break;
case 'executing':
resultDisplay = call.liveOutput;
@@ -123,6 +120,7 @@ export function mapToDisplay(
confirmationDetails,
outputFile,
ptyId,
correlationId,
};
});
@@ -15,7 +15,7 @@ import {
} from 'vitest';
import { act } from 'react';
import { renderHook } from '../../test-utils/render.js';
import { useAutoAcceptIndicator } from './useAutoAcceptIndicator.js';
import { useApprovalModeIndicator } from './useApprovalModeIndicator.js';
import { Config, ApprovalMode } from '@google/gemini-cli-core';
import type { Config as ActualConfigType } from '@google/gemini-cli-core';
@@ -37,6 +37,7 @@ interface MockConfigInstanceShape {
getApprovalMode: Mock<() => ApprovalMode>;
setApprovalMode: Mock<(value: ApprovalMode) => void>;
isYoloModeDisabled: Mock<() => boolean>;
isPlanEnabled: Mock<() => boolean>;
isTrustedFolder: Mock<() => boolean>;
getCoreTools: Mock<() => string[]>;
getToolDiscoveryCommand: Mock<() => string | undefined>;
@@ -55,7 +56,7 @@ interface MockConfigInstanceShape {
type UseKeypressHandler = (key: Key) => void;
describe('useAutoAcceptIndicator', () => {
describe('useApprovalModeIndicator', () => {
let mockConfigInstance: MockConfigInstanceShape;
let capturedUseKeypressHandler: UseKeypressHandler;
let mockedUseKeypress: MockedFunction<typeof useKeypress>;
@@ -66,7 +67,9 @@ describe('useAutoAcceptIndicator', () => {
(
Config as unknown as MockedFunction<() => MockConfigInstanceShape>
).mockImplementation(() => {
const instanceGetApprovalModeMock = vi.fn();
const instanceGetApprovalModeMock = vi
.fn()
.mockReturnValue(ApprovalMode.DEFAULT);
const instanceSetApprovalModeMock = vi.fn();
const instance: MockConfigInstanceShape = {
@@ -77,6 +80,7 @@ describe('useAutoAcceptIndicator', () => {
(value: ApprovalMode) => void
>,
isYoloModeDisabled: vi.fn().mockReturnValue(false),
isPlanEnabled: vi.fn().mockReturnValue(false),
isTrustedFolder: vi.fn().mockReturnValue(true) as Mock<() => boolean>,
getCoreTools: vi.fn().mockReturnValue([]) as Mock<() => string[]>,
getToolDiscoveryCommand: vi.fn().mockReturnValue(undefined) as Mock<
@@ -126,7 +130,7 @@ describe('useAutoAcceptIndicator', () => {
it('should initialize with ApprovalMode.AUTO_EDIT if config.getApprovalMode returns ApprovalMode.AUTO_EDIT', () => {
mockConfigInstance.getApprovalMode.mockReturnValue(ApprovalMode.AUTO_EDIT);
const { result } = renderHook(() =>
useAutoAcceptIndicator({
useApprovalModeIndicator({
config: mockConfigInstance as unknown as ActualConfigType,
addItem: vi.fn(),
}),
@@ -138,7 +142,7 @@ describe('useAutoAcceptIndicator', () => {
it('should initialize with ApprovalMode.DEFAULT if config.getApprovalMode returns ApprovalMode.DEFAULT', () => {
mockConfigInstance.getApprovalMode.mockReturnValue(ApprovalMode.DEFAULT);
const { result } = renderHook(() =>
useAutoAcceptIndicator({
useApprovalModeIndicator({
config: mockConfigInstance as unknown as ActualConfigType,
addItem: vi.fn(),
}),
@@ -150,7 +154,7 @@ describe('useAutoAcceptIndicator', () => {
it('should initialize with ApprovalMode.YOLO if config.getApprovalMode returns ApprovalMode.YOLO', () => {
mockConfigInstance.getApprovalMode.mockReturnValue(ApprovalMode.YOLO);
const { result } = renderHook(() =>
useAutoAcceptIndicator({
useApprovalModeIndicator({
config: mockConfigInstance as unknown as ActualConfigType,
addItem: vi.fn(),
}),
@@ -159,16 +163,17 @@ describe('useAutoAcceptIndicator', () => {
expect(mockConfigInstance.getApprovalMode).toHaveBeenCalledTimes(1);
});
it('should toggle the indicator and update config when Shift+Tab or Ctrl+Y is pressed', () => {
it('should cycle the indicator and update config when Shift+Tab or Ctrl+Y is pressed', () => {
mockConfigInstance.getApprovalMode.mockReturnValue(ApprovalMode.DEFAULT);
const { result } = renderHook(() =>
useAutoAcceptIndicator({
useApprovalModeIndicator({
config: mockConfigInstance as unknown as ActualConfigType,
addItem: vi.fn(),
}),
);
expect(result.current).toBe(ApprovalMode.DEFAULT);
// Shift+Tab cycles to AUTO_EDIT
act(() => {
capturedUseKeypressHandler({
name: 'tab',
@@ -188,22 +193,7 @@ describe('useAutoAcceptIndicator', () => {
);
expect(result.current).toBe(ApprovalMode.YOLO);
act(() => {
capturedUseKeypressHandler({ name: 'y', ctrl: true } as Key);
});
expect(mockConfigInstance.setApprovalMode).toHaveBeenCalledWith(
ApprovalMode.DEFAULT,
);
expect(result.current).toBe(ApprovalMode.DEFAULT);
act(() => {
capturedUseKeypressHandler({ name: 'y', ctrl: true } as Key);
});
expect(mockConfigInstance.setApprovalMode).toHaveBeenCalledWith(
ApprovalMode.YOLO,
);
expect(result.current).toBe(ApprovalMode.YOLO);
// Shift+Tab cycles back to DEFAULT (since PLAN is disabled by default in mock)
act(() => {
capturedUseKeypressHandler({
name: 'tab',
@@ -215,22 +205,67 @@ describe('useAutoAcceptIndicator', () => {
);
expect(result.current).toBe(ApprovalMode.AUTO_EDIT);
// Ctrl+Y toggles YOLO
act(() => {
capturedUseKeypressHandler({ name: 'y', ctrl: true } as Key);
});
expect(mockConfigInstance.setApprovalMode).toHaveBeenCalledWith(
ApprovalMode.YOLO,
);
expect(result.current).toBe(ApprovalMode.YOLO);
// Shift+Tab from YOLO jumps to AUTO_EDIT
act(() => {
capturedUseKeypressHandler({
name: 'tab',
shift: true,
} as Key);
});
expect(mockConfigInstance.setApprovalMode).toHaveBeenCalledWith(
ApprovalMode.AUTO_EDIT,
);
expect(result.current).toBe(ApprovalMode.AUTO_EDIT);
});
it('should cycle through DEFAULT -> AUTO_EDIT -> PLAN -> DEFAULT when plan is enabled', () => {
mockConfigInstance.getApprovalMode.mockReturnValue(ApprovalMode.DEFAULT);
mockConfigInstance.isPlanEnabled.mockReturnValue(true);
renderHook(() =>
useApprovalModeIndicator({
config: mockConfigInstance as unknown as ActualConfigType,
addItem: vi.fn(),
}),
);
// DEFAULT -> PLAN
act(() => {
capturedUseKeypressHandler({ name: 'tab', shift: true } as Key);
});
expect(mockConfigInstance.setApprovalMode).toHaveBeenCalledWith(
ApprovalMode.PLAN,
);
// PLAN -> AUTO_EDIT
act(() => {
capturedUseKeypressHandler({ name: 'tab', shift: true } as Key);
});
expect(mockConfigInstance.setApprovalMode).toHaveBeenCalledWith(
ApprovalMode.AUTO_EDIT,
);
// AUTO_EDIT -> DEFAULT
act(() => {
capturedUseKeypressHandler({ name: 'tab', shift: true } as Key);
});
expect(mockConfigInstance.setApprovalMode).toHaveBeenCalledWith(
ApprovalMode.DEFAULT,
);
expect(result.current).toBe(ApprovalMode.DEFAULT);
});
it('should not toggle if only one key or other keys combinations are pressed', () => {
mockConfigInstance.getApprovalMode.mockReturnValue(ApprovalMode.DEFAULT);
renderHook(() =>
useAutoAcceptIndicator({
useApprovalModeIndicator({
config: mockConfigInstance as unknown as ActualConfigType,
addItem: vi.fn(),
}),
@@ -279,8 +314,8 @@ describe('useAutoAcceptIndicator', () => {
act(() => {
capturedUseKeypressHandler({
name: 'a',
ctrl: true,
shift: true,
ctrl: true,
} as Key);
});
expect(mockConfigInstance.setApprovalMode).not.toHaveBeenCalled();
@@ -290,7 +325,7 @@ describe('useAutoAcceptIndicator', () => {
mockConfigInstance.getApprovalMode.mockReturnValue(ApprovalMode.DEFAULT);
const { result, rerender } = renderHook(
(props: { config: ActualConfigType; addItem: () => void }) =>
useAutoAcceptIndicator(props),
useApprovalModeIndicator(props),
{
initialProps: {
config: mockConfigInstance as unknown as ActualConfigType,
@@ -324,7 +359,7 @@ describe('useAutoAcceptIndicator', () => {
});
const mockAddItem = vi.fn();
const { result } = renderHook(() =>
useAutoAcceptIndicator({
useApprovalModeIndicator({
config: mockConfigInstance as unknown as ActualConfigType,
addItem: mockAddItem,
}),
@@ -354,7 +389,7 @@ describe('useAutoAcceptIndicator', () => {
});
const mockAddItem = vi.fn();
const { result } = renderHook(() =>
useAutoAcceptIndicator({
useApprovalModeIndicator({
config: mockConfigInstance as unknown as ActualConfigType,
addItem: mockAddItem,
}),
@@ -382,7 +417,7 @@ describe('useAutoAcceptIndicator', () => {
mockConfigInstance.getApprovalMode.mockReturnValue(ApprovalMode.YOLO);
const mockAddItem = vi.fn();
renderHook(() =>
useAutoAcceptIndicator({
useApprovalModeIndicator({
config: mockConfigInstance as unknown as ActualConfigType,
addItem: mockAddItem,
}),
@@ -404,7 +439,7 @@ describe('useAutoAcceptIndicator', () => {
);
const mockAddItem = vi.fn();
renderHook(() =>
useAutoAcceptIndicator({
useApprovalModeIndicator({
config: mockConfigInstance as unknown as ActualConfigType,
addItem: mockAddItem,
}),
@@ -433,7 +468,7 @@ describe('useAutoAcceptIndicator', () => {
const mockAddItem = vi.fn();
renderHook(() =>
useAutoAcceptIndicator({
useApprovalModeIndicator({
config: mockConfigInstance as unknown as ActualConfigType,
addItem: mockAddItem,
}),
@@ -484,7 +519,7 @@ describe('useAutoAcceptIndicator', () => {
mockConfigInstance.getApprovalMode.mockReturnValue(ApprovalMode.DEFAULT);
const mockAddItem = vi.fn();
const { result } = renderHook(() =>
useAutoAcceptIndicator({
useApprovalModeIndicator({
config: mockConfigInstance as unknown as ActualConfigType,
addItem: mockAddItem,
}),
@@ -517,7 +552,7 @@ describe('useAutoAcceptIndicator', () => {
const mockOnApprovalModeChange = vi.fn();
renderHook(() =>
useAutoAcceptIndicator({
useApprovalModeIndicator({
config: mockConfigInstance as unknown as ActualConfigType,
onApprovalModeChange: mockOnApprovalModeChange,
}),
@@ -539,7 +574,7 @@ describe('useAutoAcceptIndicator', () => {
const mockOnApprovalModeChange = vi.fn();
renderHook(() =>
useAutoAcceptIndicator({
useApprovalModeIndicator({
config: mockConfigInstance as unknown as ActualConfigType,
onApprovalModeChange: mockOnApprovalModeChange,
}),
@@ -563,7 +598,7 @@ describe('useAutoAcceptIndicator', () => {
const mockOnApprovalModeChange = vi.fn();
renderHook(() =>
useAutoAcceptIndicator({
useApprovalModeIndicator({
config: mockConfigInstance as unknown as ActualConfigType,
onApprovalModeChange: mockOnApprovalModeChange,
}),
@@ -583,7 +618,7 @@ describe('useAutoAcceptIndicator', () => {
mockConfigInstance.getApprovalMode.mockReturnValue(ApprovalMode.DEFAULT);
renderHook(() =>
useAutoAcceptIndicator({
useApprovalModeIndicator({
config: mockConfigInstance as unknown as ActualConfigType,
}),
);
@@ -604,7 +639,7 @@ describe('useAutoAcceptIndicator', () => {
const mockOnApprovalModeChange = vi.fn();
renderHook(() =>
useAutoAcceptIndicator({
useApprovalModeIndicator({
config: mockConfigInstance as unknown as ActualConfigType,
onApprovalModeChange: mockOnApprovalModeChange,
}),
@@ -11,25 +11,24 @@ import { keyMatchers, Command } from '../keyMatchers.js';
import type { HistoryItemWithoutId } from '../types.js';
import { MessageType } from '../types.js';
export interface UseAutoAcceptIndicatorArgs {
export interface UseApprovalModeIndicatorArgs {
config: Config;
addItem?: (item: HistoryItemWithoutId, timestamp: number) => void;
onApprovalModeChange?: (mode: ApprovalMode) => void;
isActive?: boolean;
}
export function useAutoAcceptIndicator({
export function useApprovalModeIndicator({
config,
addItem,
onApprovalModeChange,
isActive = true,
}: UseAutoAcceptIndicatorArgs): ApprovalMode {
}: UseApprovalModeIndicatorArgs): ApprovalMode {
const currentConfigValue = config.getApprovalMode();
const [showAutoAcceptIndicator, setShowAutoAcceptIndicator] =
useState(currentConfigValue);
const [showApprovalMode, setApprovalMode] = useState(currentConfigValue);
useEffect(() => {
setShowAutoAcceptIndicator(currentConfigValue);
setApprovalMode(currentConfigValue);
}, [currentConfigValue]);
useKeypress(
@@ -56,18 +55,32 @@ export function useAutoAcceptIndicator({
config.getApprovalMode() === ApprovalMode.YOLO
? ApprovalMode.DEFAULT
: ApprovalMode.YOLO;
} else if (keyMatchers[Command.TOGGLE_AUTO_EDIT](key)) {
nextApprovalMode =
config.getApprovalMode() === ApprovalMode.AUTO_EDIT
? ApprovalMode.DEFAULT
: ApprovalMode.AUTO_EDIT;
} else if (keyMatchers[Command.CYCLE_APPROVAL_MODE](key)) {
const currentMode = config.getApprovalMode();
switch (currentMode) {
case ApprovalMode.DEFAULT:
nextApprovalMode = config.isPlanEnabled()
? ApprovalMode.PLAN
: ApprovalMode.AUTO_EDIT;
break;
case ApprovalMode.PLAN:
nextApprovalMode = ApprovalMode.AUTO_EDIT;
break;
case ApprovalMode.AUTO_EDIT:
nextApprovalMode = ApprovalMode.DEFAULT;
break;
case ApprovalMode.YOLO:
nextApprovalMode = ApprovalMode.AUTO_EDIT;
break;
default:
}
}
if (nextApprovalMode) {
try {
config.setApprovalMode(nextApprovalMode);
// Update local state immediately for responsiveness
setShowAutoAcceptIndicator(nextApprovalMode);
setApprovalMode(nextApprovalMode);
// Notify the central handler about the approval mode change
onApprovalModeChange?.(nextApprovalMode);
@@ -87,5 +100,5 @@ export function useAutoAcceptIndicator({
{ isActive },
);
return showAutoAcceptIndicator;
return showApprovalMode;
}
@@ -19,8 +19,8 @@ import type {
TrackedExecutingToolCall,
TrackedCancelledToolCall,
TrackedWaitingToolCall,
} from './useReactToolScheduler.js';
import { useReactToolScheduler } from './useReactToolScheduler.js';
} from './useToolScheduler.js';
import { useToolScheduler } from './useToolScheduler.js';
import type {
Config,
EditorType,
@@ -87,12 +87,12 @@ vi.mock('@google/gemini-cli-core', async (importOriginal) => {
};
});
const mockUseReactToolScheduler = useReactToolScheduler as Mock;
vi.mock('./useReactToolScheduler.js', async (importOriginal) => {
const mockUseToolScheduler = useToolScheduler as Mock;
vi.mock('./useToolScheduler.js', async (importOriginal) => {
const actualSchedulerModule = (await importOriginal()) as any;
return {
...(actualSchedulerModule || {}),
useReactToolScheduler: vi.fn(),
useToolScheduler: vi.fn(),
};
});
@@ -243,7 +243,7 @@ describe('useGeminiStream', () => {
mockMarkToolsAsSubmitted = vi.fn();
// Default mock for useReactToolScheduler to prevent toolCalls being undefined initially
mockUseReactToolScheduler.mockReturnValue([
mockUseToolScheduler.mockReturnValue([
[], // Default to empty array for toolCalls
mockScheduleToolCalls,
mockMarkToolsAsSubmitted,
@@ -334,7 +334,7 @@ describe('useGeminiStream', () => {
rerender({ ...props, toolCalls: newToolCalls });
});
mockUseReactToolScheduler.mockImplementation(() => [
mockUseToolScheduler.mockImplementation(() => [
props.toolCalls,
mockScheduleToolCalls,
mockMarkToolsAsSubmitted,
@@ -579,7 +579,7 @@ describe('useGeminiStream', () => {
| ((completedTools: TrackedToolCall[]) => Promise<void>)
| null = null;
mockUseReactToolScheduler.mockImplementation((onComplete) => {
mockUseToolScheduler.mockImplementation((onComplete) => {
capturedOnComplete = onComplete;
return [[], mockScheduleToolCalls, mockMarkToolsAsSubmitted, vi.fn()];
});
@@ -661,7 +661,7 @@ describe('useGeminiStream', () => {
| ((completedTools: TrackedToolCall[]) => Promise<void>)
| null = null;
mockUseReactToolScheduler.mockImplementation((onComplete) => {
mockUseToolScheduler.mockImplementation((onComplete) => {
capturedOnComplete = onComplete;
return [[], mockScheduleToolCalls, mockMarkToolsAsSubmitted, vi.fn()];
});
@@ -740,7 +740,7 @@ describe('useGeminiStream', () => {
| ((completedTools: TrackedToolCall[]) => Promise<void>)
| null = null;
mockUseReactToolScheduler.mockImplementation((onComplete) => {
mockUseToolScheduler.mockImplementation((onComplete) => {
capturedOnComplete = onComplete;
return [
[],
@@ -864,7 +864,7 @@ describe('useGeminiStream', () => {
| ((completedTools: TrackedToolCall[]) => Promise<void>)
| null = null;
mockUseReactToolScheduler.mockImplementation((onComplete) => {
mockUseToolScheduler.mockImplementation((onComplete) => {
capturedOnComplete = onComplete;
return [[], mockScheduleToolCalls, mockMarkToolsAsSubmitted, vi.fn()];
});
@@ -972,7 +972,7 @@ describe('useGeminiStream', () => {
| null = null;
let currentToolCalls = initialToolCalls;
mockUseReactToolScheduler.mockImplementation((onComplete) => {
mockUseToolScheduler.mockImplementation((onComplete) => {
capturedOnComplete = onComplete;
return [
currentToolCalls,
@@ -1009,7 +1009,7 @@ describe('useGeminiStream', () => {
// 2. Update the tool calls to completed state and rerender
currentToolCalls = completedToolCalls;
mockUseReactToolScheduler.mockImplementation((onComplete) => {
mockUseToolScheduler.mockImplementation((onComplete) => {
capturedOnComplete = onComplete;
return [
completedToolCalls,
@@ -1616,7 +1616,7 @@ describe('useGeminiStream', () => {
| ((completedTools: TrackedToolCall[]) => Promise<void>)
| null = null;
mockUseReactToolScheduler.mockImplementation((onComplete) => {
mockUseToolScheduler.mockImplementation((onComplete) => {
capturedOnComplete = onComplete;
return [[], mockScheduleToolCalls, mockMarkToolsAsSubmitted, vi.fn()];
});
@@ -2306,9 +2306,8 @@ describe('useGeminiStream', () => {
addItemOrder.push(`addItem:${item.type}`);
});
// We need to capture the onComplete callback from useReactToolScheduler
const mockUseReactToolScheduler = useReactToolScheduler as Mock;
mockUseReactToolScheduler.mockImplementation((onComplete) => {
// We need to capture the onComplete callback from useToolScheduler
mockUseToolScheduler.mockImplementation((onComplete) => {
capturedOnComplete = onComplete;
return [
[], // toolCalls
@@ -2529,7 +2528,7 @@ describe('useGeminiStream', () => {
});
it('should memoize pendingHistoryItems', () => {
mockUseReactToolScheduler.mockReturnValue([
mockUseToolScheduler.mockReturnValue([
[],
mockScheduleToolCalls,
mockCancelAllToolCalls,
@@ -2580,7 +2579,7 @@ describe('useGeminiStream', () => {
} as unknown as TrackedExecutingToolCall,
];
mockUseReactToolScheduler.mockReturnValue([
mockUseToolScheduler.mockReturnValue([
newToolCalls,
mockScheduleToolCalls,
mockCancelAllToolCalls,
+3 -3
View File
@@ -66,12 +66,12 @@ import { useLogger } from './useLogger.js';
import { SHELL_COMMAND_NAME } from '../constants.js';
import { mapToDisplay as mapTrackedToolCallsToDisplay } from './toolMapping.js';
import {
useReactToolScheduler,
useToolScheduler,
type TrackedToolCall,
type TrackedCompletedToolCall,
type TrackedCancelledToolCall,
type TrackedWaitingToolCall,
} from './useReactToolScheduler.js';
} from './useToolScheduler.js';
import { promises as fs } from 'node:fs';
import path from 'node:path';
import { useSessionStats } from '../contexts/SessionContext.js';
@@ -159,7 +159,7 @@ export const useGeminiStream = (
setToolCallsForDisplay,
cancelAllToolCalls,
lastToolOutputTime,
] = useReactToolScheduler(
] = useToolScheduler(
async (completedToolCallsFromScheduler) => {
// This onComplete is called when ALL scheduled tools for a given batch are done.
if (completedToolCallsFromScheduler.length > 0) {
@@ -16,10 +16,26 @@ import type { LoadedTrustedFolders } from '../../config/trustedFolders.js';
import type { MultiFolderTrustDialogProps } from '../components/MultiFolderTrustDialog.js';
vi.mock('../utils/directoryUtils.js', () => ({
expandHomeDir: (p: string) => p, // Simple pass-through for testing
loadMemoryFromDirectories: vi.fn().mockResolvedValue({ fileCount: 1 }),
}));
vi.mock('../utils/directoryUtils.js', async (importOriginal) => {
const actual =
await importOriginal<typeof import('../utils/directoryUtils.js')>();
return {
...actual,
expandHomeDir: (p: string) => p, // Simple pass-through for testing
batchAddDirectories: (
workspaceContext: WorkspaceContext,
paths: string[],
) => {
const result = workspaceContext.addDirectories(paths);
const errors: string[] = [];
for (const failure of result.failed) {
errors.push(`Error adding '${failure.path}': ${failure.error.message}`);
}
return { added: result.added, errors };
},
loadMemoryFromDirectories: vi.fn().mockResolvedValue({ fileCount: 1 }),
};
});
vi.mock('../components/MultiFolderTrustDialog.js', () => ({
MultiFolderTrustDialog: (props: MultiFolderTrustDialogProps) => (
@@ -38,6 +54,7 @@ describe('useIncludeDirsTrust', () => {
mockWorkspaceContext = {
addDirectory: vi.fn(),
addDirectories: vi.fn().mockReturnValue({ added: [], failed: [] }),
getDirectories: vi.fn().mockReturnValue([]),
onDirectoriesChangedListeners: new Set(),
onDirectoriesChanged: vi.fn(),
@@ -111,23 +128,18 @@ describe('useIncludeDirsTrust', () => {
'/dir1',
'/dir2',
]);
vi.mocked(mockWorkspaceContext.addDirectory).mockImplementation(
(path) => {
if (path === '/dir2') {
throw new Error('Test error');
}
},
);
vi.mocked(mockWorkspaceContext.addDirectories).mockReturnValue({
added: ['/dir1'],
failed: [{ path: '/dir2', error: new Error('Test error') }],
});
renderTestHook(isTrusted);
await waitFor(() => {
expect(mockWorkspaceContext.addDirectory).toHaveBeenCalledWith(
expect(mockWorkspaceContext.addDirectories).toHaveBeenCalledWith([
'/dir1',
);
expect(mockWorkspaceContext.addDirectory).toHaveBeenCalledWith(
'/dir2',
);
]);
expect(mockHistoryManager.addItem).toHaveBeenCalledWith(
expect.objectContaining({
text: expect.stringContaining("Error adding '/dir2': Test error"),
@@ -171,6 +183,11 @@ describe('useIncludeDirsTrust', () => {
return undefined;
});
vi.mocked(mockWorkspaceContext.addDirectories).mockReturnValue({
added: ['/trusted'],
failed: [],
});
renderTestHook(true);
// Opens dialog for undefined trust dir
@@ -193,15 +210,16 @@ describe('useIncludeDirsTrust', () => {
pendingDirs,
);
mockIsPathTrusted.mockReturnValue(true);
vi.mocked(mockWorkspaceContext.addDirectories).mockReturnValue({
added: pendingDirs,
failed: [],
});
renderTestHook(true);
await waitFor(() => {
expect(mockWorkspaceContext.addDirectory).toHaveBeenCalledWith(
'/trusted1',
);
expect(mockWorkspaceContext.addDirectory).toHaveBeenCalledWith(
'/trusted2',
expect(mockWorkspaceContext.addDirectories).toHaveBeenCalledWith(
pendingDirs,
);
expect(mockSetCustomDialog).not.toHaveBeenCalled();
expect(mockConfig.clearPendingIncludeDirectories).toHaveBeenCalledTimes(
@@ -5,9 +5,9 @@
*/
import { useEffect } from 'react';
import type { Config } from '@google/gemini-cli-core';
import { type Config } from '@google/gemini-cli-core';
import { loadTrustedFolders } from '../../config/trustedFolders.js';
import { expandHomeDir } from '../utils/directoryUtils.js';
import { expandHomeDir, batchAddDirectories } from '../utils/directoryUtils.js';
import {
debugLogger,
refreshServerHierarchicalMemory,
@@ -79,15 +79,10 @@ export function useIncludeDirsTrust(
const added: string[] = [];
const errors: string[] = [];
const workspaceContext = config.getWorkspaceContext();
for (const pathToAdd of pendingDirs) {
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, pendingDirs);
added.push(...result.added);
errors.push(...result.errors);
if (added.length > 0 || errors.length > 0) {
// eslint-disable-next-line @typescript-eslint/no-floating-promises
@@ -125,14 +120,10 @@ export function useIncludeDirsTrust(
}
const workspaceContext = config.getWorkspaceContext();
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) {
+10 -3
View File
@@ -114,7 +114,13 @@ describe(`useKeypress`, () => {
const key = { name: 'return', sequence: '\x1B\r' };
act(() => stdin.write(key.sequence));
expect(onKeypress).toHaveBeenCalledWith(
expect.objectContaining({ ...key, meta: true }),
expect.objectContaining({
...key,
shift: false,
alt: true,
ctrl: false,
cmd: false,
}),
);
});
@@ -140,9 +146,10 @@ describe(`useKeypress`, () => {
expect(onKeypress).toHaveBeenCalledTimes(1);
expect(onKeypress).toHaveBeenCalledWith({
name: 'paste',
ctrl: false,
meta: false,
shift: false,
alt: false,
ctrl: false,
cmd: false,
insertable: true,
sequence: pasteText,
});
@@ -229,7 +229,7 @@ describe('useLoadingIndicator', () => {
);
expect(result.current.currentLoadingPhrase).toBe(
'Trying to reach gemini-pro (Attempt 2/3)',
'Trying to reach gemini-pro (Retry 2/2)',
);
});
});
@@ -61,7 +61,7 @@ export const useLoadingIndicator = (
}, [streamingState, elapsedTimeFromTimer]);
const retryPhrase = retryStatus
? `Trying to reach ${getDisplayString(retryStatus.model)} (Attempt ${retryStatus.attempt}/${retryStatus.maxAttempts})`
? `Trying to reach ${getDisplayString(retryStatus.model)} (Retry ${retryStatus.attempt}/${retryStatus.maxAttempts - 1})`
: null;
return {
@@ -59,7 +59,8 @@ describe('useSelectionList', () => {
name,
sequence,
ctrl: options.ctrl ?? false,
meta: false,
cmd: false,
alt: false,
shift: options.shift ?? false,
insertable: false,
};
@@ -328,7 +329,8 @@ describe('useSelectionList', () => {
name,
sequence: name,
ctrl: false,
meta: false,
cmd: false,
alt: false,
shift: false,
insertable: true,
};
@@ -377,7 +379,8 @@ describe('useSelectionList', () => {
name,
sequence: name,
ctrl: false,
meta: false,
cmd: false,
alt: false,
shift: false,
insertable: false,
};
@@ -316,6 +316,7 @@ function useCommandSuggestions(
value: cmd.name,
description: cmd.description,
commandKind: cmd.kind,
extensionName: cmd.extensionName,
}));
setSuggestions(finalSuggestions);
@@ -0,0 +1,415 @@
/**
* @license
* Copyright 2026 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { act } from 'react';
import { renderHook } from '../../test-utils/render.js';
import { useToolExecutionScheduler } from './useToolExecutionScheduler.js';
import {
MessageBusType,
ToolConfirmationOutcome,
Scheduler,
type Config,
type MessageBus,
type CompletedToolCall,
type ToolCallConfirmationDetails,
type ToolCallsUpdateMessage,
type AnyDeclarativeTool,
type AnyToolInvocation,
} from '@google/gemini-cli-core';
import { createMockMessageBus } from '@google/gemini-cli-core/src/test-utils/mock-message-bus.js';
// Mock Core Scheduler
vi.mock('@google/gemini-cli-core', async (importOriginal) => {
const actual =
await importOriginal<typeof import('@google/gemini-cli-core')>();
return {
...actual,
Scheduler: vi.fn().mockImplementation(() => ({
schedule: vi.fn().mockResolvedValue([]),
cancelAll: vi.fn(),
})),
};
});
const createMockTool = (
overrides: Partial<AnyDeclarativeTool> = {},
): AnyDeclarativeTool =>
({
name: 'test_tool',
displayName: 'Test Tool',
description: 'A test tool',
kind: 'function',
parameterSchema: {},
isOutputMarkdown: false,
build: vi.fn(),
...overrides,
}) as AnyDeclarativeTool;
const createMockInvocation = (
overrides: Partial<AnyToolInvocation> = {},
): AnyToolInvocation =>
({
getDescription: () => 'Executing test tool',
shouldConfirmExecute: vi.fn(),
execute: vi.fn(),
params: {},
toolLocations: [],
...overrides,
}) as AnyToolInvocation;
describe('useToolExecutionScheduler', () => {
let mockConfig: Config;
let mockMessageBus: MessageBus;
beforeEach(() => {
vi.clearAllMocks();
mockMessageBus = createMockMessageBus() as unknown as MessageBus;
mockConfig = {
getMessageBus: () => mockMessageBus,
} as unknown as Config;
});
it('initializes with empty tool calls', () => {
const { result } = renderHook(() =>
useToolExecutionScheduler(
vi.fn().mockResolvedValue(undefined),
mockConfig,
() => undefined,
),
);
const [toolCalls] = result.current;
expect(toolCalls).toEqual([]);
});
it('updates tool calls when MessageBus emits TOOL_CALLS_UPDATE', () => {
const { result } = renderHook(() =>
useToolExecutionScheduler(
vi.fn().mockResolvedValue(undefined),
mockConfig,
() => undefined,
),
);
const mockToolCall = {
status: 'executing' as const,
request: {
callId: 'call-1',
name: 'test_tool',
args: {},
isClientInitiated: false,
prompt_id: 'p1',
},
tool: createMockTool(),
invocation: createMockInvocation(),
liveOutput: 'Loading...',
};
act(() => {
void mockMessageBus.publish({
type: MessageBusType.TOOL_CALLS_UPDATE,
toolCalls: [mockToolCall],
} as ToolCallsUpdateMessage);
});
const [toolCalls] = result.current;
expect(toolCalls).toHaveLength(1);
// Expect Core Object structure, not Display Object
expect(toolCalls[0]).toMatchObject({
request: { callId: 'call-1', name: 'test_tool' },
status: 'executing', // Core status
liveOutput: 'Loading...',
responseSubmittedToGemini: false,
});
});
it('injects onConfirm callback for awaiting_approval tools (Adapter Pattern)', async () => {
const { result } = renderHook(() =>
useToolExecutionScheduler(
vi.fn().mockResolvedValue(undefined),
mockConfig,
() => undefined,
),
);
const mockToolCall = {
status: 'awaiting_approval' as const,
request: {
callId: 'call-1',
name: 'test_tool',
args: {},
isClientInitiated: false,
prompt_id: 'p1',
},
tool: createMockTool(),
invocation: createMockInvocation({
getDescription: () => 'Confirming test tool',
}),
confirmationDetails: { type: 'info', title: 'Confirm', prompt: 'Sure?' },
correlationId: 'corr-123',
};
act(() => {
void mockMessageBus.publish({
type: MessageBusType.TOOL_CALLS_UPDATE,
toolCalls: [mockToolCall],
} as ToolCallsUpdateMessage);
});
const [toolCalls] = result.current;
const call = toolCalls[0];
if (call.status !== 'awaiting_approval') {
throw new Error('Expected status to be awaiting_approval');
}
const confirmationDetails =
call.confirmationDetails as ToolCallConfirmationDetails;
expect(confirmationDetails).toBeDefined();
expect(typeof confirmationDetails.onConfirm).toBe('function');
// Test that onConfirm publishes to MessageBus
const publishSpy = vi.spyOn(mockMessageBus, 'publish');
await confirmationDetails.onConfirm(ToolConfirmationOutcome.ProceedOnce);
expect(publishSpy).toHaveBeenCalledWith({
type: MessageBusType.TOOL_CONFIRMATION_RESPONSE,
correlationId: 'corr-123',
confirmed: true,
requiresUserConfirmation: false,
outcome: ToolConfirmationOutcome.ProceedOnce,
payload: undefined,
});
});
it('injects onConfirm with payload (Inline Edit support)', async () => {
const { result } = renderHook(() =>
useToolExecutionScheduler(
vi.fn().mockResolvedValue(undefined),
mockConfig,
() => undefined,
),
);
const mockToolCall = {
status: 'awaiting_approval' as const,
request: {
callId: 'call-1',
name: 'test_tool',
args: {},
isClientInitiated: false,
prompt_id: 'p1',
},
tool: createMockTool(),
invocation: createMockInvocation(),
confirmationDetails: { type: 'edit', title: 'Edit', filePath: 'test.ts' },
correlationId: 'corr-edit',
};
act(() => {
void mockMessageBus.publish({
type: MessageBusType.TOOL_CALLS_UPDATE,
toolCalls: [mockToolCall],
} as ToolCallsUpdateMessage);
});
const [toolCalls] = result.current;
const call = toolCalls[0];
if (call.status !== 'awaiting_approval') {
throw new Error('Expected awaiting_approval');
}
const confirmationDetails =
call.confirmationDetails as ToolCallConfirmationDetails;
const publishSpy = vi.spyOn(mockMessageBus, 'publish');
const mockPayload = { newContent: 'updated code' };
await confirmationDetails.onConfirm(
ToolConfirmationOutcome.ProceedOnce,
mockPayload,
);
expect(publishSpy).toHaveBeenCalledWith({
type: MessageBusType.TOOL_CONFIRMATION_RESPONSE,
correlationId: 'corr-edit',
confirmed: true,
requiresUserConfirmation: false,
outcome: ToolConfirmationOutcome.ProceedOnce,
payload: mockPayload,
});
});
it('preserves responseSubmittedToGemini flag across updates', () => {
const { result } = renderHook(() =>
useToolExecutionScheduler(
vi.fn().mockResolvedValue(undefined),
mockConfig,
() => undefined,
),
);
const mockToolCall = {
status: 'success' as const,
request: {
callId: 'call-1',
name: 'test',
args: {},
isClientInitiated: false,
prompt_id: 'p1',
},
tool: createMockTool(),
invocation: createMockInvocation(),
response: {
callId: 'call-1',
resultDisplay: 'OK',
responseParts: [],
error: undefined,
errorType: undefined,
},
};
// 1. Initial success
act(() => {
void mockMessageBus.publish({
type: MessageBusType.TOOL_CALLS_UPDATE,
toolCalls: [mockToolCall],
} as ToolCallsUpdateMessage);
});
// 2. Mark as submitted
act(() => {
const [, , markAsSubmitted] = result.current;
markAsSubmitted(['call-1']);
});
expect(result.current[0][0].responseSubmittedToGemini).toBe(true);
// 3. Receive another update (should preserve the true flag)
act(() => {
void mockMessageBus.publish({
type: MessageBusType.TOOL_CALLS_UPDATE,
toolCalls: [mockToolCall],
} as ToolCallsUpdateMessage);
});
expect(result.current[0][0].responseSubmittedToGemini).toBe(true);
});
it('updates lastToolOutputTime when tools are executing', () => {
vi.useFakeTimers();
const { result } = renderHook(() =>
useToolExecutionScheduler(
vi.fn().mockResolvedValue(undefined),
mockConfig,
() => undefined,
),
);
const startTime = Date.now();
vi.advanceTimersByTime(1000);
act(() => {
void mockMessageBus.publish({
type: MessageBusType.TOOL_CALLS_UPDATE,
toolCalls: [
{
status: 'executing' as const,
request: {
callId: 'call-1',
name: 'test',
args: {},
isClientInitiated: false,
prompt_id: 'p1',
},
tool: createMockTool(),
invocation: createMockInvocation(),
},
],
} as ToolCallsUpdateMessage);
});
const [, , , , , lastOutputTime] = result.current;
expect(lastOutputTime).toBeGreaterThan(startTime);
vi.useRealTimers();
});
it('delegates cancelAll to the Core Scheduler', () => {
const { result } = renderHook(() =>
useToolExecutionScheduler(
vi.fn().mockResolvedValue(undefined),
mockConfig,
() => undefined,
),
);
const [, , , , cancelAll] = result.current;
const signal = new AbortController().signal;
// We need to find the mock instance of Scheduler
// Since we used vi.mock at top level, we can get it from vi.mocked(Scheduler)
const schedulerInstance = vi.mocked(Scheduler).mock.results[0].value;
cancelAll(signal);
expect(schedulerInstance.cancelAll).toHaveBeenCalled();
});
it('resolves the schedule promise when scheduler resolves', async () => {
const onComplete = vi.fn().mockResolvedValue(undefined);
const completedToolCall = {
status: 'success' as const,
request: {
callId: 'call-1',
name: 'test',
args: {},
isClientInitiated: false,
prompt_id: 'p1',
},
tool: createMockTool(),
invocation: createMockInvocation(),
response: {
callId: 'call-1',
responseParts: [],
resultDisplay: 'Success',
error: undefined,
errorType: undefined,
},
};
// Mock the specific return value for this test
const { Scheduler } = await import('@google/gemini-cli-core');
vi.mocked(Scheduler).mockImplementation(
() =>
({
schedule: vi.fn().mockResolvedValue([completedToolCall]),
cancelAll: vi.fn(),
}) as unknown as Scheduler,
);
const { result } = renderHook(() =>
useToolExecutionScheduler(onComplete, mockConfig, () => undefined),
);
const [, schedule] = result.current;
const signal = new AbortController().signal;
let completedResult: CompletedToolCall[] = [];
await act(async () => {
completedResult = await schedule(
{
callId: 'call-1',
name: 'test',
args: {},
isClientInitiated: false,
prompt_id: 'p1',
},
signal,
);
});
expect(completedResult).toEqual([completedToolCall]);
expect(onComplete).toHaveBeenCalledWith([completedToolCall]);
});
});
@@ -0,0 +1,202 @@
/**
* @license
* Copyright 2026 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import {
type Config,
type MessageBus,
type ToolCallRequestInfo,
type ToolCall,
type CompletedToolCall,
type ToolConfirmationPayload,
MessageBusType,
ToolConfirmationOutcome,
Scheduler,
type EditorType,
type ToolCallsUpdateMessage,
} from '@google/gemini-cli-core';
import { useCallback, useState, useMemo, useEffect, useRef } from 'react';
// Re-exporting types compatible with legacy hook expectations
export type ScheduleFn = (
request: ToolCallRequestInfo | ToolCallRequestInfo[],
signal: AbortSignal,
) => Promise<CompletedToolCall[]>;
export type MarkToolsAsSubmittedFn = (callIds: string[]) => void;
export type CancelAllFn = (signal: AbortSignal) => void;
/**
* The shape expected by useGeminiStream.
* It matches the Core ToolCall structure + the UI metadata flag.
*/
export type TrackedToolCall = ToolCall & {
responseSubmittedToGemini?: boolean;
};
/**
* Modern tool scheduler hook using the event-driven Core Scheduler.
*
* This hook acts as an Adapter between the new MessageBus-driven Core
* and the legacy callback-based UI components.
*/
export function useToolExecutionScheduler(
onComplete: (tools: CompletedToolCall[]) => Promise<void>,
config: Config,
getPreferredEditor: () => EditorType | undefined,
): [
TrackedToolCall[],
ScheduleFn,
MarkToolsAsSubmittedFn,
React.Dispatch<React.SetStateAction<TrackedToolCall[]>>,
CancelAllFn,
number,
] {
// State stores Core objects, not Display objects
const [toolCalls, setToolCalls] = useState<TrackedToolCall[]>([]);
const [lastToolOutputTime, setLastToolOutputTime] = useState<number>(0);
const messageBus = useMemo(() => config.getMessageBus(), [config]);
const onCompleteRef = useRef(onComplete);
useEffect(() => {
onCompleteRef.current = onComplete;
}, [onComplete]);
const getPreferredEditorRef = useRef(getPreferredEditor);
useEffect(() => {
getPreferredEditorRef.current = getPreferredEditor;
}, [getPreferredEditor]);
const scheduler = useMemo(
() =>
new Scheduler({
config,
messageBus,
getPreferredEditor: () => getPreferredEditorRef.current(),
}),
[config, messageBus],
);
const internalAdaptToolCalls = useCallback(
(coreCalls: ToolCall[], prevTracked: TrackedToolCall[]) =>
adaptToolCalls(coreCalls, prevTracked, messageBus),
[messageBus],
);
useEffect(() => {
const handler = (event: ToolCallsUpdateMessage) => {
setToolCalls((prev) => {
const adapted = internalAdaptToolCalls(event.toolCalls, prev);
// Update output timer for UI spinners
if (event.toolCalls.some((tc) => tc.status === 'executing')) {
setLastToolOutputTime(Date.now());
}
return adapted;
});
};
messageBus.subscribe(MessageBusType.TOOL_CALLS_UPDATE, handler);
return () => {
messageBus.unsubscribe(MessageBusType.TOOL_CALLS_UPDATE, handler);
};
}, [messageBus, internalAdaptToolCalls]);
const schedule: ScheduleFn = useCallback(
async (request, signal) => {
// Clear state for new run
setToolCalls([]);
// 1. Await Core Scheduler directly
const results = await scheduler.schedule(request, signal);
// 2. Trigger legacy reinjection logic (useGeminiStream loop)
await onCompleteRef.current(results);
return results;
},
[scheduler],
);
const cancelAll: CancelAllFn = useCallback(
(_signal) => {
scheduler.cancelAll();
},
[scheduler],
);
const markToolsAsSubmitted: MarkToolsAsSubmittedFn = useCallback(
(callIdsToMark: string[]) => {
setToolCalls((prevCalls) =>
prevCalls.map((tc) =>
callIdsToMark.includes(tc.request.callId)
? { ...tc, responseSubmittedToGemini: true }
: tc,
),
);
},
[],
);
return [
toolCalls,
schedule,
markToolsAsSubmitted,
setToolCalls,
cancelAll,
lastToolOutputTime,
];
}
/**
* ADAPTER: Merges UI metadata (submitted flag) and injects legacy callbacks.
*/
function adaptToolCalls(
coreCalls: ToolCall[],
prevTracked: TrackedToolCall[],
messageBus: MessageBus,
): TrackedToolCall[] {
const prevMap = new Map(prevTracked.map((t) => [t.request.callId, t]));
return coreCalls.map((coreCall): TrackedToolCall => {
const prev = prevMap.get(coreCall.request.callId);
const responseSubmittedToGemini = prev?.responseSubmittedToGemini ?? false;
// Inject onConfirm adapter for tools awaiting approval.
// The Core provides data-only (serializable) confirmationDetails. We must
// inject the legacy callback function that proxies responses back to the
// MessageBus.
if (coreCall.status === 'awaiting_approval' && coreCall.correlationId) {
const correlationId = coreCall.correlationId;
return {
...coreCall,
confirmationDetails: {
...coreCall.confirmationDetails,
onConfirm: async (
outcome: ToolConfirmationOutcome,
payload?: ToolConfirmationPayload,
) => {
await messageBus.publish({
type: MessageBusType.TOOL_CONFIRMATION_RESPONSE,
correlationId,
confirmed: outcome !== ToolConfirmationOutcome.Cancel,
requiresUserConfirmation: false,
outcome,
payload,
});
},
},
responseSubmittedToGemini,
};
}
return {
...coreCall,
responseSubmittedToGemini,
};
});
}
@@ -0,0 +1,86 @@
/**
* @license
* Copyright 2026 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import type {
Config,
EditorType,
CompletedToolCall,
ToolCallRequestInfo,
} from '@google/gemini-cli-core';
import {
useReactToolScheduler,
type TrackedToolCall as LegacyTrackedToolCall,
type TrackedScheduledToolCall,
type TrackedValidatingToolCall,
type TrackedWaitingToolCall,
type TrackedExecutingToolCall,
type TrackedCompletedToolCall,
type TrackedCancelledToolCall,
type MarkToolsAsSubmittedFn,
type CancelAllFn,
} from './useReactToolScheduler.js';
import {
useToolExecutionScheduler,
type TrackedToolCall as NewTrackedToolCall,
} from './useToolExecutionScheduler.js';
// Re-export specific state types from Legacy, as the structures are compatible
// and useGeminiStream relies on them for narrowing.
export type {
TrackedScheduledToolCall,
TrackedValidatingToolCall,
TrackedWaitingToolCall,
TrackedExecutingToolCall,
TrackedCompletedToolCall,
TrackedCancelledToolCall,
};
// Unified type that covers both implementations
export type TrackedToolCall = LegacyTrackedToolCall | NewTrackedToolCall;
// Unified Schedule function (Promise<void> | Promise<CompletedToolCall[]>)
export type ScheduleFn = (
request: ToolCallRequestInfo | ToolCallRequestInfo[],
signal: AbortSignal,
) => Promise<void | CompletedToolCall[]>;
export type UseToolSchedulerReturn = [
TrackedToolCall[],
ScheduleFn,
MarkToolsAsSubmittedFn,
React.Dispatch<React.SetStateAction<TrackedToolCall[]>>,
CancelAllFn,
number,
];
/**
* Facade hook that switches between the Legacy and Event-Driven schedulers
* based on configuration.
*
* Note: This conditionally calls hooks, which technically violates the standard
* Rules of Hooks linting. However, this is safe here because
* `config.isEventDrivenSchedulerEnabled()` is static for the lifetime of the
* application session (it essentially acts as a compile-time feature flag).
*/
export function useToolScheduler(
onComplete: (tools: CompletedToolCall[]) => Promise<void>,
config: Config,
getPreferredEditor: () => EditorType | undefined,
): UseToolSchedulerReturn {
const isEventDriven = config.isEventDrivenSchedulerEnabled();
// Note: We return the hooks directly without casting. They return compatible
// tuple structures, but use explicit tuple signatures rather than the
// UseToolSchedulerReturn named type to avoid circular dependencies back to
// this facade.
if (isEventDriven) {
// eslint-disable-next-line react-hooks/rules-of-hooks
return useToolExecutionScheduler(onComplete, config, getPreferredEditor);
}
// eslint-disable-next-line react-hooks/rules-of-hooks
return useReactToolScheduler(onComplete, config, getPreferredEditor);
}
@@ -0,0 +1,70 @@
/**
* @license
* Copyright 2026 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { renderHook } from '../../test-utils/render.js';
import { useToolScheduler } from './useToolScheduler.js';
import { useReactToolScheduler } from './useReactToolScheduler.js';
import { useToolExecutionScheduler } from './useToolExecutionScheduler.js';
import type { Config } from '@google/gemini-cli-core';
vi.mock('./useReactToolScheduler.js', () => ({
useReactToolScheduler: vi.fn().mockReturnValue(['legacy']),
}));
vi.mock('./useToolExecutionScheduler.js', () => ({
useToolExecutionScheduler: vi.fn().mockReturnValue(['modern']),
}));
describe('useToolScheduler (Facade)', () => {
let mockConfig: Config;
beforeEach(() => {
vi.clearAllMocks();
});
it('delegates to useReactToolScheduler when event-driven scheduler is disabled', () => {
mockConfig = {
isEventDrivenSchedulerEnabled: () => false,
} as unknown as Config;
const onComplete = vi.fn();
const getPreferredEditor = vi.fn();
const { result } = renderHook(() =>
useToolScheduler(onComplete, mockConfig, getPreferredEditor),
);
expect(result.current).toEqual(['legacy']);
expect(useReactToolScheduler).toHaveBeenCalledWith(
onComplete,
mockConfig,
getPreferredEditor,
);
expect(useToolExecutionScheduler).not.toHaveBeenCalled();
});
it('delegates to useToolExecutionScheduler when event-driven scheduler is enabled', () => {
mockConfig = {
isEventDrivenSchedulerEnabled: () => true,
} as unknown as Config;
const onComplete = vi.fn();
const getPreferredEditor = vi.fn();
const { result } = renderHook(() =>
useToolScheduler(onComplete, mockConfig, getPreferredEditor),
);
expect(result.current).toEqual(['modern']);
expect(useToolExecutionScheduler).toHaveBeenCalledWith(
onComplete,
mockConfig,
getPreferredEditor,
);
expect(useReactToolScheduler).not.toHaveBeenCalled();
});
});
+3 -2
View File
@@ -36,9 +36,10 @@ vi.mock('../contexts/VimModeContext.js', () => ({
const createKey = (partial: Partial<Key>): Key => ({
name: partial.name || '',
sequence: partial.sequence || '',
ctrl: partial.ctrl || false,
meta: partial.meta || false,
shift: partial.shift || false,
alt: partial.alt || false,
ctrl: partial.ctrl || false,
cmd: partial.cmd || false,
insertable: partial.insertable || false,
...partial,
});
+5 -3
View File
@@ -280,8 +280,9 @@ export function useVim(buffer: TextBuffer, onSubmit?: (value: string) => void) {
// Special handling for Enter key to allow command submission (lower priority than completion)
if (
normalizedKey.name === 'return' &&
!normalizedKey.alt &&
!normalizedKey.ctrl &&
!normalizedKey.meta
!normalizedKey.cmd
) {
if (buffer.text.trim() && onSubmit) {
// Handle command submission directly
@@ -309,9 +310,10 @@ export function useVim(buffer: TextBuffer, onSubmit?: (value: string) => void) {
(key: Key): Key => ({
name: key.name || '',
sequence: key.sequence || '',
ctrl: key.ctrl || false,
meta: key.meta || false,
shift: key.shift || false,
alt: key.alt || false,
ctrl: key.ctrl || false,
cmd: key.cmd || false,
insertable: key.insertable || false,
}),
[],
+23 -20
View File
@@ -13,9 +13,10 @@ import type { Key } from './hooks/useKeypress.js';
describe('keyMatchers', () => {
const createKey = (name: string, mods: Partial<Key> = {}): Key => ({
name,
ctrl: false,
meta: false,
shift: false,
alt: false,
ctrl: false,
cmd: false,
insertable: false,
sequence: name,
...mods,
@@ -70,8 +71,8 @@ describe('keyMatchers', () => {
command: Command.MOVE_WORD_LEFT,
positive: [
createKey('left', { ctrl: true }),
createKey('left', { meta: true }),
createKey('b', { meta: true }),
createKey('left', { alt: true }),
createKey('b', { alt: true }),
],
negative: [createKey('left'), createKey('b', { ctrl: true })],
},
@@ -79,8 +80,8 @@ describe('keyMatchers', () => {
command: Command.MOVE_WORD_RIGHT,
positive: [
createKey('right', { ctrl: true }),
createKey('right', { meta: true }),
createKey('f', { meta: true }),
createKey('right', { alt: true }),
createKey('f', { alt: true }),
],
negative: [createKey('right'), createKey('f', { ctrl: true })],
},
@@ -115,7 +116,7 @@ describe('keyMatchers', () => {
command: Command.DELETE_WORD_BACKWARD,
positive: [
createKey('backspace', { ctrl: true }),
createKey('backspace', { meta: true }),
createKey('backspace', { alt: true }),
createKey('w', { ctrl: true }),
],
negative: [createKey('backspace'), createKey('delete', { ctrl: true })],
@@ -124,19 +125,19 @@ describe('keyMatchers', () => {
command: Command.DELETE_WORD_FORWARD,
positive: [
createKey('delete', { ctrl: true }),
createKey('delete', { meta: true }),
createKey('delete', { alt: true }),
],
negative: [createKey('delete'), createKey('backspace', { ctrl: true })],
},
{
command: Command.UNDO,
positive: [createKey('z', { ctrl: true, shift: false })],
negative: [createKey('z'), createKey('z', { ctrl: true, shift: true })],
positive: [createKey('z', { shift: false, ctrl: true })],
negative: [createKey('z'), createKey('z', { shift: true, ctrl: true })],
},
{
command: Command.REDO,
positive: [createKey('z', { ctrl: true, shift: true })],
negative: [createKey('z'), createKey('z', { ctrl: true, shift: false })],
positive: [createKey('z', { shift: true, ctrl: true })],
negative: [createKey('z'), createKey('z', { shift: false, ctrl: true })],
},
// Screen control
@@ -243,14 +244,16 @@ describe('keyMatchers', () => {
positive: [createKey('return')],
negative: [
createKey('return', { ctrl: true }),
createKey('return', { meta: true }),
createKey('return', { cmd: true }),
createKey('return', { alt: true }),
],
},
{
command: Command.NEWLINE,
positive: [
createKey('return', { ctrl: true }),
createKey('return', { meta: true }),
createKey('return', { cmd: true }),
createKey('return', { alt: true }),
],
negative: [createKey('return'), createKey('n')],
},
@@ -285,13 +288,13 @@ describe('keyMatchers', () => {
},
{
command: Command.TOGGLE_MARKDOWN,
positive: [createKey('m', { meta: true })],
positive: [createKey('m', { alt: true })],
negative: [createKey('m'), createKey('m', { shift: true })],
},
{
command: Command.TOGGLE_COPY_MODE,
positive: [createKey('s', { ctrl: true })],
negative: [createKey('s'), createKey('s', { meta: true })],
negative: [createKey('s'), createKey('s', { alt: true })],
},
{
command: Command.QUIT,
@@ -333,10 +336,10 @@ describe('keyMatchers', () => {
{
command: Command.TOGGLE_YOLO,
positive: [createKey('y', { ctrl: true })],
negative: [createKey('y'), createKey('y', { meta: true })],
negative: [createKey('y'), createKey('y', { alt: true })],
},
{
command: Command.TOGGLE_AUTO_EDIT,
command: Command.CYCLE_APPROVAL_MODE,
positive: [createKey('tab', { shift: true })],
negative: [createKey('tab')],
},
@@ -401,13 +404,13 @@ describe('keyMatchers', () => {
...defaultKeyBindings,
[Command.QUIT]: [
{ key: 'q', ctrl: true },
{ key: 'q', command: true },
{ key: 'q', alt: true },
],
};
const matchers = createKeyMatchers(config);
expect(matchers[Command.QUIT](createKey('q', { ctrl: true }))).toBe(true);
expect(matchers[Command.QUIT](createKey('q', { meta: true }))).toBe(true);
expect(matchers[Command.QUIT](createKey('q', { alt: true }))).toBe(true);
});
});
+7 -18
View File
@@ -13,28 +13,17 @@ import { Command, defaultKeyBindings } from '../config/keyBindings.js';
* Pure data-driven matching logic
*/
function matchKeyBinding(keyBinding: KeyBinding, key: Key): boolean {
if (keyBinding.key !== key.name) {
return false;
}
// Check modifiers - follow original logic:
// undefined = ignore this modifier (original behavior)
// true = modifier must be pressed
// false = modifier must NOT be pressed
if (keyBinding.ctrl !== undefined && key.ctrl !== keyBinding.ctrl) {
return false;
}
if (keyBinding.shift !== undefined && key.shift !== keyBinding.shift) {
return false;
}
if (keyBinding.command !== undefined && key.meta !== keyBinding.command) {
return false;
}
return true;
return (
keyBinding.key === key.name &&
(keyBinding.shift === undefined || key.shift === keyBinding.shift) &&
(keyBinding.alt === undefined || key.alt === keyBinding.alt) &&
(keyBinding.ctrl === undefined || key.ctrl === keyBinding.ctrl) &&
(keyBinding.cmd === undefined || key.cmd === keyBinding.cmd)
);
}
/**
+11 -2
View File
@@ -10,6 +10,7 @@ import type {
MCPServerConfig,
ThoughtSummary,
ToolCallConfirmationDetails,
SerializableConfirmationDetails,
ToolResultDisplay,
RetrieveUserQuotaResponse,
SkillDefinition,
@@ -63,7 +64,11 @@ export interface ToolCallEvent {
name: string;
args: Record<string, never>;
resultDisplay: ToolResultDisplay | undefined;
confirmationDetails: ToolCallConfirmationDetails | undefined;
confirmationDetails:
| ToolCallConfirmationDetails
| SerializableConfirmationDetails
| undefined;
correlationId?: string;
}
export interface IndividualToolCallDisplay {
@@ -72,10 +77,14 @@ export interface IndividualToolCallDisplay {
description: string;
resultDisplay: ToolResultDisplay | undefined;
status: ToolCallStatus;
confirmationDetails: ToolCallConfirmationDetails | undefined;
confirmationDetails:
| ToolCallConfirmationDetails
| SerializableConfirmationDetails
| undefined;
renderOutputAsMarkdown?: boolean;
ptyId?: number;
outputFile?: string;
correlationId?: string;
}
export interface CompressionProps {

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