mirror of
https://github.com/google-gemini/gemini-cli.git
synced 2026-07-23 16:20:57 -07:00
Compare commits
2 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 0904c3767e | |||
| f18e45d34d |
@@ -1,7 +0,0 @@
|
||||
{
|
||||
"experimental": {
|
||||
"toolOutputMasking": {
|
||||
"enabled": true
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,125 +0,0 @@
|
||||
---
|
||||
name: docs-changelog
|
||||
description: Provides a step-by-step procedure for generating Gemini CLI changelog files based on github release information.
|
||||
---
|
||||
|
||||
# Procedure: Updating Changelog for New Releases
|
||||
|
||||
The following instructions are run by Gemini CLI when processing new releases.
|
||||
|
||||
## Objective
|
||||
|
||||
To standardize the process of updating the Gemini CLI changelog files for a new
|
||||
release, ensuring accuracy, consistency, and adherence to project style
|
||||
guidelines.
|
||||
|
||||
## Release Types
|
||||
|
||||
This skill covers two types of releases:
|
||||
|
||||
* **Standard Releases:** Regular, versioned releases that are announced to all
|
||||
users. These updates modify `docs/changelogs/latest.md` and
|
||||
`docs/changelogs/index.md`.
|
||||
* **Preview Releases:** Pre-release versions for testing and feedback. These
|
||||
updates only modify `docs/changelogs/preview.md`.
|
||||
|
||||
Ignore all other releases, such as nightly releases.
|
||||
|
||||
### Expected Inputs
|
||||
|
||||
Regardless of the type of release, the following information is expected:
|
||||
|
||||
* **New version number:** The version number for the new release
|
||||
(e.g., `v0.27.0`).
|
||||
* **Release date:** The date of the new release (e.g., `2026-02-03`).
|
||||
* **Raw changelog data:** A list of all pull requests and changes
|
||||
included in the release, in the format `description by @author in
|
||||
#pr_number`.
|
||||
* **Previous version number:** The version number of the last release can be
|
||||
calculated by decreasing the minor version number by one and setting the
|
||||
patch or bug fix version number.
|
||||
|
||||
## Procedure
|
||||
|
||||
### Initial Setup
|
||||
|
||||
1. Identify the files to be modified:
|
||||
|
||||
For standard releases, update `docs/changelogs/latest.md` and
|
||||
`docs/changelogs/index.md`. For preview releases, update
|
||||
`docs/changelogs/preview.md`.
|
||||
|
||||
2. Activate the `docs-writer` skill.
|
||||
|
||||
### Analyze Raw Changelog Data
|
||||
|
||||
1. Review the complete list of changes. If it is a patch or a bug fix with few
|
||||
changes, skip to the "Update `docs/changelogs/latest.md` or
|
||||
`docs/changelogs/preview.md`" section.
|
||||
|
||||
2. Group related changes into high-level categories such as
|
||||
important features, "UI/UX Improvements", and "Bug Fixes". Use the existing
|
||||
announcements in `docs/changelogs/index.md` as an example.
|
||||
|
||||
### Create Highlight Summaries
|
||||
|
||||
Create two distinct versions of the release highlights.
|
||||
|
||||
**Important:** Carefully inspect highlights for "experimental" or
|
||||
"preview" features before public announcement, and do not include them.
|
||||
|
||||
#### Version 1: Comprehensive Highlights (for `latest.md` or `preview.md`)
|
||||
|
||||
Write a detailed summary for each category focusing on user-facing
|
||||
impact.
|
||||
|
||||
#### Version 2: Concise Highlights (for `index.md`)
|
||||
|
||||
Skip this step for preview releases.
|
||||
|
||||
Write concise summaries including the primary PR and author
|
||||
(e.g., `([#12345](link) by @author)`).
|
||||
|
||||
### Update `docs/changelogs/latest.md` or `docs/changelogs/preview.md`
|
||||
|
||||
1. Read current content and use `write_file` to replace it with the new
|
||||
version number, and date.
|
||||
|
||||
If it is a patch or bug fix with few changes, simply add these
|
||||
changes to the "What's Changed" list. Otherwise, replace comprehensive
|
||||
highlights, and the full "What's Changed" list.
|
||||
|
||||
2. For each item in the "What's Changed" list, keep usernames in plaintext, and
|
||||
add github links for each issue number. Example:
|
||||
|
||||
"- feat: implement /rewind command by @username in
|
||||
[#12345](https://github.com/google-gemini/gemini-cli/pull/12345)"
|
||||
|
||||
3. Skip entries by @gemini-cli-robot.
|
||||
|
||||
4. Do not add the "New Contributors" section.
|
||||
|
||||
5. Update the "Full changelog:" link by doing one of following:
|
||||
|
||||
If it is a patch or bug fix with few changes, retain the original link
|
||||
but replace the latter version with the new version. For example, if the
|
||||
patch is version is "v0.28.1", replace the latter version:
|
||||
"https://github.com/google-gemini/gemini-cli/compare/v0.27.0...v0.28.0" with
|
||||
"https://github.com/google-gemini/gemini-cli/compare/v0.27.0...v0.28.1".
|
||||
|
||||
Otherwise, for minor and major version changes, replace the link with the
|
||||
one included at the end of the changelog data.
|
||||
|
||||
6. Ensure lines are wrapped to 80 characters.
|
||||
|
||||
### Update `docs/changelogs/index.md`
|
||||
|
||||
Skip this step for patches, bug fixes, or preview releases.
|
||||
|
||||
Insert a new "Announcements" section for the new version directly
|
||||
above the previous version's section. Ensure lines are wrapped to
|
||||
80 characters.
|
||||
|
||||
### Finalize
|
||||
|
||||
Run `npm run format` to ensure consistency.
|
||||
@@ -1,84 +0,0 @@
|
||||
# This workflow is triggered on every new release.
|
||||
# It uses Gemini to generate release notes and creates a PR with the changes.
|
||||
name: 'Generate Release Notes'
|
||||
|
||||
on:
|
||||
release:
|
||||
types: ['created']
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
version:
|
||||
description: 'New version (e.g., v1.2.3)'
|
||||
required: true
|
||||
type: 'string'
|
||||
body:
|
||||
description: 'Release notes body'
|
||||
required: true
|
||||
type: 'string'
|
||||
time:
|
||||
description: 'Release time'
|
||||
required: true
|
||||
type: 'string'
|
||||
|
||||
jobs:
|
||||
generate-release-notes:
|
||||
runs-on: 'ubuntu-latest'
|
||||
permissions:
|
||||
contents: 'write'
|
||||
pull-requests: 'write'
|
||||
steps:
|
||||
- name: 'Checkout repository'
|
||||
uses: 'actions/checkout@v4'
|
||||
with:
|
||||
# The user-level skills need to be available to the workflow
|
||||
fetch-depth: 0
|
||||
|
||||
- name: 'Set up Node.js'
|
||||
uses: 'actions/setup-node@v4'
|
||||
with:
|
||||
node-version: '20'
|
||||
|
||||
- name: 'Get release information'
|
||||
id: 'release_info'
|
||||
run: |
|
||||
VERSION="${{ github.event.inputs.version || github.event.release.tag_name }}"
|
||||
BODY="${{ github.event.inputs.body || github.event.release.body }}"
|
||||
TIME="${{ github.event.inputs.time || github.event.release.created_at }}"
|
||||
|
||||
echo "VERSION=${VERSION}" >> "$GITHUB_OUTPUT"
|
||||
echo "TIME=${TIME}" >> "$GITHUB_OUTPUT"
|
||||
|
||||
# Use a heredoc to preserve multiline release body
|
||||
echo 'RAW_CHANGELOG<<EOF' >> "$GITHUB_OUTPUT"
|
||||
echo "${BODY}" >> "$GITHUB_OUTPUT"
|
||||
echo 'EOF' >> "$GITHUB_OUTPUT"
|
||||
env:
|
||||
GH_TOKEN: '${{ secrets.GEMINI_CLI_ROBOT_GITHUB_PAT }}'
|
||||
|
||||
- name: 'Generate Changelog with Gemini'
|
||||
uses: 'google-github-actions/run-gemini-cli@a3bf79042542528e91937b3a3a6fbc4967ee3c31' # ratchet:google-github-actions/run-gemini-cli@v0
|
||||
with:
|
||||
gemini_api_key: '${{ secrets.GEMINI_API_KEY }}'
|
||||
prompt: |
|
||||
Activate the 'docs-changelog' skill.
|
||||
|
||||
**Release Information:**
|
||||
- New Version: ${{ steps.release_info.outputs.VERSION }}
|
||||
- Release Date: ${{ steps.release_info.outputs.TIME }}
|
||||
- Raw Changelog Data: ${{ steps.release_info.outputs.RAW_CHANGELOG }}
|
||||
|
||||
Execute the release notes generation process using the information provided.
|
||||
|
||||
- name: 'Create Pull Request'
|
||||
uses: 'peter-evans/create-pull-request@v6'
|
||||
with:
|
||||
token: '${{ secrets.GEMINI_CLI_ROBOT_GITHUB_PAT }}'
|
||||
commit-message: 'docs(changelog): update for ${{ steps.release_info.outputs.VERSION }}'
|
||||
title: 'Changelog for ${{ steps.release_info.outputs.VERSION }}'
|
||||
body: |
|
||||
This PR contains the auto-generated changelog for the ${{ steps.release_info.outputs.VERSION }} release.
|
||||
|
||||
Please review and merge.
|
||||
branch: 'changelog-${{ steps.release_info.outputs.VERSION }}'
|
||||
team-reviewers: 'gemini-cli-docs, gemini-cli-maintainers'
|
||||
delete-branch: true
|
||||
@@ -29,11 +29,7 @@ on:
|
||||
jobs:
|
||||
verify-release:
|
||||
environment: "${{ github.event.inputs.environment || 'prod' }}"
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
os: ['ubuntu-latest', 'macos-latest', 'windows-latest']
|
||||
runs-on: '${{ matrix.os }}'
|
||||
runs-on: 'ubuntu-latest'
|
||||
permissions:
|
||||
contents: 'read'
|
||||
packages: 'write'
|
||||
|
||||
@@ -55,8 +55,6 @@ powerful tool for developers.
|
||||
- **Contributions:** Follow the process outlined in `CONTRIBUTING.md`. Requires
|
||||
signing the Google CLA.
|
||||
- **Pull Requests:** Keep PRs small, focused, and linked to an existing issue.
|
||||
Always activate the `pr-creator` skill for PR generation, even when using the
|
||||
`gh` CLI.
|
||||
- **Commit Messages:** Follow the
|
||||
[Conventional Commits](https://www.conventionalcommits.org/) standard.
|
||||
- **Coding Style:** Adhere to existing patterns in `packages/cli` (React/Ink)
|
||||
|
||||
@@ -124,6 +124,8 @@ npm install -g @google/gemini-cli@nightly
|
||||
|
||||
### Advanced Capabilities
|
||||
|
||||
- **Automated Iterative Loops**: Use [Ralph Wiggum mode](./docs/ralph-wiggum.md)
|
||||
to repeatedly execute prompts until a goal is met (e.g., fixing tests).
|
||||
- Ground your queries with built-in
|
||||
[Google Search](https://ai.google.dev/gemini-api/docs/grounding) for real-time
|
||||
information
|
||||
@@ -256,6 +258,33 @@ use `--output-format stream-json` to get newline-delimited JSON events:
|
||||
gemini -p "Run tests and deploy" --output-format stream-json
|
||||
```
|
||||
|
||||
### Ralph Wiggum Mode (Iterative Automation)
|
||||
|
||||
Ralph Wiggum mode is an advanced automation feature that allows Gemini CLI to
|
||||
repeatedly execute a prompt in a loop until a specific goal is achieved. This is
|
||||
ideal for tasks like fixing failing tests or complex refactoring.
|
||||
|
||||
To use Ralph Wiggum mode, provide a prompt and a **completion promise** (a
|
||||
string to look for in the output). The CLI will:
|
||||
|
||||
1. Enter **YOLO mode** to auto-approve all tool calls.
|
||||
2. Run the prompt and check the response for your completion string.
|
||||
3. If not found, it repeats the process up to a specified **max iterations**.
|
||||
4. **Persistent Context**: It uses a **memory file** (`memories.md` by default)
|
||||
to pass notes between iterations. **Note:** Use a unique `--memory-file` for
|
||||
different tasks in the same directory to ensure context isolation.
|
||||
|
||||
```bash
|
||||
gemini -p "Fix the failing tests in this repo" \
|
||||
--ralph-wiggum \
|
||||
--completion-promise "ALL TESTS PASSED" \
|
||||
--max-iterations 5 \
|
||||
--memory-file "task-fix-tests.md"
|
||||
```
|
||||
|
||||
At the end of the run, a summary table displays the result of each iteration and
|
||||
extracted test statistics.
|
||||
|
||||
### Quick Examples
|
||||
|
||||
#### Start a new project
|
||||
|
||||
@@ -18,22 +18,6 @@ on GitHub.
|
||||
| [Preview](preview.md) | Experimental features ready for early feedback. |
|
||||
| [Stable](latest.md) | Stable, recommended for general use. |
|
||||
|
||||
## Announcements: v0.27.0 - 2026-02-03
|
||||
|
||||
- **Event-Driven Architecture:** The CLI now uses a new event-driven scheduler
|
||||
for tool execution, resulting in a more responsive and performant experience
|
||||
([#17078](https://github.com/google-gemini/gemini-cli/pull/17078) by
|
||||
@abhipatel12).
|
||||
- **Enhanced User Experience:** This release includes queued tool confirmations,
|
||||
and expandable large text pastes for a smoother workflow.
|
||||
- **New `/rewind` Command:** Easily navigate your session history with the new
|
||||
`/rewind` command
|
||||
([#15720](https://github.com/google-gemini/gemini-cli/pull/15720) by
|
||||
@Adib234).
|
||||
- **Linux Clipboard Support:** You can now paste images on Linux with Wayland
|
||||
and X11 ([#17144](https://github.com/google-gemini/gemini-cli/pull/17144) by
|
||||
@devr0306).
|
||||
|
||||
## Announcements: v0.26.0 - 2026-01-27
|
||||
|
||||
- **Agents and Skills:** We've introduced a new `skill-creator` skill
|
||||
|
||||
+317
-426
@@ -1,6 +1,6 @@
|
||||
# Latest stable release: v0.27.0
|
||||
# Latest stable release: v0.26.0
|
||||
|
||||
Released: February 3, 2026
|
||||
Released: January 27, 2026
|
||||
|
||||
For most users, our latest stable release is the recommended release. Install
|
||||
the latest stable version with:
|
||||
@@ -11,437 +11,328 @@ npm install -g @google/gemini-cli
|
||||
|
||||
## Highlights
|
||||
|
||||
- **Event-Driven Architecture:** The CLI now uses an event-driven scheduler for
|
||||
tool execution, improving performance and responsiveness. This includes
|
||||
migrating non-interactive flows and sub-agents to the new scheduler.
|
||||
- **Enhanced User Experience:** This release introduces several UI/UX
|
||||
improvements, including queued tool confirmations and the ability to expand
|
||||
and collapse large pasted text blocks. The `Settings` dialog has been improved
|
||||
to reduce jitter and preserve focus.
|
||||
- **Agent and Skill Improvements:** Agent Skills have been promoted to a stable
|
||||
feature. Sub-agents now use a JSON schema for input and are tracked by an
|
||||
`AgentRegistry`.
|
||||
- **New `/rewind` Command:** A new `/rewind` command has been implemented to
|
||||
allow users to go back in their session history.
|
||||
- **Improved Shell and File Handling:** The shell tool's output format has been
|
||||
optimized, and the CLI now gracefully handles disk-full errors during chat
|
||||
recording. A bug in detecting already added paths has been fixed.
|
||||
- **Linux Clipboard Support:** Image pasting capabilities for Wayland and X11 on
|
||||
Linux have been added.
|
||||
- **Enhanced Agent and Skill Capabilities:** This release introduces the new
|
||||
`skill-creator` built-in skill, enables Agent Skills by default, and adds a
|
||||
generalist agent to improve task routing. Security for skill installation has
|
||||
also been enhanced with new consent prompts.
|
||||
- **Improved UI and UX:** A new "Rewind" feature lets you walk back through
|
||||
conversation history. We've also added an `/introspect` command for debugging
|
||||
and unified various shell confirmation dialogs for a more consistent user
|
||||
experience.
|
||||
- **Core Stability and Performance:** This release includes significant
|
||||
performance improvements, including a fix for PDF token estimation,
|
||||
optimizations for large inputs, and prevention of OOM crashes. Key memory
|
||||
management components like `LRUCache` have also been updated.
|
||||
- **Scheduler and Policy Refactoring:** The core tool scheduler has been
|
||||
decoupled into distinct orchestration, policy, and confirmation components,
|
||||
and we've added an experimental event-driven scheduler to improve performance
|
||||
and reliability.
|
||||
|
||||
## What's Changed
|
||||
|
||||
- remove fireAgent and beforeAgent hook by @ishaanxgupta in
|
||||
[#16919](https://github.com/google-gemini/gemini-cli/pull/16919)
|
||||
- Remove unused modelHooks and toolHooks by @ved015 in
|
||||
[#17115](https://github.com/google-gemini/gemini-cli/pull/17115)
|
||||
- feat(cli): sanitize ANSI escape sequences in non-interactive output by
|
||||
@sehoon38 in [#17172](https://github.com/google-gemini/gemini-cli/pull/17172)
|
||||
- Update Attempt text to Retry when showing the retry happening to the … by
|
||||
@sehoon38 in [#17178](https://github.com/google-gemini/gemini-cli/pull/17178)
|
||||
- chore(skills): update pr-creator skill workflow by @sehoon38 in
|
||||
[#17180](https://github.com/google-gemini/gemini-cli/pull/17180)
|
||||
- feat(cli): implement event-driven tool execution scheduler by @abhipatel12 in
|
||||
[#17078](https://github.com/google-gemini/gemini-cli/pull/17078)
|
||||
- chore(release): bump version to 0.27.0-nightly.20260121.97aac696f by
|
||||
- fix: PDF token estimation (#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
|
||||
[#17181](https://github.com/google-gemini/gemini-cli/pull/17181)
|
||||
- Remove other rewind reference in docs by @chrstnb in
|
||||
[#17149](https://github.com/google-gemini/gemini-cli/pull/17149)
|
||||
- feat(skills): add code-reviewer skill by @sehoon38 in
|
||||
[#17187](https://github.com/google-gemini/gemini-cli/pull/17187)
|
||||
- feat(plan): Extend Shift+Tab Mode Cycling to include Plan Mode by @Adib234 in
|
||||
[#17177](https://github.com/google-gemini/gemini-cli/pull/17177)
|
||||
- feat(plan): refactor TestRig and eval helper to support configurable approval
|
||||
modes by @jerop in
|
||||
[#17171](https://github.com/google-gemini/gemini-cli/pull/17171)
|
||||
- feat(workflows): support recursive workstream labeling and new IDs by
|
||||
@bdmorgan in [#17207](https://github.com/google-gemini/gemini-cli/pull/17207)
|
||||
- Run evals for all models. by @gundermanc in
|
||||
[#17123](https://github.com/google-gemini/gemini-cli/pull/17123)
|
||||
- fix(github): improve label-workstream-rollup efficiency with GraphQL by
|
||||
@bdmorgan in [#17217](https://github.com/google-gemini/gemini-cli/pull/17217)
|
||||
- Docs: Update changelogs for v.0.25.0 and v0.26.0-preview.0 releases. by
|
||||
@g-samroberts in
|
||||
[#17215](https://github.com/google-gemini/gemini-cli/pull/17215)
|
||||
- Migrate beforeTool and afterTool hooks to hookSystem by @ved015 in
|
||||
[#17204](https://github.com/google-gemini/gemini-cli/pull/17204)
|
||||
- fix(github): improve label-workstream-rollup efficiency and fix bugs by
|
||||
@bdmorgan in [#17219](https://github.com/google-gemini/gemini-cli/pull/17219)
|
||||
- feat(cli): improve skill enablement/disablement verbiage by @NTaylorMullen in
|
||||
[#17192](https://github.com/google-gemini/gemini-cli/pull/17192)
|
||||
- fix(admin): Ensure CLI commands run in non-interactive mode by @skeshive in
|
||||
[#17218](https://github.com/google-gemini/gemini-cli/pull/17218)
|
||||
- feat(core): support dynamic variable substitution in system prompt override by
|
||||
@NTaylorMullen in
|
||||
[#17042](https://github.com/google-gemini/gemini-cli/pull/17042)
|
||||
- fix(core,cli): enable recursive directory access for by @galz10 in
|
||||
[#17094](https://github.com/google-gemini/gemini-cli/pull/17094)
|
||||
- Docs: Marking for experimental features by @jkcinouye in
|
||||
[#16760](https://github.com/google-gemini/gemini-cli/pull/16760)
|
||||
- Support command/ctrl/alt backspace correctly by @scidomino in
|
||||
[#17175](https://github.com/google-gemini/gemini-cli/pull/17175)
|
||||
- feat(plan): add approval mode instructions to system prompt by @jerop in
|
||||
[#17151](https://github.com/google-gemini/gemini-cli/pull/17151)
|
||||
- feat(core): enable disableLLMCorrection by default by @SandyTao520 in
|
||||
[#17223](https://github.com/google-gemini/gemini-cli/pull/17223)
|
||||
- Remove unused slug from sidebar by @chrstnb in
|
||||
[#17229](https://github.com/google-gemini/gemini-cli/pull/17229)
|
||||
- drain stdin on exit by @scidomino in
|
||||
[#17241](https://github.com/google-gemini/gemini-cli/pull/17241)
|
||||
- refactor(cli): decouple UI from live tool execution via ToolActionsContext by
|
||||
@abhipatel12 in
|
||||
[#17183](https://github.com/google-gemini/gemini-cli/pull/17183)
|
||||
- fix(core): update token count and telemetry on /chat resume history load by
|
||||
@psinha40898 in
|
||||
[#16279](https://github.com/google-gemini/gemini-cli/pull/16279)
|
||||
- fix: /policy to display policies according to mode by @ishaanxgupta in
|
||||
[#16772](https://github.com/google-gemini/gemini-cli/pull/16772)
|
||||
- fix(core): simplify replace tool error message by @SandyTao520 in
|
||||
[#17246](https://github.com/google-gemini/gemini-cli/pull/17246)
|
||||
- feat(cli): consolidate shell inactivity and redirection monitoring by
|
||||
@NTaylorMullen in
|
||||
[#17086](https://github.com/google-gemini/gemini-cli/pull/17086)
|
||||
- fix(scheduler): prevent stale tool re-publication and fix stuck UI state by
|
||||
@abhipatel12 in
|
||||
[#17227](https://github.com/google-gemini/gemini-cli/pull/17227)
|
||||
- feat(config): default enableEventDrivenScheduler to true by @abhipatel12 in
|
||||
[#17211](https://github.com/google-gemini/gemini-cli/pull/17211)
|
||||
- feat(hooks): enable hooks system by default by @abhipatel12 in
|
||||
[#17247](https://github.com/google-gemini/gemini-cli/pull/17247)
|
||||
- feat(core): Enable AgentRegistry to track all discovered subagents by
|
||||
@SandyTao520 in
|
||||
[#17253](https://github.com/google-gemini/gemini-cli/pull/17253)
|
||||
- feat(core): Have subagents use a JSON schema type for input. by @joshualitt in
|
||||
[#17152](https://github.com/google-gemini/gemini-cli/pull/17152)
|
||||
- feat: replace large text pastes with [Pasted Text: X lines] placeholder by
|
||||
@jackwotherspoon in
|
||||
[#16422](https://github.com/google-gemini/gemini-cli/pull/16422)
|
||||
- security(hooks): Wrap hook-injected context in distinct XML tags by @yunaseoul
|
||||
in [#17237](https://github.com/google-gemini/gemini-cli/pull/17237)
|
||||
- Enable the ability to queue specific nightly eval tests by @gundermanc in
|
||||
[#17262](https://github.com/google-gemini/gemini-cli/pull/17262)
|
||||
- docs(hooks): comprehensive update of hook documentation and specs by
|
||||
@abhipatel12 in
|
||||
[#16816](https://github.com/google-gemini/gemini-cli/pull/16816)
|
||||
- refactor: improve large text paste placeholder by @jacob314 in
|
||||
[#17269](https://github.com/google-gemini/gemini-cli/pull/17269)
|
||||
- feat: implement /rewind command by @Adib234 in
|
||||
[#15720](https://github.com/google-gemini/gemini-cli/pull/15720)
|
||||
- Feature/jetbrains ide detection by @SoLoHiC in
|
||||
[#16243](https://github.com/google-gemini/gemini-cli/pull/16243)
|
||||
- docs: update typo in mcp-server.md file by @schifferl in
|
||||
[#17099](https://github.com/google-gemini/gemini-cli/pull/17099)
|
||||
- Sanitize command names and descriptions by @ehedlund in
|
||||
[#17228](https://github.com/google-gemini/gemini-cli/pull/17228)
|
||||
- fix(auth): don't crash when initial auth fails by @skeshive in
|
||||
[#17308](https://github.com/google-gemini/gemini-cli/pull/17308)
|
||||
- Added image pasting capabilities for Wayland and X11 on Linux by @devr0306 in
|
||||
[#17144](https://github.com/google-gemini/gemini-cli/pull/17144)
|
||||
- feat: add AskUser tool schema by @jackwotherspoon in
|
||||
[#16988](https://github.com/google-gemini/gemini-cli/pull/16988)
|
||||
- fix cli settings: resolve layout jitter in settings bar by @Mag1ck in
|
||||
[#16256](https://github.com/google-gemini/gemini-cli/pull/16256)
|
||||
- fix: show whitespace changes in edit tool diffs by @Ujjiyara in
|
||||
[#17213](https://github.com/google-gemini/gemini-cli/pull/17213)
|
||||
- Remove redundant calls setting linuxClipboardTool. getUserLinuxClipboardTool()
|
||||
now handles the caching internally by @jacob314 in
|
||||
[#17320](https://github.com/google-gemini/gemini-cli/pull/17320)
|
||||
- ci: allow failure in evals-nightly run step by @gundermanc in
|
||||
[#17319](https://github.com/google-gemini/gemini-cli/pull/17319)
|
||||
- feat(cli): Add state management and plumbing for agent configuration dialog by
|
||||
@SandyTao520 in
|
||||
[#17259](https://github.com/google-gemini/gemini-cli/pull/17259)
|
||||
- bug: fix ide-client connection to ide-companion when inside docker via
|
||||
ssh/devcontainer by @kapsner in
|
||||
[#15049](https://github.com/google-gemini/gemini-cli/pull/15049)
|
||||
- Emit correct newline type return by @scidomino in
|
||||
[#17331](https://github.com/google-gemini/gemini-cli/pull/17331)
|
||||
- New skill: docs-writer by @g-samroberts in
|
||||
[#17268](https://github.com/google-gemini/gemini-cli/pull/17268)
|
||||
- fix(core): Resolve AbortSignal MaxListenersExceededWarning (#5950) by
|
||||
@spencer426 in
|
||||
[#16735](https://github.com/google-gemini/gemini-cli/pull/16735)
|
||||
- Disable tips after 10 runs by @Adib234 in
|
||||
[#17101](https://github.com/google-gemini/gemini-cli/pull/17101)
|
||||
- Fix so rewind starts at the bottom and loadHistory refreshes static content.
|
||||
by @jacob314 in
|
||||
[#17335](https://github.com/google-gemini/gemini-cli/pull/17335)
|
||||
- feat(core): Remove legacy settings. by @joshualitt in
|
||||
[#17244](https://github.com/google-gemini/gemini-cli/pull/17244)
|
||||
- feat(plan): add 'communicate' tool kind by @jerop in
|
||||
[#17341](https://github.com/google-gemini/gemini-cli/pull/17341)
|
||||
- feat(routing): A/B Test Numerical Complexity Scoring for Gemini 3 by
|
||||
@mattKorwel in
|
||||
[#16041](https://github.com/google-gemini/gemini-cli/pull/16041)
|
||||
- feat(plan): update UI Theme for Plan Mode by @Adib234 in
|
||||
[#17243](https://github.com/google-gemini/gemini-cli/pull/17243)
|
||||
- fix(ui): stabilize rendering during terminal resize in alternate buffer by
|
||||
@lkk214 in [#15783](https://github.com/google-gemini/gemini-cli/pull/15783)
|
||||
- feat(cli): add /agents config command and improve agent discovery by
|
||||
@SandyTao520 in
|
||||
[#17342](https://github.com/google-gemini/gemini-cli/pull/17342)
|
||||
- feat(mcp): add enable/disable commands for MCP servers (#11057) by @jasmeetsb
|
||||
in [#16299](https://github.com/google-gemini/gemini-cli/pull/16299)
|
||||
- fix(cli)!: Default to interactive mode for positional arguments by
|
||||
@ishaanxgupta in
|
||||
[#16329](https://github.com/google-gemini/gemini-cli/pull/16329)
|
||||
- Fix issue #17080 by @jacob314 in
|
||||
[#17100](https://github.com/google-gemini/gemini-cli/pull/17100)
|
||||
- feat(core): Refresh agents after loading an extension. by @joshualitt in
|
||||
[#17355](https://github.com/google-gemini/gemini-cli/pull/17355)
|
||||
- fix(cli): include source in policy rule display by @allenhutchison in
|
||||
[#17358](https://github.com/google-gemini/gemini-cli/pull/17358)
|
||||
- fix: remove obsolete CloudCode PerDay quota and 120s terminal threshold by
|
||||
@gsquared94 in
|
||||
[#17236](https://github.com/google-gemini/gemini-cli/pull/17236)
|
||||
- Refactor subagent delegation to be one tool per agent by @gundermanc in
|
||||
[#17346](https://github.com/google-gemini/gemini-cli/pull/17346)
|
||||
- fix(core): Include MCP server name in OAuth message by @jerop in
|
||||
[#17351](https://github.com/google-gemini/gemini-cli/pull/17351)
|
||||
- Fix pr-triage.sh script to update pull requests with tags "help wanted" and
|
||||
"maintainer only" by @jacob314 in
|
||||
[#17324](https://github.com/google-gemini/gemini-cli/pull/17324)
|
||||
- feat(plan): implement simple workflow for planning in main agent by @jerop in
|
||||
[#17326](https://github.com/google-gemini/gemini-cli/pull/17326)
|
||||
- fix: exit with non-zero code when esbuild is missing by @yuvrajangadsingh in
|
||||
[#16967](https://github.com/google-gemini/gemini-cli/pull/16967)
|
||||
- fix: ensure @docs/cli/custom-commands.md UI message ordering and test by
|
||||
[#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
|
||||
[#12038](https://github.com/google-gemini/gemini-cli/pull/12038)
|
||||
- fix(core): add alternative command names for Antigravity editor detec… by
|
||||
@baeseokjae in
|
||||
[#16829](https://github.com/google-gemini/gemini-cli/pull/16829)
|
||||
- Refactor: Migrate CLI appEvents to Core coreEvents by @Adib234 in
|
||||
[#15737](https://github.com/google-gemini/gemini-cli/pull/15737)
|
||||
- fix(core): await MCP initialization in non-interactive mode by @Ratish1 in
|
||||
[#17390](https://github.com/google-gemini/gemini-cli/pull/17390)
|
||||
- Fix modifyOtherKeys enablement on unsupported terminals by @seekskyworld in
|
||||
[#16714](https://github.com/google-gemini/gemini-cli/pull/16714)
|
||||
- fix(core): gracefully handle disk full errors in chat recording by
|
||||
@godwiniheuwa in
|
||||
[#17305](https://github.com/google-gemini/gemini-cli/pull/17305)
|
||||
- fix(oauth): update oauth to use 127.0.0.1 instead of localhost by @skeshive in
|
||||
[#17388](https://github.com/google-gemini/gemini-cli/pull/17388)
|
||||
- fix(core): use RFC 9728 compliant path-based OAuth protected resource
|
||||
discovery by @vrv in
|
||||
[#15756](https://github.com/google-gemini/gemini-cli/pull/15756)
|
||||
- Update Code Wiki README badge by @PatoBeltran in
|
||||
[#15229](https://github.com/google-gemini/gemini-cli/pull/15229)
|
||||
- Add conda installation instructions for Gemini CLI by @ishaanxgupta in
|
||||
[#16921](https://github.com/google-gemini/gemini-cli/pull/16921)
|
||||
- chore(refactor): extract BaseSettingsDialog component by @SandyTao520 in
|
||||
[#17369](https://github.com/google-gemini/gemini-cli/pull/17369)
|
||||
- fix(cli): preserve input text when declining tool approval (#15624) by
|
||||
@ManojINaik in
|
||||
[#15659](https://github.com/google-gemini/gemini-cli/pull/15659)
|
||||
- chore: upgrade dep: diff 7.0.0-> 8.0.3 by @scidomino in
|
||||
[#17403](https://github.com/google-gemini/gemini-cli/pull/17403)
|
||||
- feat: add AskUserDialog for UI component of AskUser tool by @jackwotherspoon
|
||||
in [#17344](https://github.com/google-gemini/gemini-cli/pull/17344)
|
||||
- feat(ui): display user tier in about command by @sehoon38 in
|
||||
[#17400](https://github.com/google-gemini/gemini-cli/pull/17400)
|
||||
- feat: add clearContext to AfterAgent hooks by @jackwotherspoon in
|
||||
[#16574](https://github.com/google-gemini/gemini-cli/pull/16574)
|
||||
- fix(cli): change image paste location to global temp directory (#17396) by
|
||||
@devr0306 in [#17396](https://github.com/google-gemini/gemini-cli/pull/17396)
|
||||
- Fix line endings issue with Notice file by @scidomino in
|
||||
[#17417](https://github.com/google-gemini/gemini-cli/pull/17417)
|
||||
- feat(plan): implement persistent approvalMode setting by @Adib234 in
|
||||
[#17350](https://github.com/google-gemini/gemini-cli/pull/17350)
|
||||
- feat(ui): Move keyboard handling into BaseSettingsDialog by @SandyTao520 in
|
||||
[#17404](https://github.com/google-gemini/gemini-cli/pull/17404)
|
||||
- Allow prompt queueing during MCP initialization by @Adib234 in
|
||||
[#17395](https://github.com/google-gemini/gemini-cli/pull/17395)
|
||||
- feat: implement AgentConfigDialog for /agents config command by @SandyTao520
|
||||
in [#17370](https://github.com/google-gemini/gemini-cli/pull/17370)
|
||||
- fix(agents): default to all tools when tool list is omitted in subagents by
|
||||
@gundermanc in
|
||||
[#17422](https://github.com/google-gemini/gemini-cli/pull/17422)
|
||||
- feat(cli): Moves tool confirmations to a queue UX by @abhipatel12 in
|
||||
[#17276](https://github.com/google-gemini/gemini-cli/pull/17276)
|
||||
- fix(core): hide user tier name by @sehoon38 in
|
||||
[#17418](https://github.com/google-gemini/gemini-cli/pull/17418)
|
||||
- feat: Enforce unified folder trust for /directory add by @galz10 in
|
||||
[#17359](https://github.com/google-gemini/gemini-cli/pull/17359)
|
||||
- migrate fireToolNotificationHook to hookSystem by @ved015 in
|
||||
[#17398](https://github.com/google-gemini/gemini-cli/pull/17398)
|
||||
- Clean up dead code by @scidomino in
|
||||
[#17443](https://github.com/google-gemini/gemini-cli/pull/17443)
|
||||
- feat(workflow): add stale pull request closer with linked-issue enforcement by
|
||||
@bdmorgan in [#17449](https://github.com/google-gemini/gemini-cli/pull/17449)
|
||||
- feat(workflow): expand stale-exempt labels to include help wanted and Public
|
||||
Roadmap by @bdmorgan in
|
||||
[#17459](https://github.com/google-gemini/gemini-cli/pull/17459)
|
||||
- chore(workflow): remove redundant label-enforcer workflow by @bdmorgan in
|
||||
[#17460](https://github.com/google-gemini/gemini-cli/pull/17460)
|
||||
- Resolves the confusing error message `ripgrep exited with code null that
|
||||
occurs when a search operation is cancelled or aborted by @maximmasiutin in
|
||||
[#14267](https://github.com/google-gemini/gemini-cli/pull/14267)
|
||||
- fix: detect pnpm/pnpx in ~/.local by @rwakulszowa in
|
||||
[#15254](https://github.com/google-gemini/gemini-cli/pull/15254)
|
||||
- docs: Add instructions for MacPorts and uninstall instructions for Homebrew by
|
||||
@breun in [#17412](https://github.com/google-gemini/gemini-cli/pull/17412)
|
||||
- docs(hooks): clarify mandatory 'type' field and update hook schema
|
||||
documentation by @abhipatel12 in
|
||||
[#17499](https://github.com/google-gemini/gemini-cli/pull/17499)
|
||||
- Improve error messages on failed onboarding by @gsquared94 in
|
||||
[#17357](https://github.com/google-gemini/gemini-cli/pull/17357)
|
||||
- Follow up to "enableInteractiveShell for external tooling relying on a2a
|
||||
server" by @DavidAPierce in
|
||||
[#17130](https://github.com/google-gemini/gemini-cli/pull/17130)
|
||||
- Fix/issue 17070 by @alih552 in
|
||||
[#17242](https://github.com/google-gemini/gemini-cli/pull/17242)
|
||||
- fix(core): handle URI-encoded workspace paths in IdeClient by @dong-jun-shin
|
||||
in [#17476](https://github.com/google-gemini/gemini-cli/pull/17476)
|
||||
- feat(cli): add quick clear input shortcuts in vim mode by @harshanadim in
|
||||
[#17470](https://github.com/google-gemini/gemini-cli/pull/17470)
|
||||
- feat(core): optimize shell tool llmContent output format by @SandyTao520 in
|
||||
[#17538](https://github.com/google-gemini/gemini-cli/pull/17538)
|
||||
- Fix bug in detecting already added paths. by @jacob314 in
|
||||
[#17430](https://github.com/google-gemini/gemini-cli/pull/17430)
|
||||
- feat(scheduler): support multi-scheduler tool aggregation and nested call IDs
|
||||
by @abhipatel12 in
|
||||
[#17429](https://github.com/google-gemini/gemini-cli/pull/17429)
|
||||
- feat(agents): implement first-run experience for project-level sub-agents by
|
||||
@gundermanc in
|
||||
[#17266](https://github.com/google-gemini/gemini-cli/pull/17266)
|
||||
- Update extensions docs by @chrstnb in
|
||||
[#16093](https://github.com/google-gemini/gemini-cli/pull/16093)
|
||||
- Docs: Refactor left nav on the website by @jkcinouye in
|
||||
[#17558](https://github.com/google-gemini/gemini-cli/pull/17558)
|
||||
- fix(core): stream grep/ripgrep output to prevent OOM by @adamfweidman in
|
||||
[#17146](https://github.com/google-gemini/gemini-cli/pull/17146)
|
||||
- feat(plan): add persistent plan file storage by @jerop in
|
||||
[#17563](https://github.com/google-gemini/gemini-cli/pull/17563)
|
||||
- feat(agents): migrate subagents to event-driven scheduler by @abhipatel12 in
|
||||
[#17567](https://github.com/google-gemini/gemini-cli/pull/17567)
|
||||
- Fix extensions config error by @chrstnb in
|
||||
[#17580](https://github.com/google-gemini/gemini-cli/pull/17580)
|
||||
- fix(plan): remove subagent invocation from plan mode by @jerop in
|
||||
[#17593](https://github.com/google-gemini/gemini-cli/pull/17593)
|
||||
- feat(ui): add solid background color option for input prompt by @jacob314 in
|
||||
[#16563](https://github.com/google-gemini/gemini-cli/pull/16563)
|
||||
- feat(plan): refresh system prompt when approval mode changes (Shift+Tab) by
|
||||
@jerop in [#17585](https://github.com/google-gemini/gemini-cli/pull/17585)
|
||||
- feat(cli): add global setting to disable UI spinners by @galz10 in
|
||||
[#17234](https://github.com/google-gemini/gemini-cli/pull/17234)
|
||||
- fix(security): enforce strict policy directory permissions by @yunaseoul in
|
||||
[#17353](https://github.com/google-gemini/gemini-cli/pull/17353)
|
||||
- test(core): fix tests in windows by @scidomino in
|
||||
[#17592](https://github.com/google-gemini/gemini-cli/pull/17592)
|
||||
- feat(mcp/extensions): Allow users to selectively enable/disable MCP servers
|
||||
included in an extension( Issue #11057 & #17402) by @jasmeetsb in
|
||||
[#17434](https://github.com/google-gemini/gemini-cli/pull/17434)
|
||||
- Always map mac keys, even on other platforms by @scidomino in
|
||||
[#17618](https://github.com/google-gemini/gemini-cli/pull/17618)
|
||||
- Ctrl-O by @jacob314 in
|
||||
[#17617](https://github.com/google-gemini/gemini-cli/pull/17617)
|
||||
- feat(plan): update cycling order of approval modes by @Adib234 in
|
||||
[#17622](https://github.com/google-gemini/gemini-cli/pull/17622)
|
||||
- fix(cli): restore 'Modify with editor' option in external terminals by
|
||||
[#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
|
||||
[#17621](https://github.com/google-gemini/gemini-cli/pull/17621)
|
||||
- Slash command for helping in debugging by @gundermanc in
|
||||
[#17609](https://github.com/google-gemini/gemini-cli/pull/17609)
|
||||
- feat: add double-click to expand/collapse large paste placeholders by
|
||||
@jackwotherspoon in
|
||||
[#17471](https://github.com/google-gemini/gemini-cli/pull/17471)
|
||||
- refactor(cli): migrate non-interactive flow to event-driven scheduler by
|
||||
[#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
|
||||
[#17572](https://github.com/google-gemini/gemini-cli/pull/17572)
|
||||
- fix: loadcodeassist eligible tiers getting ignored for unlicensed users
|
||||
(regression) by @gsquared94 in
|
||||
[#17581](https://github.com/google-gemini/gemini-cli/pull/17581)
|
||||
- chore(core): delete legacy nonInteractiveToolExecutor by @abhipatel12 in
|
||||
[#17573](https://github.com/google-gemini/gemini-cli/pull/17573)
|
||||
- feat(core): enforce server prefixes for MCP tools in agent definitions by
|
||||
[#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
|
||||
[#17574](https://github.com/google-gemini/gemini-cli/pull/17574)
|
||||
- feat (mcp): Refresh MCP prompts on list changed notification by @MrLesk in
|
||||
[#14863](https://github.com/google-gemini/gemini-cli/pull/14863)
|
||||
- feat(ui): pretty JSON rendering tool outputs by @medic-code in
|
||||
[#9767](https://github.com/google-gemini/gemini-cli/pull/9767)
|
||||
- Fix iterm alternate buffer mode issue rendering backgrounds by @jacob314 in
|
||||
[#17634](https://github.com/google-gemini/gemini-cli/pull/17634)
|
||||
- feat(cli): add gemini extensions list --output-format=json by @AkihiroSuda in
|
||||
[#14479](https://github.com/google-gemini/gemini-cli/pull/14479)
|
||||
- fix(extensions): add .gitignore to extension templates by @godwiniheuwa in
|
||||
[#17293](https://github.com/google-gemini/gemini-cli/pull/17293)
|
||||
- paste transform followup by @jacob314 in
|
||||
[#17624](https://github.com/google-gemini/gemini-cli/pull/17624)
|
||||
- refactor: rename formatMemoryUsage to formatBytes by @Nubebuster in
|
||||
[#14997](https://github.com/google-gemini/gemini-cli/pull/14997)
|
||||
- chore: remove extra top margin from /hooks and /extensions by @jackwotherspoon
|
||||
in [#17663](https://github.com/google-gemini/gemini-cli/pull/17663)
|
||||
- feat(cli): add oncall command for issue triage by @sehoon38 in
|
||||
[#17661](https://github.com/google-gemini/gemini-cli/pull/17661)
|
||||
- Fix sidebar issue for extensions link by @chrstnb in
|
||||
[#17668](https://github.com/google-gemini/gemini-cli/pull/17668)
|
||||
- Change formatting to prevent UI redressing attacks by @scidomino in
|
||||
[#17611](https://github.com/google-gemini/gemini-cli/pull/17611)
|
||||
- Fix cluster of bugs in the settings dialog. by @jacob314 in
|
||||
[#17628](https://github.com/google-gemini/gemini-cli/pull/17628)
|
||||
- Update sidebar to resolve site build issues by @chrstnb in
|
||||
[#17674](https://github.com/google-gemini/gemini-cli/pull/17674)
|
||||
- fix(admin): fix a few bugs related to admin controls by @skeshive in
|
||||
[#17590](https://github.com/google-gemini/gemini-cli/pull/17590)
|
||||
- revert bad changes to tests by @scidomino in
|
||||
[#17673](https://github.com/google-gemini/gemini-cli/pull/17673)
|
||||
- feat(cli): show candidate issue state reason and duplicate status in triage by
|
||||
@sehoon38 in [#17676](https://github.com/google-gemini/gemini-cli/pull/17676)
|
||||
- Fix missing slash commands when Gemini CLI is in a project with a package.json
|
||||
that doesn't follow semantic versioning by @Adib234 in
|
||||
[#17561](https://github.com/google-gemini/gemini-cli/pull/17561)
|
||||
- feat(core): Model family-specific system prompts by @joshualitt in
|
||||
[#17614](https://github.com/google-gemini/gemini-cli/pull/17614)
|
||||
- Sub-agents documentation. by @gundermanc in
|
||||
[#16639](https://github.com/google-gemini/gemini-cli/pull/16639)
|
||||
- feat: wire up AskUserTool with dialog by @jackwotherspoon in
|
||||
[#17411](https://github.com/google-gemini/gemini-cli/pull/17411)
|
||||
- Load extension settings for hooks, agents, skills by @chrstnb in
|
||||
[#17245](https://github.com/google-gemini/gemini-cli/pull/17245)
|
||||
- Fix issue where Gemini CLI can make changes when simply asked a question by
|
||||
@gundermanc in
|
||||
[#17608](https://github.com/google-gemini/gemini-cli/pull/17608)
|
||||
- Update docs-writer skill for editing and add style guide for reference. by
|
||||
@g-samroberts in
|
||||
[#17669](https://github.com/google-gemini/gemini-cli/pull/17669)
|
||||
- fix(ux): have user message display a short path for pasted images by @devr0306
|
||||
in [#17613](https://github.com/google-gemini/gemini-cli/pull/17613)
|
||||
- feat(plan): enable AskUser tool in Plan mode for clarifying questions by
|
||||
@jerop in [#17694](https://github.com/google-gemini/gemini-cli/pull/17694)
|
||||
- GEMINI.md polish by @jacob314 in
|
||||
[#17680](https://github.com/google-gemini/gemini-cli/pull/17680)
|
||||
- refactor(core): centralize path validation and allow temp dir access for tools
|
||||
by @NTaylorMullen in
|
||||
[#17185](https://github.com/google-gemini/gemini-cli/pull/17185)
|
||||
- feat(skills): promote Agent Skills to stable by @abhipatel12 in
|
||||
[#17693](https://github.com/google-gemini/gemini-cli/pull/17693)
|
||||
- refactor(cli): keyboard handling and AskUserDialog by @jacob314 in
|
||||
[#17414](https://github.com/google-gemini/gemini-cli/pull/17414)
|
||||
- docs: Add Experimental Remote Agent Docs by @adamfweidman in
|
||||
[#17697](https://github.com/google-gemini/gemini-cli/pull/17697)
|
||||
- revert: promote Agent Skills to stable (#17693) by @abhipatel12 in
|
||||
[#17712](https://github.com/google-gemini/gemini-cli/pull/17712)
|
||||
- feat(ux) Expandable (ctrl-O) and scrollable approvals in alternate buffer
|
||||
mode. by @jacob314 in
|
||||
[#17640](https://github.com/google-gemini/gemini-cli/pull/17640)
|
||||
- feat(skills): promote skills settings to stable by @abhipatel12 in
|
||||
[#17713](https://github.com/google-gemini/gemini-cli/pull/17713)
|
||||
- fix(cli): Preserve settings dialog focus when searching by @SandyTao520 in
|
||||
[#17701](https://github.com/google-gemini/gemini-cli/pull/17701)
|
||||
- feat(ui): add terminal cursor support by @jacob314 in
|
||||
[#17711](https://github.com/google-gemini/gemini-cli/pull/17711)
|
||||
- docs(skills): remove experimental labels and update tutorials by @abhipatel12
|
||||
in [#17714](https://github.com/google-gemini/gemini-cli/pull/17714)
|
||||
- docs: remove 'experimental' syntax for hooks in docs by @abhipatel12 in
|
||||
[#17660](https://github.com/google-gemini/gemini-cli/pull/17660)
|
||||
- Add support for an additional exclusion file besides .gitignore and
|
||||
.geminiignore by @alisa-alisa in
|
||||
[#16487](https://github.com/google-gemini/gemini-cli/pull/16487)
|
||||
- feat: add review-frontend-and-fix command by @galz10 in
|
||||
[#17707](https://github.com/google-gemini/gemini-cli/pull/17707)
|
||||
[#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) 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.26.0...v0.27.0
|
||||
https://github.com/google-gemini/gemini-cli/compare/v0.25.2...v0.26.0
|
||||
|
||||
+419
-289
@@ -1,6 +1,6 @@
|
||||
# Preview release: Release v0.28.0-preview.0
|
||||
# Preview release: Release v0.27.0-preview.0
|
||||
|
||||
Released: February 3, 2026
|
||||
Released: January 27, 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).
|
||||
@@ -13,295 +13,425 @@ npm install -g @google/gemini-cli@preview
|
||||
|
||||
## Highlights
|
||||
|
||||
- **Improved Hooks Management:** Hooks enable/disable functionality now aligns
|
||||
with skills and offers improved completion.
|
||||
- **Custom Themes for Extensions:** Extensions can now support custom themes,
|
||||
allowing for greater personalization.
|
||||
- **User Identity Display:** User identity information (auth, email, tier) is
|
||||
now displayed on startup and in the `stats` command.
|
||||
- **Plan Mode Enhancements:** Plan mode has been improved with a generic
|
||||
`Checklist` component and refactored `Todo`.
|
||||
- **Background Shell Commands:** Implementation of background shell commands.
|
||||
- **Event-Driven Architecture:** The tool execution scheduler is now
|
||||
event-driven, improving performance and reliability.
|
||||
- **System Prompt Override:** Now supports dynamic variable substitution.
|
||||
- **Rewind Command:** The `/rewind` command has been implemented.
|
||||
- **Linux Clipboard:** Image pasting capabilities for Wayland and X11 on Linux.
|
||||
|
||||
## What's Changed
|
||||
|
||||
- feat(commands): add /prompt-suggest slash command by NTaylorMullen in
|
||||
[#17264](https://github.com/google-gemini/gemini-cli/pull/17264)
|
||||
- feat(cli): align hooks enable/disable with skills and improve completion by
|
||||
sehoon38 in [#16822](https://github.com/google-gemini/gemini-cli/pull/16822)
|
||||
- docs: add CLI reference documentation by leochiu-a in
|
||||
[#17504](https://github.com/google-gemini/gemini-cli/pull/17504)
|
||||
- chore(release): bump version to 0.28.0-nightly.20260128.adc8e11bb by
|
||||
gemini-cli-robot in
|
||||
[#17725](https://github.com/google-gemini/gemini-cli/pull/17725)
|
||||
- feat(skills): final stable promotion cleanup by abhipatel12 in
|
||||
[#17726](https://github.com/google-gemini/gemini-cli/pull/17726)
|
||||
- test(core): mock fetch in OAuth transport fallback tests by jw409 in
|
||||
[#17059](https://github.com/google-gemini/gemini-cli/pull/17059)
|
||||
- feat(cli): include auth method in /bug by erikus in
|
||||
[#17569](https://github.com/google-gemini/gemini-cli/pull/17569)
|
||||
- Add a email privacy note to bug_report template by nemyung in
|
||||
[#17474](https://github.com/google-gemini/gemini-cli/pull/17474)
|
||||
- Rewind documentation by Adib234 in
|
||||
[#17446](https://github.com/google-gemini/gemini-cli/pull/17446)
|
||||
- fix: verify audio/video MIME types with content check by maru0804 in
|
||||
[#16907](https://github.com/google-gemini/gemini-cli/pull/16907)
|
||||
- feat(core): add support for positron ide (#15045) by kapsner in
|
||||
[#15047](https://github.com/google-gemini/gemini-cli/pull/15047)
|
||||
- /oncall dedup - wrap texts to nextlines by sehoon38 in
|
||||
[#17782](https://github.com/google-gemini/gemini-cli/pull/17782)
|
||||
- fix(admin): rename advanced features admin setting by skeshive in
|
||||
[#17786](https://github.com/google-gemini/gemini-cli/pull/17786)
|
||||
- [extension config] Make breaking optional value non-optional by chrstnb in
|
||||
[#17785](https://github.com/google-gemini/gemini-cli/pull/17785)
|
||||
- Fix docs-writer skill issues by g-samroberts in
|
||||
[#17734](https://github.com/google-gemini/gemini-cli/pull/17734)
|
||||
- fix(core): suppress duplicate hook failure warnings during streaming by
|
||||
abhipatel12 in
|
||||
[#17727](https://github.com/google-gemini/gemini-cli/pull/17727)
|
||||
- test: add more tests for AskUser by jackwotherspoon in
|
||||
[#17720](https://github.com/google-gemini/gemini-cli/pull/17720)
|
||||
- feat(cli): enable activity logging for non-interactive mode and evals by
|
||||
SandyTao520 in
|
||||
[#17703](https://github.com/google-gemini/gemini-cli/pull/17703)
|
||||
- feat(core): add support for custom deny messages in policy rules by
|
||||
allenhutchison in
|
||||
[#17427](https://github.com/google-gemini/gemini-cli/pull/17427)
|
||||
- Fix unintended credential exposure to MCP Servers by Adib234 in
|
||||
[#17311](https://github.com/google-gemini/gemini-cli/pull/17311)
|
||||
- feat(extensions): add support for custom themes in extensions by spencer426 in
|
||||
[#17327](https://github.com/google-gemini/gemini-cli/pull/17327)
|
||||
- fix: persist and restore workspace directories on session resume by
|
||||
korade-krushna in
|
||||
[#17454](https://github.com/google-gemini/gemini-cli/pull/17454)
|
||||
- Update release notes pages for 0.26.0 and 0.27.0-preview. by g-samroberts in
|
||||
[#17744](https://github.com/google-gemini/gemini-cli/pull/17744)
|
||||
- feat(ux): update cell border color and created test file for table rendering
|
||||
by devr0306 in
|
||||
[#17798](https://github.com/google-gemini/gemini-cli/pull/17798)
|
||||
- Change height for the ToolConfirmationQueue. by jacob314 in
|
||||
[#17799](https://github.com/google-gemini/gemini-cli/pull/17799)
|
||||
- feat(cli): add user identity info to stats command by sehoon38 in
|
||||
[#17612](https://github.com/google-gemini/gemini-cli/pull/17612)
|
||||
- fix(ux): fixed off-by-some wrapping caused by fixed-width characters by
|
||||
devr0306 in [#17816](https://github.com/google-gemini/gemini-cli/pull/17816)
|
||||
- feat(cli): update undo/redo keybindings to Cmd+Z/Alt+Z and
|
||||
Shift+Cmd+Z/Shift+Alt+Z by scidomino in
|
||||
[#17800](https://github.com/google-gemini/gemini-cli/pull/17800)
|
||||
- fix(evals): use absolute path for activity log directory by SandyTao520 in
|
||||
[#17830](https://github.com/google-gemini/gemini-cli/pull/17830)
|
||||
- test: add integration test to verify stdout/stderr routing by ved015 in
|
||||
[#17280](https://github.com/google-gemini/gemini-cli/pull/17280)
|
||||
- fix(cli): list installed extensions when update target missing by tt-a1i in
|
||||
[#17082](https://github.com/google-gemini/gemini-cli/pull/17082)
|
||||
- fix(cli): handle PAT tokens and credentials in git remote URL parsing by
|
||||
afarber in [#14650](https://github.com/google-gemini/gemini-cli/pull/14650)
|
||||
- fix(core): use returnDisplay for error result display by Nubebuster in
|
||||
[#14994](https://github.com/google-gemini/gemini-cli/pull/14994)
|
||||
- Fix detection of bun as package manager by Randomblock1 in
|
||||
[#17462](https://github.com/google-gemini/gemini-cli/pull/17462)
|
||||
- feat(cli): show hooksConfig.enabled in settings dialog by abhipatel12 in
|
||||
[#17810](https://github.com/google-gemini/gemini-cli/pull/17810)
|
||||
- feat(cli): Display user identity (auth, email, tier) on startup by yunaseoul
|
||||
in [#17591](https://github.com/google-gemini/gemini-cli/pull/17591)
|
||||
- fix: prevent ghost border for AskUserDialog by jackwotherspoon in
|
||||
[#17788](https://github.com/google-gemini/gemini-cli/pull/17788)
|
||||
- docs: mark A2A subagents as experimental in subagents.md by adamfweidman in
|
||||
[#17863](https://github.com/google-gemini/gemini-cli/pull/17863)
|
||||
- Resolve error thrown for sensitive values by chrstnb in
|
||||
[#17826](https://github.com/google-gemini/gemini-cli/pull/17826)
|
||||
- fix(admin): Rename secureModeEnabled to strictModeDisabled by skeshive in
|
||||
[#17789](https://github.com/google-gemini/gemini-cli/pull/17789)
|
||||
- feat(ux): update truncate dots to be shorter in tables by devr0306 in
|
||||
[#17825](https://github.com/google-gemini/gemini-cli/pull/17825)
|
||||
- fix(core): resolve DEP0040 punycode deprecation via patch-package by
|
||||
ATHARVA262005 in
|
||||
[#17692](https://github.com/google-gemini/gemini-cli/pull/17692)
|
||||
- feat(plan): create generic Checklist component and refactor Todo by Adib234 in
|
||||
[#17741](https://github.com/google-gemini/gemini-cli/pull/17741)
|
||||
- Cleanup post delegate_to_agent removal by gundermanc in
|
||||
[#17875](https://github.com/google-gemini/gemini-cli/pull/17875)
|
||||
- fix(core): use GIT_CONFIG_GLOBAL to isolate shadow git repo configuration -
|
||||
Fixes #17877 by cocosheng-g in
|
||||
[#17803](https://github.com/google-gemini/gemini-cli/pull/17803)
|
||||
- Disable mouse tracking e2e by alisa-alisa in
|
||||
[#17880](https://github.com/google-gemini/gemini-cli/pull/17880)
|
||||
- fix(cli): use correct setting key for Cloud Shell auth by sehoon38 in
|
||||
[#17884](https://github.com/google-gemini/gemini-cli/pull/17884)
|
||||
- chore: revert IDE specific ASCII logo by jackwotherspoon in
|
||||
[#17887](https://github.com/google-gemini/gemini-cli/pull/17887)
|
||||
- Revert "fix(core): resolve DEP0040 punycode deprecation via patch-package" by
|
||||
sehoon38 in [#17898](https://github.com/google-gemini/gemini-cli/pull/17898)
|
||||
- Refactoring of disabling of mouse tracking in e2e tests by alisa-alisa in
|
||||
[#17902](https://github.com/google-gemini/gemini-cli/pull/17902)
|
||||
- feat(core): Add GOOGLE_GENAI_API_VERSION environment variable support by deyim
|
||||
in [#16177](https://github.com/google-gemini/gemini-cli/pull/16177)
|
||||
- feat(core): Isolate and cleanup truncated tool outputs by SandyTao520 in
|
||||
[#17594](https://github.com/google-gemini/gemini-cli/pull/17594)
|
||||
- Create skills page, update commands, refine docs by g-samroberts in
|
||||
[#17842](https://github.com/google-gemini/gemini-cli/pull/17842)
|
||||
- feat: preserve EOL in files by Thomas-Shephard in
|
||||
[#16087](https://github.com/google-gemini/gemini-cli/pull/16087)
|
||||
- Fix HalfLinePaddedBox in screenreader mode. by jacob314 in
|
||||
[#17914](https://github.com/google-gemini/gemini-cli/pull/17914)
|
||||
- bug(ux) vim mode fixes. Start in insert mode. Fix bug blocking F12 and ctrl-X
|
||||
in vim mode. by jacob314 in
|
||||
[#17938](https://github.com/google-gemini/gemini-cli/pull/17938)
|
||||
- feat(core): implement interactive and non-interactive consent for OAuth by
|
||||
ehedlund in [#17699](https://github.com/google-gemini/gemini-cli/pull/17699)
|
||||
- perf(core): optimize token calculation and add support for multimodal tool
|
||||
responses by abhipatel12 in
|
||||
[#17835](https://github.com/google-gemini/gemini-cli/pull/17835)
|
||||
- refactor(hooks): remove legacy tools.enableHooks setting by abhipatel12 in
|
||||
[#17867](https://github.com/google-gemini/gemini-cli/pull/17867)
|
||||
- feat(ci): add npx smoke test to verify installability by bdmorgan in
|
||||
[#17927](https://github.com/google-gemini/gemini-cli/pull/17927)
|
||||
- feat(core): implement dynamic policy registration for subagents by abhipatel12
|
||||
in [#17838](https://github.com/google-gemini/gemini-cli/pull/17838)
|
||||
- feat: Implement background shell commands by galz10 in
|
||||
[#14849](https://github.com/google-gemini/gemini-cli/pull/14849)
|
||||
- feat(admin): provide actionable error messages for disabled features by
|
||||
skeshive in [#17815](https://github.com/google-gemini/gemini-cli/pull/17815)
|
||||
- Fix bugs where Rewind and Resume showed Ugly and 100X too verbose content. by
|
||||
jacob314 in [#17940](https://github.com/google-gemini/gemini-cli/pull/17940)
|
||||
- Fix broken link in docs by chrstnb in
|
||||
[#17959](https://github.com/google-gemini/gemini-cli/pull/17959)
|
||||
- feat(plan): reuse standard tool confirmation for AskUser tool by jerop in
|
||||
[#17864](https://github.com/google-gemini/gemini-cli/pull/17864)
|
||||
- feat(core): enable overriding CODE_ASSIST_API_VERSION with env var by
|
||||
lottielin in [#17942](https://github.com/google-gemini/gemini-cli/pull/17942)
|
||||
- run npx pointing to the specific commit SHA by sehoon38 in
|
||||
[#17970](https://github.com/google-gemini/gemini-cli/pull/17970)
|
||||
- Add allowedExtensions setting by kevinjwang1 in
|
||||
[#17695](https://github.com/google-gemini/gemini-cli/pull/17695)
|
||||
- feat(plan): refactor ToolConfirmationPayload to union type by jerop in
|
||||
[#17980](https://github.com/google-gemini/gemini-cli/pull/17980)
|
||||
- lower the default max retries to reduce contention by sehoon38 in
|
||||
[#17975](https://github.com/google-gemini/gemini-cli/pull/17975)
|
||||
- fix(core): ensure YOLO mode auto-approves complex shell commands when parsing
|
||||
fails by abhipatel12 in
|
||||
[#17920](https://github.com/google-gemini/gemini-cli/pull/17920)
|
||||
- Fix broken link. by g-samroberts in
|
||||
[#17972](https://github.com/google-gemini/gemini-cli/pull/17972)
|
||||
- Support ctrl-C and Ctrl-D correctly Refactor so InputPrompt has priority over
|
||||
AppContainer for input handling. by jacob314 in
|
||||
[#17993](https://github.com/google-gemini/gemini-cli/pull/17993)
|
||||
- Fix truncation for AskQuestion by jacob314 in
|
||||
[#18001](https://github.com/google-gemini/gemini-cli/pull/18001)
|
||||
- fix(workflow): update maintainer check logic to be inclusive and
|
||||
case-insensitive by bdmorgan in
|
||||
[#18009](https://github.com/google-gemini/gemini-cli/pull/18009)
|
||||
- Fix Esc cancel during streaming by LyalinDotCom in
|
||||
[#18039](https://github.com/google-gemini/gemini-cli/pull/18039)
|
||||
- feat(acp): add session resume support by bdmorgan in
|
||||
[#18043](https://github.com/google-gemini/gemini-cli/pull/18043)
|
||||
- fix(ci): prevent stale PR closer from incorrectly closing new PRs by bdmorgan
|
||||
in [#18069](https://github.com/google-gemini/gemini-cli/pull/18069)
|
||||
- chore: delete autoAccept setting unused in production by victorvianna in
|
||||
[#17862](https://github.com/google-gemini/gemini-cli/pull/17862)
|
||||
- feat(plan): use placeholder for choice question "Other" option by jerop in
|
||||
[#18101](https://github.com/google-gemini/gemini-cli/pull/18101)
|
||||
- docs: update clearContext to hookSpecificOutput by jackwotherspoon in
|
||||
[#18024](https://github.com/google-gemini/gemini-cli/pull/18024)
|
||||
- docs-writer skill: Update docs writer skill by jkcinouye in
|
||||
[#17928](https://github.com/google-gemini/gemini-cli/pull/17928)
|
||||
- Sehoon/oncall filter by sehoon38 in
|
||||
[#18105](https://github.com/google-gemini/gemini-cli/pull/18105)
|
||||
- feat(core): add setting to disable loop detection by SandyTao520 in
|
||||
[#18008](https://github.com/google-gemini/gemini-cli/pull/18008)
|
||||
- Docs: Revise docs/index.md by jkcinouye in
|
||||
[#17879](https://github.com/google-gemini/gemini-cli/pull/17879)
|
||||
- Fix up/down arrow regression and add test. by jacob314 in
|
||||
[#18108](https://github.com/google-gemini/gemini-cli/pull/18108)
|
||||
- fix(ui): prevent content leak in MaxSizedBox bottom overflow by jerop in
|
||||
[#17991](https://github.com/google-gemini/gemini-cli/pull/17991)
|
||||
- refactor: migrate checks.ts utility to core and deduplicate by jerop in
|
||||
[#18139](https://github.com/google-gemini/gemini-cli/pull/18139)
|
||||
- feat(core): implement tool name aliasing for backward compatibility by
|
||||
SandyTao520 in
|
||||
[#17974](https://github.com/google-gemini/gemini-cli/pull/17974)
|
||||
- docs: fix help-wanted label spelling by pavan-sh in
|
||||
[#18114](https://github.com/google-gemini/gemini-cli/pull/18114)
|
||||
- feat(cli): implement automatic theme switching based on terminal background by
|
||||
Abhijit-2592 in
|
||||
[#17976](https://github.com/google-gemini/gemini-cli/pull/17976)
|
||||
- fix(ide): no-op refactoring that moves the connection logic to helper
|
||||
functions by skeshive in
|
||||
[#18118](https://github.com/google-gemini/gemini-cli/pull/18118)
|
||||
- feat: update review-frontend-and-fix slash command to review-and-fix by galz10
|
||||
in [#18146](https://github.com/google-gemini/gemini-cli/pull/18146)
|
||||
- fix: improve Ctrl+R reverse search by jackwotherspoon in
|
||||
[#18075](https://github.com/google-gemini/gemini-cli/pull/18075)
|
||||
- feat(plan): handle inconsistency in schedulers by Adib234 in
|
||||
[#17813](https://github.com/google-gemini/gemini-cli/pull/17813)
|
||||
- feat(plan): add core logic and exit_plan_mode tool definition by jerop in
|
||||
[#18110](https://github.com/google-gemini/gemini-cli/pull/18110)
|
||||
- feat(core): rename search_file_content tool to grep_search and add legacy
|
||||
alias by SandyTao520 in
|
||||
[#18003](https://github.com/google-gemini/gemini-cli/pull/18003)
|
||||
- fix(core): prioritize detailed error messages for code assist setup by
|
||||
gsquared94 in [#17852](https://github.com/google-gemini/gemini-cli/pull/17852)
|
||||
- fix(cli): resolve environment loading and auth validation issues in ACP mode
|
||||
by bdmorgan in
|
||||
[#18025](https://github.com/google-gemini/gemini-cli/pull/18025)
|
||||
- feat(core): add .agents/skills directory alias for skill discovery by
|
||||
NTaylorMullen in
|
||||
[#18151](https://github.com/google-gemini/gemini-cli/pull/18151)
|
||||
- chore(core): reassign telemetry keys to avoid server conflict by mattKorwel in
|
||||
[#18161](https://github.com/google-gemini/gemini-cli/pull/18161)
|
||||
- Add link to rewind doc in commands.md by Adib234 in
|
||||
[#17961](https://github.com/google-gemini/gemini-cli/pull/17961)
|
||||
- feat(core): add draft-2020-12 JSON Schema support with lenient fallback by
|
||||
afarber in [#15060](https://github.com/google-gemini/gemini-cli/pull/15060)
|
||||
- refactor(core): robust trimPreservingTrailingNewline and regression test by
|
||||
adamfweidman in
|
||||
[#18196](https://github.com/google-gemini/gemini-cli/pull/18196)
|
||||
- Remove MCP servers on extension uninstall by chrstnb in
|
||||
[#18121](https://github.com/google-gemini/gemini-cli/pull/18121)
|
||||
- refactor: localize ACP error parsing logic to cli package by bdmorgan in
|
||||
[#18193](https://github.com/google-gemini/gemini-cli/pull/18193)
|
||||
- feat(core): Add A2A auth config types by adamfweidman in
|
||||
[#18205](https://github.com/google-gemini/gemini-cli/pull/18205)
|
||||
- Set default max attempts to 3 and use the common variable by sehoon38 in
|
||||
[#18209](https://github.com/google-gemini/gemini-cli/pull/18209)
|
||||
- feat(plan): add exit_plan_mode ui and prompt by jerop in
|
||||
[#18162](https://github.com/google-gemini/gemini-cli/pull/18162)
|
||||
- fix(test): improve test isolation and enable subagent evaluations by
|
||||
cocosheng-g in
|
||||
[#18138](https://github.com/google-gemini/gemini-cli/pull/18138)
|
||||
- feat(plan): use custom deny messages in plan mode policies by Adib234 in
|
||||
[#18195](https://github.com/google-gemini/gemini-cli/pull/18195)
|
||||
- Match on extension ID when stopping extensions by chrstnb in
|
||||
[#18218](https://github.com/google-gemini/gemini-cli/pull/18218)
|
||||
- fix(core): Respect user's .gitignore preference by xyrolle in
|
||||
[#15482](https://github.com/google-gemini/gemini-cli/pull/15482)
|
||||
- docs: document GEMINI_CLI_HOME environment variable by adamfweidman in
|
||||
[#18219](https://github.com/google-gemini/gemini-cli/pull/18219)
|
||||
- chore(core): explicitly state plan storage path in prompt by jerop in
|
||||
[#18222](https://github.com/google-gemini/gemini-cli/pull/18222)
|
||||
- A2a admin setting by DavidAPierce in
|
||||
[#17868](https://github.com/google-gemini/gemini-cli/pull/17868)
|
||||
- feat(a2a): Add pluggable auth provider infrastructure by adamfweidman in
|
||||
[#17934](https://github.com/google-gemini/gemini-cli/pull/17934)
|
||||
- Fix handling of empty settings by chrstnb in
|
||||
[#18131](https://github.com/google-gemini/gemini-cli/pull/18131)
|
||||
- Reload skills when extensions change by chrstnb in
|
||||
[#18225](https://github.com/google-gemini/gemini-cli/pull/18225)
|
||||
- feat: Add markdown rendering to ask_user tool by jackwotherspoon in
|
||||
[#18211](https://github.com/google-gemini/gemini-cli/pull/18211)
|
||||
- Add telemetry to rewind by Adib234 in
|
||||
[#18122](https://github.com/google-gemini/gemini-cli/pull/18122)
|
||||
- feat(admin): add support for MCP configuration via admin controls (pt1) by
|
||||
skeshive in [#18223](https://github.com/google-gemini/gemini-cli/pull/18223)
|
||||
- feat(core): require user consent before MCP server OAuth by ehedlund in
|
||||
[#18132](https://github.com/google-gemini/gemini-cli/pull/18132)
|
||||
- fix(sandbox): propagate GOOGLE_GEMINI_BASE_URL&GOOGLE_VERTEX_BASE_URL env vars
|
||||
by skeshive in
|
||||
[#18231](https://github.com/google-gemini/gemini-cli/pull/18231)
|
||||
- feat(ui): move user identity display to header by sehoon38 in
|
||||
[#18216](https://github.com/google-gemini/gemini-cli/pull/18216)
|
||||
- fix: enforce folder trust for workspace settings, skills, and context by
|
||||
galz10 in [#17596](https://github.com/google-gemini/gemini-cli/pull/17596)
|
||||
- remove fireAgent and beforeAgent hook by @ishaanxgupta in
|
||||
[#16919](https://github.com/google-gemini/gemini-cli/pull/16919)
|
||||
- Remove unused modelHooks and toolHooks by @ved015 in
|
||||
[#17115](https://github.com/google-gemini/gemini-cli/pull/17115)
|
||||
- feat(cli): sanitize ANSI escape sequences in non-interactive output by
|
||||
@sehoon38 in [#17172](https://github.com/google-gemini/gemini-cli/pull/17172)
|
||||
- Update Attempt text to Retry when showing the retry happening to the … by
|
||||
@sehoon38 in [#17178](https://github.com/google-gemini/gemini-cli/pull/17178)
|
||||
- chore(skills): update pr-creator skill workflow by @sehoon38 in
|
||||
[#17180](https://github.com/google-gemini/gemini-cli/pull/17180)
|
||||
- feat(cli): implement event-driven tool execution scheduler by @abhipatel12 in
|
||||
[#17078](https://github.com/google-gemini/gemini-cli/pull/17078)
|
||||
- chore(release): bump version to 0.27.0-nightly.20260121.97aac696f by
|
||||
@gemini-cli-robot in
|
||||
[#17181](https://github.com/google-gemini/gemini-cli/pull/17181)
|
||||
- Remove other rewind reference in docs by @chrstnb in
|
||||
[#17149](https://github.com/google-gemini/gemini-cli/pull/17149)
|
||||
- feat(skills): add code-reviewer skill by @sehoon38 in
|
||||
[#17187](httpshttps://github.com/google-gemini/gemini-cli/pull/17187)
|
||||
- feat(plan): Extend Shift+Tab Mode Cycling to include Plan Mode by @Adib234 in
|
||||
[#17177](https://github.com/google-gemini/gemini-cli/pull/17177)
|
||||
- feat(plan): refactor TestRig and eval helper to support configurable approval
|
||||
modes by @jerop in
|
||||
[#17171](https://github.com/google-gemini/gemini-cli/pull/17171)
|
||||
- feat(workflows): support recursive workstream labeling and new IDs by
|
||||
@bdmorgan in [#17207](https://github.com/google-gemini/gemini-cli/pull/17207)
|
||||
- Run evals for all models. by @gundermanc in
|
||||
[#17123](https://github.com/google-gemini/gemini-cli/pull/17123)
|
||||
- fix(github): improve label-workstream-rollup efficiency with GraphQL by
|
||||
@bdmorgan in [#17217](https://github.com/google-gemini/gemini-cli/pull/17217)
|
||||
- Docs: Update changelogs for v.0.25.0 and v0.26.0-preview.0 releases. by
|
||||
@g-samroberts in
|
||||
[#17215](https://github.com/google-gemini/gemini-cli/pull/17215)
|
||||
- Migrate beforeTool and afterTool hooks to hookSystem by @ved015 in
|
||||
[#17204](https://github.com/google-gemini/gemini-cli/pull/17204)
|
||||
- fix(github): improve label-workstream-rollup efficiency and fix bugs by
|
||||
@bdmorgan in [#17219](https://github.com/google-gemini/gemini-cli/pull/17219)
|
||||
- feat(cli): improve skill enablement/disablement verbiage by @NTaylorMullen in
|
||||
[#17192](https://github.com/google-gemini/gemini-cli/pull/17192)
|
||||
- fix(admin): Ensure CLI commands run in non-interactive mode by @skeshive in
|
||||
[#17218](https://github.com/google-gemini/gemini-cli/pull/17218)
|
||||
- feat(core): support dynamic variable substitution in system prompt override by
|
||||
@NTaylorMullen in
|
||||
[#17042](https://github.com/google-gemini/gemini-cli/pull/17042)
|
||||
- fix(core,cli): enable recursive directory access for by @galz10 in
|
||||
[#17094](https://github.com/google-gemini/gemini-cli/pull/17094)
|
||||
- Docs: Marking for experimental features by @jkcinouye in
|
||||
[#16760](https://github.com/google-gemini/gemini-cli/pull/16760)
|
||||
- Support command/ctrl/alt backspace correctly by @scidomino in
|
||||
[#17175](https://github.com/google-gemini/gemini-cli/pull/17175)
|
||||
- feat(plan): add approval mode instructions to system prompt by @jerop in
|
||||
[#17151](https://github.com/google-gemini/gemini-cli/pull/17151)
|
||||
- feat(core): enable disableLLMCorrection by default by @SandyTao520 in
|
||||
[#17223](https://github.com/google-gemini/gemini-cli/pull/17223)
|
||||
- Remove unused slug from sidebar by @chrstnb in
|
||||
[#17229](https://github.com/google-gemini/gemini-cli/pull/17229)
|
||||
- drain stdin on exit by @scidomino in
|
||||
[#17241](https://github.com/google-gemini/gemini-cli/pull/17241)
|
||||
- refactor(cli): decouple UI from live tool execution via ToolActionsContext by
|
||||
@abhipatel12 in
|
||||
[#17183](https://github.com/google-gemini/gemini-cli/pull/17183)
|
||||
- fix(core): update token count and telemetry on /chat resume history load by
|
||||
@psinha40898 in
|
||||
[#16279](https://github.com/google-gemini/gemini-cli/pull/16279)
|
||||
- fix: /policy to display policies according to mode by @ishaanxgupta in
|
||||
[#16772](https://github.com/google-gemini/gemini-cli/pull/16772)
|
||||
- fix(core): simplify replace tool error message by @SandyTao520 in
|
||||
[#17246](https://github.com/google-gemini/gemini-cli/pull/17246)
|
||||
- feat(cli): consolidate shell inactivity and redirection monitoring by
|
||||
@NTaylorMullen in
|
||||
[#17086](https://github.com/google-gemini/gemini-cli/pull/17086)
|
||||
- fix(scheduler): prevent stale tool re-publication and fix stuck UI state by
|
||||
@abhipatel12 in
|
||||
[#17227](https://github.com/google-gemini/gemini-cli/pull/17227)
|
||||
- feat(config): default enableEventDrivenScheduler to true by @abhipatel12 in
|
||||
[#17211](https://github.com/google-gemini/gemini-cli/pull/17211)
|
||||
- feat(hooks): enable hooks system by default by @abhipatel12 in
|
||||
[#17247](https://github.com/google-gemini/gemini-cli/pull/17247)
|
||||
- feat(core): Enable AgentRegistry to track all discovered subagents by
|
||||
@SandyTao520 in
|
||||
[#17253](https://github.com/google-gemini/gemini-cli/pull/17253)
|
||||
- feat(core): Have subagents use a JSON schema type for input. by @joshualitt in
|
||||
[#17152](https://github.com/google-gemini/gemini-cli/pull/17152)
|
||||
- feat: replace large text pastes with [Pasted Text: X lines] placeholder by
|
||||
@jackwotherspoon in
|
||||
[#16422](https://github.com/google-gemini/gemini-cli/pull/16422)
|
||||
- security(hooks): Wrap hook-injected context in distinct XML tags by @yunaseoul
|
||||
in [#17237](https://github.com/google-gemini/gemini-cli/pull/17237)
|
||||
- Enable the ability to queue specific nightly eval tests by @gundermanc in
|
||||
[#17262](https://github.com/google-gemini/gemini-cli/pull/17262)
|
||||
- docs(hooks): comprehensive update of hook documentation and specs by
|
||||
@abhipatel12 in
|
||||
[#16816](https://github.com/google-gemini/gemini-cli/pull/16816)
|
||||
- refactor: improve large text paste placeholder by @jacob314 in
|
||||
[#17269](https://github.com/google-gemini/gemini-cli/pull/17269)
|
||||
- feat: implement /rewind command by @Adib234 in
|
||||
[#15720](https://github.com/google-gemini/gemini-cli/pull/15720)
|
||||
- Feature/jetbrains ide detection by @SoLoHiC in
|
||||
[#16243](https://github.com/google-gemini/gemini-cli/pull/16243)
|
||||
- docs: update typo in mcp-server.md file by @schifferl in
|
||||
[#17099](https://github.com/google-gemini/gemini-cli/pull/17099)
|
||||
- Sanitize command names and descriptions by @ehedlund in
|
||||
[#17228](https://github.com/google-gemini/gemini-cli/pull/17228)
|
||||
- fix(auth): don't crash when initial auth fails by @skeshive in
|
||||
[#17308](https://github.com/google-gemini/gemini-cli/pull/17308)
|
||||
- Added image pasting capabilities for Wayland and X11 on Linux by @devr0306 in
|
||||
[#17144](https://github.com/google-gemini/gemini-cli/pull/17144)
|
||||
- feat: add AskUser tool schema by @jackwotherspoon in
|
||||
[#16988](https://github.com/google-gemini/gemini-cli/pull/16988)
|
||||
- fix cli settings: resolve layout jitter in settings bar by @Mag1ck in
|
||||
[#16256](https://github.com/google-gemini/gemini-cli/pull/16256)
|
||||
- fix: show whitespace changes in edit tool diffs by @Ujjiyara in
|
||||
[#17213](https://github.com/google-gemini/gemini-cli/pull/17213)
|
||||
- Remove redundant calls setting linuxClipboardTool. getUserLinuxClipboardTool()
|
||||
now handles the caching internally by @jacob314 in
|
||||
[#17320](https://github.com/google-gemini/gemini-cli/pull/17320)
|
||||
- ci: allow failure in evals-nightly run step by @gundermanc in
|
||||
[#17319](https://github.com/google-gemini/gemini-cli/pull/17319)
|
||||
- feat(cli): Add state management and plumbing for agent configuration dialog by
|
||||
@SandyTao520 in
|
||||
[#17259](https://github.com/google-gemini/gemini-cli/pull/17259)
|
||||
- bug: fix ide-client connection to ide-companion when inside docker via
|
||||
ssh/devcontainer by @kapsner in
|
||||
[#15049](https://github.com/google-gemini/gemini-cli/pull/15049)
|
||||
- Emit correct newline type return by @scidomino in
|
||||
[#17331](https://github.com/google-gemini/gemini-cli/pull/17331)
|
||||
- New skill: docs-writer by @g-samroberts in
|
||||
[#17268](https://github.com/google-gemini/gemini-cli/pull/17268)
|
||||
- fix(core): Resolve AbortSignal MaxListenersExceededWarning (#5950) by
|
||||
@spencer426 in
|
||||
[#16735](https://github.com/google-gemini/gemini-cli/pull/16735)
|
||||
- Disable tips after 10 runs by @Adib234 in
|
||||
[#17101](https://github.com/google-gemini/gemini-cli/pull/17101)
|
||||
- Fix so rewind starts at the bottom and loadHistory refreshes static content.
|
||||
by @jacob314 in
|
||||
[#17335](https://github.com/google-gemini/gemini-cli/pull/17335)
|
||||
- feat(core): Remove legacy settings. by @joshualitt in
|
||||
[#17244](https://github.com/google-gemini/gemini-cli/pull/17244)
|
||||
- feat(plan): add 'communicate' tool kind by @jerop in
|
||||
[#17341](https://github.com/google-gemini/gemini-cli/pull/17341)
|
||||
- feat(routing): A/B Test Numerical Complexity Scoring for Gemini 3 by
|
||||
@mattKorwel in
|
||||
[#16041](https://github.com/google-gemini/gemini-cli/pull/16041)
|
||||
- feat(plan): update UI Theme for Plan Mode by @Adib234 in
|
||||
[#17243](https://github.com/google-gemini/gemini-cli/pull/17243)
|
||||
- fix(ui): stabilize rendering during terminal resize in alternate buffer by
|
||||
@lkk214 in [#15783](https://github.com/google-gemini/gemini-cli/pull/15783)
|
||||
- feat(cli): add /agents config command and improve agent discovery by
|
||||
@SandyTao520 in
|
||||
[#17342](https://github.com/google-gemini/gemini-cli/pull/17342)
|
||||
- feat(mcp): add enable/disable commands for MCP servers (#11057) by @jasmeetsb
|
||||
in [#16299](https://github.com/google-gemini/gemini-cli/pull/16299)
|
||||
- fix(cli)!: Default to interactive mode for positional arguments by
|
||||
@ishaanxgupta in
|
||||
[#16329](https://github.com/google-gemini/gemini-cli/pull/16329)
|
||||
- Fix issue #17080 by @jacob314 in
|
||||
[#17100](https://github.com/google-gemini/gemini-cli/pull/17100)
|
||||
- feat(core): Refresh agents after loading an extension. by @joshualitt in
|
||||
[#17355](https://github.com/google-gemini/gemini-cli/pull/17355)
|
||||
- fix(cli): include source in policy rule display by @allenhutchison in
|
||||
[#17358](https://github.com/google-gemini/gemini-cli/pull/17358)
|
||||
- fix: remove obsolete CloudCode PerDay quota and 120s terminal threshold by
|
||||
@gsquared94 in
|
||||
[#17236](https://github.com/google-gemini/gemini-cli/pull/17236)
|
||||
- Refactor subagent delegation to be one tool per agent by @gundermanc in
|
||||
[#17346](https://github.com/google-gemini/gemini-cli/pull/17346)
|
||||
- fix(core): Include MCP server name in OAuth message by @jerop in
|
||||
[#17351](https://github.com/google-gemini/gemini-cli/pull/17351)
|
||||
- Fix pr-triage.sh script to update pull requests with tags "help wanted" and
|
||||
"maintainer only" by @jacob314 in
|
||||
[#17324](https://github.com/google-gemini/gemini-cli/pull/17324)
|
||||
- feat(plan): implement simple workflow for planning in main agent by @jerop in
|
||||
[#17326](https://github.com/google-gemini/gemini-cli/pull/17326)
|
||||
- fix: exit with non-zero code when esbuild is missing by @yuvrajangadsingh in
|
||||
[#16967](https://github.com/google-gemini/gemini-cli/pull/16967)
|
||||
- fix: ensure @docs/cli/custom-commands.md UI message ordering and test by
|
||||
@medic-code in
|
||||
[#12038](https://github.com/google-gemini/gemini-cli/pull/12038)
|
||||
- fix(core): add alternative command names for Antigravity editor detec… by
|
||||
@BaeSeokJae in
|
||||
[#16829](https://github.com/google-gemini/gemini-cli/pull/16829)
|
||||
- Refactor: Migrate CLI appEvents to Core coreEvents by @Adib234 in
|
||||
[#15737](https://github.com/google-gemini/gemini-cli/pull/15737)
|
||||
- fix(core): await MCP initialization in non-interactive mode by @Ratish1 in
|
||||
[#17390](https://github.com/google-gemini/gemini-cli/pull/17390)
|
||||
- Fix modifyOtherKeys enablement on unsupported terminals by @seekskyworld in
|
||||
[#16714](https://github.com/google-gemini/gemini-cli/pull/16714)
|
||||
- fix(core): gracefully handle disk full errors in chat recording by
|
||||
@godwiniheuwa in
|
||||
[#17305](https://github.com/google-gemini/gemini-cli/pull/17305)
|
||||
- fix(oauth): update oauth to use 127.0.0.1 instead of localhost by @skeshive in
|
||||
[#17388](https://github.com/google-gemini/gemini-cli/pull/17388)
|
||||
- fix(core): use RFC 9728 compliant path-based OAuth protected resource
|
||||
discovery by @vrv in
|
||||
[#15756](https://github.com/google-gemini/gemini-cli/pull/15756)
|
||||
- Update Code Wiki README badge by @PatoBeltran in
|
||||
[#15229](https://github.com/google-gemini/gemini-cli/pull/15229)
|
||||
- Add conda installation instructions for Gemini CLI by @ishaanxgupta in
|
||||
[#16921](https://github.com/google-gemini/gemini-cli/pull/16921)
|
||||
- chore(refactor): extract BaseSettingsDialog component by @SandyTao520 in
|
||||
[#17369](https://github.com/google-gemini/gemini-cli/pull/17369)
|
||||
- fix(cli): preserve input text when declining tool approval (#15624) by
|
||||
@ManojINaik in
|
||||
[#15659](https://github.com/google-gemini/gemini-cli/pull/15659)
|
||||
- chore: upgrade dep: diff 7.0.0-> 8.0.3 by @scidomino in
|
||||
[#17403](https://github.com/google-gemini/gemini-cli/pull/17403)
|
||||
- feat: add AskUserDialog for UI component of AskUser tool by @jackwotherspoon
|
||||
in [#17344](https://github.com/google-gemini/gemini-cli/pull/17344)
|
||||
- feat(ui): display user tier in about command by @sehoon38 in
|
||||
[#17400](https://github.com/google-gemini/gemini-cli/pull/17400)
|
||||
- feat: add clearContext to AfterAgent hooks by @jackwotherspoon in
|
||||
[#16574](https://github.com/google-gemini/gemini-cli/pull/16574)
|
||||
- fix(cli): change image paste location to global temp directory (#17396) by
|
||||
@devr0306 in [#17396](https://github.com/google-gemini/gemini-cli/pull/17396)
|
||||
- Fix line endings issue with Notice file by @scidomino in
|
||||
[#17417](https://github.com/google-gemini/gemini-cli/pull/17417)
|
||||
- feat(plan): implement persistent approvalMode setting by @Adib234 in
|
||||
[#17350](https://github.com/google-gemini/gemini-cli/pull/17350)
|
||||
- feat(ui): Move keyboard handling into BaseSettingsDialog by @SandyTao520 in
|
||||
[#17404](https://github.com/google-gemini/gemini-cli/pull/17404)
|
||||
- Allow prompt queueing during MCP initialization by @Adib234 in
|
||||
[#17395](https://github.com/google-gemini/gemini-cli/pull/17395)
|
||||
- feat: implement AgentConfigDialog for /agents config command by @SandyTao520
|
||||
in [#17370](https://github.com/google-gemini/gemini-cli/pull/17370)
|
||||
- fix(agents): default to all tools when tool list is omitted in subagents by
|
||||
@gundermanc in
|
||||
[#17422](https://github.com/google-gemini/gemini-cli/pull/17422)
|
||||
- feat(cli): Moves tool confirmations to a queue UX by @abhipatel12 in
|
||||
[#17276](https://github.com/google-gemini/gemini-cli/pull/17276)
|
||||
- fix(core): hide user tier name by @sehoon38 in
|
||||
[#17418](https://github.com/google-gemini/gemini-cli/pull/17418)
|
||||
- feat: Enforce unified folder trust for /directory add by @galz10 in
|
||||
[#17359](https://github.com/google-gemini/gemini-cli/pull/17359)
|
||||
- migrate fireToolNotificationHook to hookSystem by @ved015 in
|
||||
[#17398](https://github.com/google-gemini/gemini-cli/pull/17398)
|
||||
- Clean up dead code by @scidomino in
|
||||
[#17443](https://github.com/google-gemini/gemini-cli/pull/17443)
|
||||
- feat(workflow): add stale pull request closer with linked-issue enforcement by
|
||||
@bdmorgan in [#17449](https://github.com/google-gemini/gemini-cli/pull/17449)
|
||||
- feat(workflow): expand stale-exempt labels to include help wanted and Public
|
||||
Roadmap by @bdmorgan in
|
||||
[#17459](https://github.com/google-gemini/gemini-cli/pull/17459)
|
||||
- chore(workflow): remove redundant label-enforcer workflow by @bdmorgan in
|
||||
[#17460](https://github.com/google-gemini/gemini-cli/pull/17460)
|
||||
- Resolves the confusing error message `ripgrep exited with code null that
|
||||
occurs when a search operation is cancelled or aborted by @maximmasiutin in
|
||||
[#14267](https://github.com/google-gemini/gemini-cli/pull/14267)
|
||||
- fix: detect pnpm/pnpx in ~/.local by @rwakulszowa in
|
||||
[#15254](https://github.com/google-gemini/gemini-cli/pull/15254)
|
||||
- docs: Add instructions for MacPorts and uninstall instructions for Homebrew by
|
||||
@breun in [#17412](https://github.com/google-gemini/gemini-cli/pull/17412)
|
||||
- docs(hooks): clarify mandatory 'type' field and update hook schema
|
||||
documentation by @abhipatel12 in
|
||||
[#17499](https://github.com/google-gemini/gemini-cli/pull/17499)
|
||||
- Improve error messages on failed onboarding by @gsquared94 in
|
||||
[#17357](https://github.com/google-gemini/gemini-cli/pull/17357)
|
||||
- Follow up to "enableInteractiveShell for external tooling relying on a2a
|
||||
server" by @DavidAPierce in
|
||||
[#17130](https://github.com/google-gemini/gemini-cli/pull/17130)
|
||||
- Fix/issue 17070 by @alih552 in
|
||||
[#17242](https://github.com/google-gemini/gemini-cli/pull/17242)
|
||||
- fix(core): handle URI-encoded workspace paths in IdeClient by @dong-jun-shin
|
||||
in [#17476](https://github.com/google-gemini/gemini-cli/pull/17476)
|
||||
- feat(cli): add quick clear input shortcuts in vim mode by @harshanadim in
|
||||
[#17470](https://github.com/google-gemini/gemini-cli/pull/17470)
|
||||
- feat(core): optimize shell tool llmContent output format by @SandyTao520 in
|
||||
[#17538](https://github.com/google-gemini/gemini-cli/pull/17538)
|
||||
- Fix bug in detecting already added paths. by @jacob314 in
|
||||
[#17430](https://github.com/google-gemini/gemini-cli/pull/17430)
|
||||
- feat(scheduler): support multi-scheduler tool aggregation and nested call IDs
|
||||
by @abhipatel12 in
|
||||
[#17429](https://github.com/google-gemini/gemini-cli/pull/17429)
|
||||
- feat(agents): implement first-run experience for project-level sub-agents by
|
||||
@gundermanc in
|
||||
[#17266](https://github.com/google-gemini/gemini-cli/pull/17266)
|
||||
- Update extensions docs by @chrstnb in
|
||||
[#16093](https://github.com/google-gemini/gemini-cli/pull/16093)
|
||||
- Docs: Refactor left nav on the website by @jkcinouye in
|
||||
[#17558](https://github.com/google-gemini/gemini-cli/pull/17558)
|
||||
- fix(core): stream grep/ripgrep output to prevent OOM by @adamfweidman in
|
||||
[#17146](https://github.com/google-gemini/gemini-cli/pull/17146)
|
||||
- feat(plan): add persistent plan file storage by @jerop in
|
||||
[#17563](https://github.com/google-gemini/gemini-cli/pull/17563)
|
||||
- feat(agents): migrate subagents to event-driven scheduler by @abhipatel12 in
|
||||
[#17567](https://github.com/google-gemini/gemini-cli/pull/17567)
|
||||
- Fix extensions config error by @chrstnb in
|
||||
[#17580](https://github.com/google-gemini/gemini-cli/pull/17580)
|
||||
- fix(plan): remove subagent invocation from plan mode by @jerop in
|
||||
[#17593](https://github.com/google-gemini/gemini-cli/pull/17593)
|
||||
- feat(ui): add solid background color option for input prompt by @jacob314 in
|
||||
[#16563](https://github.com/google-gemini/gemini-cli/pull/16563)
|
||||
- feat(plan): refresh system prompt when approval mode changes (Shift+Tab) by
|
||||
@jerop in [#17585](https://github.com/google-gemini/gemini-cli/pull/17585)
|
||||
- feat(cli): add global setting to disable UI spinners by @galz10 in
|
||||
[#17234](https://github.com/google-gemini/gemini-cli/pull/17234)
|
||||
- fix(security): enforce strict policy directory permissions by @yunaseoul in
|
||||
[#17353](https://github.com/google-gemini/gemini-cli/pull/17353)
|
||||
- test(core): fix tests in windows by @scidomino in
|
||||
[#17592](https://github.com/google-gemini/gemini-cli/pull/17592)
|
||||
- feat(mcp/extensions): Allow users to selectively enable/disable MCP servers
|
||||
included in an extension( Issue #11057 & #17402) by @jasmeetsb in
|
||||
[#17434](https://github.com/google-gemini/gemini-cli/pull/17434)
|
||||
- Always map mac keys, even on other platforms by @scidomino in
|
||||
[#17618](https://github.com/google-gemini/gemini-cli/pull/17618)
|
||||
- Ctrl-O by @jacob314 in
|
||||
[#17617](https://github.com/google-gemini/gemini-cli/pull/17617)
|
||||
- feat(plan): update cycling order of approval modes by @Adib234 in
|
||||
[#17622](https://github.com/google-gemini/gemini-cli/pull/17622)
|
||||
- fix(cli): restore 'Modify with editor' option in external terminals by
|
||||
@abhipatel12 in
|
||||
[#17621](https://github.com/google-gemini/gemini-cli/pull/17621)
|
||||
- Slash command for helping in debugging by @gundermanc in
|
||||
[#17609](https://github.com/google-gemini/gemini-cli/pull/17609)
|
||||
- feat: add double-click to expand/collapse large paste placeholders by
|
||||
@jackwotherspoon in
|
||||
[#17471](https://github.com/google-gemini/gemini-cli/pull/17471)
|
||||
- refactor(cli): migrate non-interactive flow to event-driven scheduler by
|
||||
@abhipatel12 in
|
||||
[#17572](https://github.com/google-gemini/gemini-cli/pull/17572)
|
||||
- fix: loadcodeassist eligible tiers getting ignored for unlicensed users
|
||||
(regression) by @gsquared94 in
|
||||
[#17581](https://github.com/google-gemini/gemini-cli/pull/17581)
|
||||
- chore(core): delete legacy nonInteractiveToolExecutor by @abhipatel12 in
|
||||
[#17573](https://github.com/google-gemini/gemini-cli/pull/17573)
|
||||
- feat(core): enforce server prefixes for MCP tools in agent definitions by
|
||||
@abhipatel12 in
|
||||
[#17574](https://github.com/google-gemini/gemini-cli/pull/17574)
|
||||
- feat (mcp): Refresh MCP prompts on list changed notification by @MrLesk in
|
||||
[#14863](https://github.com/google-gemini/gemini-cli/pull/14863)
|
||||
- feat(ui): pretty JSON rendering tool outputs by @medic-code in
|
||||
[#9767](https://github.com/google-gemini/gemini-cli/pull/9767)
|
||||
- Fix iterm alternate buffer mode issue rendering backgrounds by @jacob314 in
|
||||
[#17634](https://github.com/google-gemini/gemini-cli/pull/17634)
|
||||
- feat(cli): add gemini extensions list --output-format=json by @AkihiroSuda in
|
||||
[#14479](https://github.com/google-gemini/gemini-cli/pull/14479)
|
||||
- fix(extensions): add .gitignore to extension templates by @godwiniheuwa in
|
||||
[#17293](https://github.com/google-gemini/gemini-cli/pull/17293)
|
||||
- paste transform followup by @jacob314 in
|
||||
[#17624](https://github.com/google-gemini/gemini-cli/pull/17624)
|
||||
- refactor: rename formatMemoryUsage to formatBytes by @Nubebuster in
|
||||
[#14997](https://github.com/google-gemini/gemini-cli/pull/14997)
|
||||
- chore: remove extra top margin from /hooks and /extensions by @jackwotherspoon
|
||||
in [#17663](https://github.com/google-gemini/gemini-cli/pull/17663)
|
||||
- feat(cli): add oncall command for issue triage by @sehoon38 in
|
||||
[#17661](https://github.com/google-gemini/gemini-cli/pull/17661)
|
||||
- Fix sidebar issue for extensions link by @chrstnb in
|
||||
[#17668](https://github.com/google-gemini/gemini-cli/pull/17668)
|
||||
- Change formatting to prevent UI redressing attacks by @scidomino in
|
||||
[#17611](https://github.com/google-gemini/gemini-cli/pull/17611)
|
||||
- Fix cluster of bugs in the settings dialog. by @jacob314 in
|
||||
[#17628](https://github.com/google-gemini/gemini-cli/pull/17628)
|
||||
- Update sidebar to resolve site build issues by @chrstnb in
|
||||
[#17674](https://github.com/google-gemini/gemini-cli/pull/17674)
|
||||
- fix(admin): fix a few bugs related to admin controls by @skeshive in
|
||||
[#17590](https://github.com/google-gemini/gemini-cli/pull/17590)
|
||||
- revert bad changes to tests by @scidomino in
|
||||
[#17673](https://github.com/google-gemini/gemini-cli/pull/17673)
|
||||
- feat(cli): show candidate issue state reason and duplicate status in triage by
|
||||
@sehoon38 in [#17676](https://github.com/google-gemini/gemini-cli/pull/17676)
|
||||
- Fix missing slash commands when Gemini CLI is in a project with a package.json
|
||||
that doesn't follow semantic versioning by @Adib234 in
|
||||
[#17561](https://github.com/google-gemini/gemini-cli/pull/17561)
|
||||
- feat(core): Model family-specific system prompts by @joshualitt in
|
||||
[#17614](https://github.com/google-gemini/gemini-cli/pull/17614)
|
||||
- Sub-agents documentation. by @gundermanc in
|
||||
[#16639](https://github.com/google-gemini/gemini-cli/pull/16639)
|
||||
- feat: wire up AskUserTool with dialog by @jackwotherspoon in
|
||||
[#17411](https://github.com/google-gemini/gemini-cli/pull/17411)
|
||||
- Load extension settings for hooks, agents, skills by @chrstnb in
|
||||
[#17245](https://github.com/google-gemini/gemini-cli/pull/17245)
|
||||
- Fix issue where Gemini CLI can make changes when simply asked a question by
|
||||
@gundermanc in
|
||||
[#17608](https://github.com/google-gemini/gemini-cli/pull/17608)
|
||||
- Update docs-writer skill for editing and add style guide for reference. by
|
||||
@g-samroberts in
|
||||
[#17669](https://github.com/google-gemini/gemini-cli/pull/17669)
|
||||
- fix(ux): have user message display a short path for pasted images by @devr0306
|
||||
in [#17613](https://github.com/google-gemini/gemini-cli/pull/17613)
|
||||
- feat(plan): enable AskUser tool in Plan mode for clarifying questions by
|
||||
@jerop in [#17694](https://github.com/google-gemini/gemini-cli/pull/17694)
|
||||
- GEMINI.md polish by @jacob314 in
|
||||
[#17680](https://github.com/google-gemini/gemini-cli/pull/17680)
|
||||
- refactor(core): centralize path validation and allow temp dir access for tools
|
||||
by @NTaylorMullen in
|
||||
[#17185](https://github.com/google-gemini/gemini-cli/pull/17185)
|
||||
- feat(skills): promote Agent Skills to stable by @abhipatel12 in
|
||||
[#17693](https://github.com/google-gemini/gemini-cli/pull/17693)
|
||||
- refactor(cli): keyboard handling and AskUserDialog by @jacob314 in
|
||||
[#17414](https://github.com/google-gemini/gemini-cli/pull/17414)
|
||||
- docs: Add Experimental Remote Agent Docs by @adamfweidman in
|
||||
[#17697](https://github.com/google-gemini/gemini-cli/pull/17697)
|
||||
- revert: promote Agent Skills to stable (#17693) by @abhipatel12 in
|
||||
[#17712](https://github.com/google-gemini/gemini-cli/pull/17712)
|
||||
- feat(ux) Expandable (ctrl-O) and scrollable approvals in alternate buffer
|
||||
mode. by @jacob314 in
|
||||
[#17640](https://github.com/google-gemini/gemini-cli/pull/17640)
|
||||
- feat(skills): promote skills settings to stable by @abhipatel12 in
|
||||
[#17713](https://github.com/google-gemini/gemini-cli/pull/17713)
|
||||
- fix(cli): Preserve settings dialog focus when searching by @SandyTao520 in
|
||||
[#17701](https://github.com/google-gemini/gemini-cli/pull/17701)
|
||||
- feat(ui): add terminal cursor support by @jacob314 in
|
||||
[#17711](https://github.com/google-gemini/gemini-cli/pull/17711)
|
||||
- docs(skills): remove experimental labels and update tutorials by @abhipatel12
|
||||
in [#17714](https://github.com/google-gemini/gemini-cli/pull/17714)
|
||||
- docs: remove 'experimental' syntax for hooks in docs by @abhipatel12 in
|
||||
[#17660](https://github.com/google-gemini/gemini-cli/pull/17660)
|
||||
- Add support for an additional exclusion file besides .gitignore and
|
||||
.geminiignore by @alisa-alisa in
|
||||
[#16487](https://github.com/google-gemini/gemini-cli/pull/16487)
|
||||
- feat: add review-frontend-and-fix command by @galz10 in
|
||||
[#17707](https://github.com/google-gemini/gemini-cli/pull/17707)
|
||||
|
||||
**Full changelog**:
|
||||
https://github.com/google-gemini/gemini-cli/compare/v0.27.0-preview.8...v0.28.0-preview.0
|
||||
https://github.com/google-gemini/gemini-cli/compare/v0.26.0-preview.5...v0.27.0-preview.0
|
||||
|
||||
@@ -35,6 +35,9 @@ and parameters.
|
||||
| `--model` | `-m` | string | `auto` | Model to use. See [Model Selection](#model-selection) for available values. |
|
||||
| `--prompt` | `-p` | string | - | Prompt text. Appended to stdin input if provided. **Deprecated:** Use positional arguments instead. |
|
||||
| `--prompt-interactive` | `-i` | string | - | Execute prompt and continue in interactive mode |
|
||||
| `--ralph-wiggum` | - | boolean | `false` | Enable Ralph Wiggum iterative loop mode |
|
||||
| `--completion-promise` | - | string | - | String to look for to signal completion in Ralph Wiggum mode |
|
||||
| `--max-iterations` | - | number | `10` | Maximum loop iterations for Ralph Wiggum mode |
|
||||
| `--sandbox` | `-s` | boolean | `false` | Run in a sandboxed environment for safer execution |
|
||||
| `--approval-mode` | - | string | `default` | Approval mode for tool execution. Choices: `default`, `auto_edit`, `yolo` |
|
||||
| `--yolo` | `-y` | boolean | `false` | **Deprecated.** Auto-approve all actions. Use `--approval-mode=yolo` instead. |
|
||||
|
||||
@@ -113,14 +113,10 @@ Slash commands provide meta-level control over the CLI itself.
|
||||
- **Description:** Lists all active extensions in the current Gemini CLI
|
||||
session. See [Gemini CLI Extensions](../extensions/index.md).
|
||||
|
||||
- **`/help`**
|
||||
- **`/help`** (or **`/?`**)
|
||||
- **Description:** Display help information about Gemini CLI, including
|
||||
available commands and their usage.
|
||||
|
||||
- **`/shortcuts`**
|
||||
- **Description:** Toggle the shortcuts panel above the input.
|
||||
- **Shortcut:** Press `?` when the prompt is empty.
|
||||
|
||||
- **`/hooks`**
|
||||
- **Description:** Manage hooks, which allow you to intercept and customize
|
||||
Gemini CLI behavior at specific lifecycle events.
|
||||
@@ -347,11 +343,11 @@ please see the dedicated [Custom Commands documentation](./custom-commands.md).
|
||||
These shortcuts apply directly to the input prompt for text manipulation.
|
||||
|
||||
- **Undo:**
|
||||
- **Keyboard shortcut:** Press **Alt+z** or **Cmd+z** to undo the last action
|
||||
- **Keyboard shortcut:** Press **Cmd+z** or **Alt+z** to undo the last action
|
||||
in the input prompt.
|
||||
|
||||
- **Redo:**
|
||||
- **Keyboard shortcut:** Press **Shift+Alt+Z** or **Shift+Cmd+Z** to redo the
|
||||
- **Keyboard shortcut:** Press **Shift+Cmd+Z** or **Shift+Alt+Z** to redo the
|
||||
last undone action in the input prompt.
|
||||
|
||||
## At commands (`@`)
|
||||
|
||||
@@ -23,8 +23,6 @@ overview of Gemini CLI, see the [main documentation page](../index.md).
|
||||
|
||||
## Advanced features
|
||||
|
||||
- **[Plan mode (experimental)](./plan-mode.md):** Use a safe, read-only mode for
|
||||
planning complex changes.
|
||||
- **[Checkpointing](./checkpointing.md):** Automatically save and restore
|
||||
snapshots of your session and files.
|
||||
- **[Enterprise configuration](./enterprise.md):** Deploy and manage Gemini CLI
|
||||
|
||||
@@ -106,17 +106,16 @@ available combinations.
|
||||
| Toggle YOLO (auto-approval) mode for tool calls. | `Ctrl + Y` |
|
||||
| Cycle through approval modes: default (prompt), auto_edit (auto-approve edits), and plan (read-only). | `Shift + Tab` |
|
||||
| Expand a height-constrained response to show additional lines when not in alternate buffer mode. | `Ctrl + O`<br />`Ctrl + S` |
|
||||
| Toggle current background shell visibility. | `Ctrl + B` |
|
||||
| Toggle background shell list. | `Ctrl + L` |
|
||||
| Kill the active background shell. | `Ctrl + K` |
|
||||
| Confirm selection in background shell list. | `Enter` |
|
||||
| Dismiss background shell list. | `Esc` |
|
||||
| Move focus from background shell to Gemini. | `Shift + Tab` |
|
||||
| Move focus from background shell list to Gemini. | `Tab (no Shift)` |
|
||||
| Show warning when trying to unfocus background shell via Tab. | `Tab (no Shift)` |
|
||||
| Show warning when trying to unfocus shell input via Tab. | `Tab (no Shift)` |
|
||||
| Move focus from Gemini to the active shell. | `Tab (no Shift)` |
|
||||
| Move focus from the shell back to Gemini. | `Shift + Tab` |
|
||||
| Ctrl+B | `Ctrl + B` |
|
||||
| Ctrl+L | `Ctrl + L` |
|
||||
| Ctrl+K | `Ctrl + K` |
|
||||
| Enter | `Enter` |
|
||||
| Esc | `Esc` |
|
||||
| Shift+Tab | `Shift + Tab` |
|
||||
| Tab | `Tab (no Shift)` |
|
||||
| Tab | `Tab (no Shift)` |
|
||||
| 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` |
|
||||
| Suspend the application (not yet implemented). | `Ctrl + Z` |
|
||||
@@ -128,9 +127,6 @@ available combinations.
|
||||
- `Option+B/F/M` (macOS only): Are interpreted as `Cmd+B/F/M` even if your
|
||||
terminal isn't configured to send Meta with Option.
|
||||
- `!` on an empty prompt: Enter or exit shell mode.
|
||||
- `?` on an empty prompt: Toggle the shortcuts panel above the input. Press
|
||||
`Esc`, `Backspace`, or any printable key to close it. Press `?` again to close
|
||||
the panel and insert a `?` into the prompt.
|
||||
- `\` (at end of a line) + `Enter`: Insert a newline without leaving single-line
|
||||
mode.
|
||||
- `Esc` pressed twice quickly: Clear the input prompt if it is not empty,
|
||||
|
||||
@@ -1,106 +0,0 @@
|
||||
# Plan Mode (experimental) <!-- omit in toc -->
|
||||
|
||||
Plan Mode is a safe, read-only mode for researching and designing complex
|
||||
changes. It prevents modifications while you research, design and plan an
|
||||
implementation strategy.
|
||||
|
||||
> **Note: Plan Mode is currently an experimental feature.**
|
||||
>
|
||||
> Experimental features are subject to change. To use Plan Mode, enable it via
|
||||
> `/settings` (search for `Plan`) or add the following to your `settings.json`:
|
||||
>
|
||||
> ```json
|
||||
> {
|
||||
> "experimental": {
|
||||
> "plan": true
|
||||
> }
|
||||
> }
|
||||
> ```
|
||||
>
|
||||
> Your feedback is invaluable as we refine this feature. If you have ideas,
|
||||
> suggestions, or encounter issues:
|
||||
>
|
||||
> - Use the `/bug` command within the CLI to file an issue.
|
||||
> - [Open an issue](https://github.com/google-gemini/gemini-cli/issues) on
|
||||
> GitHub.
|
||||
|
||||
- [Starting in Plan Mode](#starting-in-plan-mode)
|
||||
- [How to use Plan Mode](#how-to-use-plan-mode)
|
||||
- [Entering Plan Mode](#entering-plan-mode)
|
||||
- [The Planning Workflow](#the-planning-workflow)
|
||||
- [Exiting Plan Mode](#exiting-plan-mode)
|
||||
- [Tool Restrictions](#tool-restrictions)
|
||||
|
||||
## Starting in Plan Mode
|
||||
|
||||
You can configure Gemini CLI to start directly in Plan Mode by default:
|
||||
|
||||
1. Type `/settings` in the CLI.
|
||||
2. Search for `Approval Mode`.
|
||||
3. Set the value to `Plan`.
|
||||
|
||||
Other ways to start in Plan Mode:
|
||||
|
||||
- **CLI Flag:** `gemini --approval-mode=plan`
|
||||
- **Manual Settings:** Manually update your `settings.json`:
|
||||
|
||||
```json
|
||||
{
|
||||
"tools": {
|
||||
"approvalMode": "plan"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## How to use Plan Mode
|
||||
|
||||
### Entering Plan Mode
|
||||
|
||||
You can enter Plan Mode in three ways:
|
||||
|
||||
1. **Keyboard Shortcut:** Press `Shift+Tab` to cycle through approval modes
|
||||
(`Default` -> `Plan` -> `Auto-Edit`).
|
||||
2. **Command:** Type `/plan` in the input box.
|
||||
3. **Natural Language:** Ask the agent to "start a plan for...".
|
||||
|
||||
### The Planning Workflow
|
||||
|
||||
1. **Requirements:** The agent clarifies goals using `ask_user`.
|
||||
2. **Exploration:** The agent uses read-only tools (like [`read_file`]) to map
|
||||
the codebase and validate assumptions.
|
||||
3. **Planning:** A detailed plan is written to a temporary Markdown file.
|
||||
4. **Review:** You review the plan.
|
||||
- **Approve:** Exit Plan Mode and start implementation (switching to
|
||||
Auto-Edit or Default approval mode).
|
||||
- **Iterate:** Provide feedback to refine the plan.
|
||||
|
||||
### Exiting Plan Mode
|
||||
|
||||
To exit Plan Mode:
|
||||
|
||||
1. **Keyboard Shortcut:** Press `Shift+Tab` to cycle to the desired mode.
|
||||
1. **Tool:** The agent calls the `exit_plan_mode` tool to present the finalized
|
||||
plan for your approval.
|
||||
|
||||
## Tool Restrictions
|
||||
|
||||
Plan Mode enforces strict safety policies to prevent accidental changes.
|
||||
|
||||
These are the only allowed tools:
|
||||
|
||||
- **FileSystem (Read):** [`read_file`], [`list_directory`], [`glob`]
|
||||
- **Search:** [`grep_search`], [`google_web_search`]
|
||||
- **Interaction:** `ask_user`
|
||||
- **MCP Tools (Read):** Read-only [MCP tools] (e.g., `github_read_issue`,
|
||||
`postgres_read_schema`) are allowed.
|
||||
- **Planning (Write):** [`write_file`] and [`replace`] ONLY allowed for `.md`
|
||||
files in the `~/.gemini/tmp/<project>/plans/` directory.
|
||||
|
||||
[`list_directory`]: ../tools/file-system.md#1-list_directory-readfolder
|
||||
[`read_file`]: ../tools/file-system.md#2-read_file-readfile
|
||||
[`grep_search`]: ../tools/file-system.md#5-grep_search-searchtext
|
||||
[`write_file`]: ../tools/file-system.md#3-write_file-writefile
|
||||
[`glob`]: ../tools/file-system.md#4-glob-findfiles
|
||||
[`google_web_search`]: ../tools/web-search.md
|
||||
[`replace`]: ../tools/file-system.md#6-replace-edit
|
||||
[MCP tools]: ../tools/mcp-server.md
|
||||
+11
-8
@@ -22,13 +22,14 @@ they appear in the UI.
|
||||
|
||||
### General
|
||||
|
||||
| UI Label | Setting | Description | Default |
|
||||
| ------------------------ | ---------------------------------- | ------------------------------------------------------------- | ------- |
|
||||
| Vim Mode | `general.vimMode` | Enable Vim keybindings | `false` |
|
||||
| Enable Auto Update | `general.enableAutoUpdate` | Enable automatic updates. | `true` |
|
||||
| Enable Prompt Completion | `general.enablePromptCompletion` | Enable AI-powered prompt completion suggestions while typing. | `false` |
|
||||
| Debug Keystroke Logging | `general.debugKeystrokeLogging` | Enable debug logging of keystrokes to the console. | `false` |
|
||||
| Enable Session Cleanup | `general.sessionRetention.enabled` | Enable automatic session cleanup | `false` |
|
||||
| UI Label | Setting | Description | Default |
|
||||
| ------------------------------- | ---------------------------------- | ------------------------------------------------------------- | ------- |
|
||||
| Preview Features (e.g., models) | `general.previewFeatures` | Enable preview features (e.g., preview models). | `false` |
|
||||
| Vim Mode | `general.vimMode` | Enable Vim keybindings | `false` |
|
||||
| Enable Auto Update | `general.enableAutoUpdate` | Enable automatic updates. | `true` |
|
||||
| Enable Prompt Completion | `general.enablePromptCompletion` | Enable AI-powered prompt completion suggestions while typing. | `false` |
|
||||
| Debug Keystroke Logging | `general.debugKeystrokeLogging` | Enable debug logging of keystrokes to the console. | `false` |
|
||||
| Enable Session Cleanup | `general.sessionRetention.enabled` | Enable automatic session cleanup | `false` |
|
||||
|
||||
### Output
|
||||
|
||||
@@ -101,7 +102,9 @@ they appear in the UI.
|
||||
| Show Color | `tools.shell.showColor` | Show color in shell output. | `false` |
|
||||
| Approval Mode | `tools.approvalMode` | The default approval mode for tool execution. 'default' prompts for approval, 'auto_edit' auto-approves edit tools, and 'plan' is read-only mode. 'yolo' is not supported yet. | `"default"` |
|
||||
| Use Ripgrep | `tools.useRipgrep` | Use ripgrep for file content search instead of the fallback implementation. Provides faster search performance. | `true` |
|
||||
| Tool Output Truncation Threshold | `tools.truncateToolOutputThreshold` | Maximum characters to show when truncating large tool outputs. Set to 0 or negative to disable truncation. | `40000` |
|
||||
| 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. | `true` |
|
||||
|
||||
### Security
|
||||
|
||||
@@ -320,8 +320,6 @@ Captures startup configuration and user prompt submissions.
|
||||
|
||||
Tracks changes and duration of approval modes.
|
||||
|
||||
##### Lifecycle
|
||||
|
||||
- `approval_mode_switch`: Approval mode was changed.
|
||||
- **Attributes**:
|
||||
- `from_mode` (string)
|
||||
@@ -332,15 +330,6 @@ Tracks changes and duration of approval modes.
|
||||
- `mode` (string)
|
||||
- `duration_ms` (int)
|
||||
|
||||
##### Execution
|
||||
|
||||
These events track the execution of an approval mode, such as Plan Mode.
|
||||
|
||||
- `plan_execution`: A plan was executed and the session switched from plan mode
|
||||
to active execution.
|
||||
- **Attributes**:
|
||||
- `approval_mode` (string)
|
||||
|
||||
#### Tools
|
||||
|
||||
Captures tool executions, output truncation, and Edit behavior.
|
||||
@@ -721,17 +710,6 @@ Agent lifecycle metrics: runs, durations, and turns.
|
||||
- **Attributes**:
|
||||
- `agent_name` (string)
|
||||
|
||||
##### Approval Mode
|
||||
|
||||
###### Execution
|
||||
|
||||
These metrics track the adoption and usage of specific approval workflows, such
|
||||
as Plan Mode.
|
||||
|
||||
- `gemini_cli.plan.execution.count` (Counter, Int): Counts plan executions.
|
||||
- **Attributes**:
|
||||
- `approval_mode` (string)
|
||||
|
||||
##### UI
|
||||
|
||||
UI stability signals such as flicker count.
|
||||
|
||||
+13
-2
@@ -49,8 +49,19 @@ Gemini CLI comes with the following built-in sub-agents:
|
||||
dependencies.
|
||||
- **When to use:** "How does the authentication system work?", "Map out the
|
||||
dependencies of the `AgentRegistry` class."
|
||||
- **Configuration:** Enabled by default. You can configure it using agent
|
||||
overrides in `settings.json`.
|
||||
- **Configuration:** Enabled by default. You can configure it in
|
||||
`settings.json`. Example (forcing a specific model):
|
||||
```json
|
||||
{
|
||||
"experimental": {
|
||||
"codebaseInvestigatorSettings": {
|
||||
"enabled": true,
|
||||
"maxNumTurns": 20,
|
||||
"model": "gemini-2.5-pro"
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### CLI Help Agent
|
||||
|
||||
|
||||
@@ -98,6 +98,10 @@ their corresponding top-level category object in your `settings.json` file.
|
||||
|
||||
#### `general`
|
||||
|
||||
- **`general.previewFeatures`** (boolean):
|
||||
- **Description:** Enable preview features (e.g., preview models).
|
||||
- **Default:** `false`
|
||||
|
||||
- **`general.preferredEditor`** (string):
|
||||
- **Description:** The preferred editor to open files in.
|
||||
- **Default:** `undefined`
|
||||
@@ -716,10 +720,20 @@ their corresponding top-level category object in your `settings.json` file.
|
||||
implementation. Provides faster search performance.
|
||||
- **Default:** `true`
|
||||
|
||||
- **`tools.enableToolOutputTruncation`** (boolean):
|
||||
- **Description:** Enable truncation of large tool outputs.
|
||||
- **Default:** `true`
|
||||
- **Requires restart:** Yes
|
||||
|
||||
- **`tools.truncateToolOutputThreshold`** (number):
|
||||
- **Description:** Maximum characters to show when truncating large tool
|
||||
outputs. Set to 0 or negative to disable truncation.
|
||||
- **Default:** `40000`
|
||||
- **Description:** Truncate tool output if it is larger than this many
|
||||
characters. Set to -1 to disable.
|
||||
- **Default:** `4000000`
|
||||
- **Requires restart:** Yes
|
||||
|
||||
- **`tools.truncateToolOutputLines`** (number):
|
||||
- **Description:** The number of lines to keep when truncating tool output.
|
||||
- **Default:** `1000`
|
||||
- **Requires restart:** Yes
|
||||
|
||||
- **`tools.disableLLMCorrection`** (boolean):
|
||||
@@ -852,6 +866,11 @@ their corresponding top-level category object in your `settings.json` file.
|
||||
|
||||
- **`experimental.extensionConfig`** (boolean):
|
||||
- **Description:** Enable requesting and fetching of extension settings.
|
||||
- **Default:** `false`
|
||||
- **Requires restart:** Yes
|
||||
|
||||
- **`experimental.enableEventDrivenScheduler`** (boolean):
|
||||
- **Description:** Enables event-driven scheduler within the CLI session.
|
||||
- **Default:** `true`
|
||||
- **Requires restart:** Yes
|
||||
|
||||
@@ -976,10 +995,6 @@ their corresponding top-level category object in your `settings.json` file.
|
||||
- **Description:** If false, disallows MCP servers from being used.
|
||||
- **Default:** `true`
|
||||
|
||||
- **`admin.mcp.config`** (object):
|
||||
- **Description:** Admin-configured MCP servers.
|
||||
- **Default:** `{}`
|
||||
|
||||
- **`admin.skills.enabled`** (boolean):
|
||||
- **Description:** If false, disallows agent skills from being used.
|
||||
- **Default:** `true`
|
||||
|
||||
+4
-2
@@ -100,8 +100,10 @@ Connect Gemini CLI to external services and other development tools.
|
||||
the Model Context Protocol.
|
||||
- **[IDE integration](./ide-integration/index.md):** Use Gemini CLI alongside VS
|
||||
Code.
|
||||
- **[Hooks](./hooks/index.md):** Write scripts that run on specific CLI events.
|
||||
- **[Agent skills](./cli/skills.md):** Add specialized expertise and workflows.
|
||||
- **[Hooks](./hooks/index.md):** (Preview) Write scripts that run on specific
|
||||
CLI events.
|
||||
- **[Agent skills](./cli/skills.md):** (Preview) Add specialized expertise and
|
||||
workflows.
|
||||
- **[Sub-agents](./core/subagents.md):** (Preview) Delegate tasks to specialized
|
||||
agents.
|
||||
|
||||
|
||||
@@ -0,0 +1,141 @@
|
||||
# Ralph Wiggum mode
|
||||
|
||||
Ralph Wiggum mode is an iterative automation technique that lets Gemini CLI
|
||||
repeatedly execute a prompt until a specific goal is met. This mode is designed
|
||||
for tasks that benefit from persistent refinement, such as fixing failing tests
|
||||
or performing complex refactoring.
|
||||
|
||||
> **Note:** This is a preview feature currently under active development.
|
||||
|
||||
## Overview
|
||||
|
||||
Inspired by the "Ralph Wiggum" technique, this mode treats failures as data and
|
||||
uses a feedback loop to reach a successful state. When you enable Ralph Wiggum
|
||||
mode, Gemini CLI enters YOLO (auto-approval) mode and continues to process the
|
||||
provided prompt until it detects your specified completion string in the model's
|
||||
output or reaches the maximum number of iterations.
|
||||
|
||||
## Usage
|
||||
|
||||
To use Ralph Wiggum mode, you must provide a prompt using the `-p` or `--prompt`
|
||||
flag. You then configure the loop behavior using the following flags:
|
||||
|
||||
| Flag | Description |
|
||||
| :--------------------- | :--------------------------------------------------------- |
|
||||
| `--ralph-wiggum` | Enables the Ralph Wiggum iterative loop mode. |
|
||||
| `--completion-promise` | The string to look for in the output to signal completion. |
|
||||
| `--max-iterations` | The maximum number of times to run the loop (default: 10). |
|
||||
| `--memory-file` | Task-specific memory file (default: `memories.md`). |
|
||||
|
||||
### Example
|
||||
|
||||
The following command attempts to fix tests by running the loop up to 5 times
|
||||
until the string "TESTS PASSED" appears in the output, using a specific memory
|
||||
file for this task:
|
||||
|
||||
```bash
|
||||
gemini -p "Fix the tests in packages/core" \
|
||||
--ralph-wiggum \
|
||||
--completion-promise "TESTS PASSED" \
|
||||
--max-iterations 5 \
|
||||
--memory-file "fix-core-tests.md"
|
||||
```
|
||||
|
||||
## How it works
|
||||
|
||||
When you run Gemini CLI with the `--ralph-wiggum` flag, the following process
|
||||
occurs:
|
||||
|
||||
1. **Enforces YOLO mode:** The tool automatically sets the approval mode to
|
||||
`yolo`. This ensures that tool calls (like writing files or running shell
|
||||
commands) are approved automatically to allow the automation to proceed
|
||||
without human intervention.
|
||||
2. **Iterative execution:** The CLI executes the provided prompt in a loop.
|
||||
3. **Completion check:** After each iteration, the CLI scans the full text of
|
||||
the assistant's response for the string provided in `--completion-promise`.
|
||||
4. **Loop termination:**
|
||||
- If the completion string is found, the loop exits successfully.
|
||||
- If the completion string is not found, the CLI starts a new iteration
|
||||
using the same initial prompt.
|
||||
- If the number of iterations reaches the `--max-iterations` limit, the loop
|
||||
stops.
|
||||
|
||||
## Persistent context (Memories)
|
||||
|
||||
To help the agent learn from previous attempts, Ralph Wiggum mode uses a
|
||||
`memories.md` file in your current working directory.
|
||||
|
||||
- **Automatic creation:** If the file doesn't exist, the CLI creates it with a
|
||||
default header.
|
||||
- **Context injection:** At the start of each iteration, the content of
|
||||
`memories.md` is read and prepended to your prompt.
|
||||
- **Usage:** You (or the agent, via tool use) can write notes, error logs, or
|
||||
successful patterns into this file. This allows the agent to "remember" what
|
||||
failed in iteration 1 and avoid repeating the same mistake in iteration 2.
|
||||
|
||||
## Summary statistics
|
||||
|
||||
At the end of the execution, Ralph Wiggum mode provides a summary table in the
|
||||
terminal. This table details the performance of each iteration, including:
|
||||
|
||||
- **Iteration number:** The sequence of the run.
|
||||
- **Status:** Whether the iteration met the completion promise ("Success") or
|
||||
failed to do so ("Failed").
|
||||
- **Tests Passed/Failed:** If the output contains recognizable test runner
|
||||
patterns (such as those from Vitest, Jest, or Mocha), the CLI extracts and
|
||||
displays the number of passing and failing tests.
|
||||
|
||||
### Example summary table
|
||||
|
||||
```text
|
||||
--- Ralph Wiggum Mode Summary ---
|
||||
| Iteration | Status | Tests Passed | Tests Failed |
|
||||
|-----------|---------|--------------|--------------|
|
||||
| 1 | Failed | 2 | 10 |
|
||||
| 2 | Failed | 8 | 4 |
|
||||
| 3 | Success | 12 | 0 |
|
||||
---------------------------------
|
||||
```
|
||||
|
||||
## Best practices
|
||||
|
||||
To get the most out of Ralph Wiggum mode, we recommend the following:
|
||||
|
||||
- **Clear completion criteria:** Ensure your prompt instructs the model to emit
|
||||
a specific, unique string (like "ALL TESTS PASSED") only when the task is
|
||||
truly complete.
|
||||
- **Incremental goals:** Use prompts that encourage the model to make small,
|
||||
verifiable changes in each iteration.
|
||||
- **Safety nets:** Always set a reasonable `--max-iterations` limit to prevent
|
||||
unintended long-running processes.
|
||||
|
||||
## Development and rebuilding
|
||||
|
||||
If you're modifying Ralph Wiggum mode or enabling it in a development
|
||||
environment, you must recompile the TypeScript source code.
|
||||
|
||||
### Full rebuild
|
||||
|
||||
To build all packages in the monorepo, run the following command from the root
|
||||
directory:
|
||||
|
||||
```bash
|
||||
npm run build
|
||||
```
|
||||
|
||||
### Fast CLI rebuild
|
||||
|
||||
If you've already performed a full build and are only making changes to the CLI
|
||||
package, you can run a targeted build:
|
||||
|
||||
```bash
|
||||
npm run build -w @google/gemini-cli
|
||||
```
|
||||
|
||||
### Running in development
|
||||
|
||||
After rebuilding, test your changes using the `npm run start` script:
|
||||
|
||||
```bash
|
||||
npm run start -- -p "Your task" --ralph-wiggum --completion-promise "SUCCESS"
|
||||
```
|
||||
+9
-9
@@ -20,7 +20,6 @@
|
||||
{ "label": "Project context (GEMINI.md)", "slug": "docs/cli/gemini-md" },
|
||||
{ "label": "Shell commands", "slug": "docs/tools/shell" },
|
||||
{ "label": "Session management", "slug": "docs/cli/session-management" },
|
||||
{ "label": "Plan mode (experimental)", "slug": "docs/cli/plan-mode" },
|
||||
{ "label": "Todos", "slug": "docs/tools/todos" },
|
||||
{ "label": "Web search and fetch", "slug": "docs/tools/web-search" }
|
||||
]
|
||||
@@ -46,6 +45,7 @@
|
||||
{ "label": "Custom commands", "slug": "docs/cli/custom-commands" },
|
||||
{ "label": "Enterprise features", "slug": "docs/cli/enterprise" },
|
||||
{ "label": "Headless mode & scripting", "slug": "docs/cli/headless" },
|
||||
{ "label": "Ralph Wiggum mode", "slug": "docs/ralph-wiggum" },
|
||||
{ "label": "Sandboxing", "slug": "docs/cli/sandbox" },
|
||||
{ "label": "System prompt override", "slug": "docs/cli/system-prompt" },
|
||||
{ "label": "Telemetry", "slug": "docs/cli/telemetry" }
|
||||
@@ -124,6 +124,14 @@
|
||||
"items": [
|
||||
{ "label": "FAQ", "slug": "docs/faq" },
|
||||
{ "label": "Quota and pricing", "slug": "docs/quota-and-pricing" },
|
||||
{
|
||||
"label": "Releases",
|
||||
"items": [
|
||||
{ "label": "Release notes", "slug": "docs/changelogs/" },
|
||||
{ "label": "Stable release", "slug": "docs/changelogs/latest" },
|
||||
{ "label": "Preview release", "slug": "docs/changelogs/preview" }
|
||||
]
|
||||
},
|
||||
{ "label": "Terms and privacy", "slug": "docs/tos-privacy" },
|
||||
{ "label": "Troubleshooting", "slug": "docs/troubleshooting" },
|
||||
{ "label": "Uninstall", "slug": "docs/cli/uninstall" }
|
||||
@@ -141,13 +149,5 @@
|
||||
{ "label": "Local development", "slug": "docs/local-development" },
|
||||
{ "label": "NPM package structure", "slug": "docs/npm" }
|
||||
]
|
||||
},
|
||||
{
|
||||
"label": "Releases",
|
||||
"items": [
|
||||
{ "label": "Release notes", "slug": "docs/changelogs/" },
|
||||
{ "label": "Stable release", "slug": "docs/changelogs/latest" },
|
||||
{ "label": "Preview release", "slug": "docs/changelogs/preview" }
|
||||
]
|
||||
}
|
||||
]
|
||||
|
||||
@@ -1,128 +0,0 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2026 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { describe, expect } from 'vitest';
|
||||
import { ApprovalMode } from '@google/gemini-cli-core';
|
||||
import { evalTest } from './test-helper.js';
|
||||
import {
|
||||
assertModelHasOutput,
|
||||
checkModelOutputContent,
|
||||
} from './test-helper.js';
|
||||
|
||||
describe('plan_mode', () => {
|
||||
const TEST_PREFIX = 'Plan Mode: ';
|
||||
const settings = {
|
||||
experimental: { plan: true },
|
||||
};
|
||||
|
||||
evalTest('USUALLY_PASSES', {
|
||||
name: 'should refuse file modification when in plan mode',
|
||||
approvalMode: ApprovalMode.PLAN,
|
||||
params: {
|
||||
settings,
|
||||
},
|
||||
files: {
|
||||
'README.md': '# Original Content',
|
||||
},
|
||||
prompt: 'Please overwrite README.md with the text "Hello World"',
|
||||
assert: async (rig, result) => {
|
||||
await rig.waitForTelemetryReady();
|
||||
const toolLogs = rig.readToolLogs();
|
||||
|
||||
const writeTargets = toolLogs
|
||||
.filter((log) =>
|
||||
['write_file', 'replace'].includes(log.toolRequest.name),
|
||||
)
|
||||
.map((log) => {
|
||||
try {
|
||||
return JSON.parse(log.toolRequest.args).file_path;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
});
|
||||
|
||||
expect(
|
||||
writeTargets,
|
||||
'Should not attempt to modify README.md in plan mode',
|
||||
).not.toContain('README.md');
|
||||
|
||||
assertModelHasOutput(result);
|
||||
checkModelOutputContent(result, {
|
||||
expectedContent: [/plan mode|read-only|cannot modify|refuse|exiting/i],
|
||||
testName: `${TEST_PREFIX}should refuse file modification`,
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
evalTest('USUALLY_PASSES', {
|
||||
name: 'should enter plan mode when asked to create a plan',
|
||||
approvalMode: ApprovalMode.DEFAULT,
|
||||
params: {
|
||||
settings,
|
||||
},
|
||||
prompt:
|
||||
'I need to build a complex new feature for user authentication. Please create a detailed implementation plan.',
|
||||
assert: async (rig, result) => {
|
||||
const wasToolCalled = await rig.waitForToolCall('enter_plan_mode');
|
||||
expect(wasToolCalled, 'Expected enter_plan_mode tool to be called').toBe(
|
||||
true,
|
||||
);
|
||||
assertModelHasOutput(result);
|
||||
},
|
||||
});
|
||||
|
||||
evalTest('USUALLY_PASSES', {
|
||||
name: 'should exit plan mode when plan is complete and implementation is requested',
|
||||
approvalMode: ApprovalMode.PLAN,
|
||||
params: {
|
||||
settings,
|
||||
},
|
||||
files: {
|
||||
'plans/my-plan.md':
|
||||
'# My Implementation Plan\n\n1. Step one\n2. Step two',
|
||||
},
|
||||
prompt:
|
||||
'The plan in plans/my-plan.md is solid. Please proceed with the implementation.',
|
||||
assert: async (rig, result) => {
|
||||
const wasToolCalled = await rig.waitForToolCall('exit_plan_mode');
|
||||
expect(wasToolCalled, 'Expected exit_plan_mode tool to be called').toBe(
|
||||
true,
|
||||
);
|
||||
assertModelHasOutput(result);
|
||||
},
|
||||
});
|
||||
|
||||
evalTest('USUALLY_PASSES', {
|
||||
name: 'should allow file modification in plans directory when in plan mode',
|
||||
approvalMode: ApprovalMode.PLAN,
|
||||
params: {
|
||||
settings,
|
||||
},
|
||||
prompt: 'Create a plan for a new login feature.',
|
||||
assert: async (rig, result) => {
|
||||
await rig.waitForTelemetryReady();
|
||||
const toolLogs = rig.readToolLogs();
|
||||
|
||||
const writeCall = toolLogs.find(
|
||||
(log) => log.toolRequest.name === 'write_file',
|
||||
);
|
||||
|
||||
expect(
|
||||
writeCall,
|
||||
'Should attempt to modify a file in the plans directory when in plan mode',
|
||||
).toBeDefined();
|
||||
|
||||
if (writeCall) {
|
||||
const args = JSON.parse(writeCall.toolRequest.args);
|
||||
expect(args.file_path).toContain('.gemini/tmp');
|
||||
expect(args.file_path).toContain('/plans/');
|
||||
expect(args.file_path).toMatch(/\.md$/);
|
||||
}
|
||||
|
||||
assertModelHasOutput(result);
|
||||
},
|
||||
});
|
||||
});
|
||||
+7
-259
@@ -6,16 +6,11 @@
|
||||
|
||||
import { describe, expect } from 'vitest';
|
||||
import { evalTest } from './test-helper.js';
|
||||
import {
|
||||
assertModelHasOutput,
|
||||
checkModelOutputContent,
|
||||
} from '../integration-tests/test-helper.js';
|
||||
import { validateModelOutput } from '../integration-tests/test-helper.js';
|
||||
|
||||
describe('save_memory', () => {
|
||||
const TEST_PREFIX = 'Save memory test: ';
|
||||
const rememberingFavoriteColor = "Agent remembers user's favorite color";
|
||||
evalTest('ALWAYS_PASSES', {
|
||||
name: rememberingFavoriteColor,
|
||||
name: 'should be able to save to memory',
|
||||
params: {
|
||||
settings: { tools: { core: ['save_memory'] } },
|
||||
},
|
||||
@@ -23,260 +18,13 @@ describe('save_memory', () => {
|
||||
|
||||
what is my favorite color? tell me that and surround it with $ symbol`,
|
||||
assert: async (rig, result) => {
|
||||
const wasToolCalled = await rig.waitForToolCall('save_memory');
|
||||
expect(wasToolCalled, 'Expected save_memory tool to be called').toBe(
|
||||
true,
|
||||
);
|
||||
|
||||
assertModelHasOutput(result);
|
||||
checkModelOutputContent(result, {
|
||||
expectedContent: 'blue',
|
||||
testName: `${TEST_PREFIX}${rememberingFavoriteColor}`,
|
||||
});
|
||||
},
|
||||
});
|
||||
const rememberingCommandRestrictions = 'Agent remembers command restrictions';
|
||||
evalTest('ALWAYS_PASSES', {
|
||||
name: rememberingCommandRestrictions,
|
||||
params: {
|
||||
settings: { tools: { core: ['save_memory'] } },
|
||||
},
|
||||
prompt: `I don't want you to ever run npm commands.`,
|
||||
assert: async (rig, result) => {
|
||||
const wasToolCalled = await rig.waitForToolCall('save_memory');
|
||||
expect(wasToolCalled, 'Expected save_memory tool to be called').toBe(
|
||||
true,
|
||||
);
|
||||
|
||||
assertModelHasOutput(result);
|
||||
checkModelOutputContent(result, {
|
||||
expectedContent: [/not run npm commands|remember|ok/i],
|
||||
testName: `${TEST_PREFIX}${rememberingCommandRestrictions}`,
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
const rememberingWorkflow = 'Agent remembers workflow preferences';
|
||||
evalTest('ALWAYS_PASSES', {
|
||||
name: rememberingWorkflow,
|
||||
params: {
|
||||
settings: { tools: { core: ['save_memory'] } },
|
||||
},
|
||||
prompt: `I want you to always lint after building.`,
|
||||
assert: async (rig, result) => {
|
||||
const wasToolCalled = await rig.waitForToolCall('save_memory');
|
||||
expect(wasToolCalled, 'Expected save_memory tool to be called').toBe(
|
||||
true,
|
||||
);
|
||||
|
||||
assertModelHasOutput(result);
|
||||
checkModelOutputContent(result, {
|
||||
expectedContent: [/always|ok|remember|will do/i],
|
||||
testName: `${TEST_PREFIX}${rememberingWorkflow}`,
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
const ignoringTemporaryInformation =
|
||||
'Agent ignores temporary conversation details';
|
||||
evalTest('ALWAYS_PASSES', {
|
||||
name: ignoringTemporaryInformation,
|
||||
params: {
|
||||
settings: { tools: { core: ['save_memory'] } },
|
||||
},
|
||||
prompt: `I'm going to get a coffee.`,
|
||||
assert: async (rig, result) => {
|
||||
await rig.waitForTelemetryReady();
|
||||
const wasToolCalled = rig
|
||||
.readToolLogs()
|
||||
.some((log) => log.toolRequest.name === 'save_memory');
|
||||
const foundToolCall = await rig.waitForToolCall('save_memory');
|
||||
expect(
|
||||
wasToolCalled,
|
||||
'save_memory should not be called for temporary information',
|
||||
).toBe(false);
|
||||
foundToolCall,
|
||||
'Expected to find a save_memory tool call',
|
||||
).toBeTruthy();
|
||||
|
||||
assertModelHasOutput(result);
|
||||
checkModelOutputContent(result, {
|
||||
testName: `${TEST_PREFIX}${ignoringTemporaryInformation}`,
|
||||
forbiddenContent: [/remember|will do/i],
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
const rememberingPetName = "Agent remembers user's pet's name";
|
||||
evalTest('ALWAYS_PASSES', {
|
||||
name: rememberingPetName,
|
||||
params: {
|
||||
settings: { tools: { core: ['save_memory'] } },
|
||||
},
|
||||
prompt: `Please remember that my dog's name is Buddy.`,
|
||||
assert: async (rig, result) => {
|
||||
const wasToolCalled = await rig.waitForToolCall('save_memory');
|
||||
expect(wasToolCalled, 'Expected save_memory tool to be called').toBe(
|
||||
true,
|
||||
);
|
||||
|
||||
assertModelHasOutput(result);
|
||||
checkModelOutputContent(result, {
|
||||
expectedContent: [/Buddy/i],
|
||||
testName: `${TEST_PREFIX}${rememberingPetName}`,
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
const rememberingCommandAlias = 'Agent remembers custom command aliases';
|
||||
evalTest('ALWAYS_PASSES', {
|
||||
name: rememberingCommandAlias,
|
||||
params: {
|
||||
settings: { tools: { core: ['save_memory'] } },
|
||||
},
|
||||
prompt: `When I say 'start server', you should run 'npm run dev'.`,
|
||||
assert: async (rig, result) => {
|
||||
const wasToolCalled = await rig.waitForToolCall('save_memory');
|
||||
expect(wasToolCalled, 'Expected save_memory tool to be called').toBe(
|
||||
true,
|
||||
);
|
||||
|
||||
assertModelHasOutput(result);
|
||||
checkModelOutputContent(result, {
|
||||
expectedContent: [/npm run dev|start server|ok|remember|will do/i],
|
||||
testName: `${TEST_PREFIX}${rememberingCommandAlias}`,
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
const ignoringDbSchemaLocation =
|
||||
"Agent ignores workspace's database schema location";
|
||||
evalTest('ALWAYS_PASSES', {
|
||||
name: ignoringDbSchemaLocation,
|
||||
params: {
|
||||
settings: {
|
||||
tools: {
|
||||
core: [
|
||||
'save_memory',
|
||||
'list_directory',
|
||||
'read_file',
|
||||
'run_shell_command',
|
||||
],
|
||||
},
|
||||
},
|
||||
},
|
||||
prompt: `The database schema for this workspace is located in \`db/schema.sql\`.`,
|
||||
assert: async (rig, result) => {
|
||||
await rig.waitForTelemetryReady();
|
||||
const wasToolCalled = rig
|
||||
.readToolLogs()
|
||||
.some((log) => log.toolRequest.name === 'save_memory');
|
||||
expect(
|
||||
wasToolCalled,
|
||||
'save_memory should not be called for workspace-specific information',
|
||||
).toBe(false);
|
||||
|
||||
assertModelHasOutput(result);
|
||||
},
|
||||
});
|
||||
|
||||
const rememberingCodingStyle =
|
||||
"Agent remembers user's coding style preference";
|
||||
evalTest('ALWAYS_PASSES', {
|
||||
name: rememberingCodingStyle,
|
||||
params: {
|
||||
settings: { tools: { core: ['save_memory'] } },
|
||||
},
|
||||
prompt: `I prefer to use tabs instead of spaces for indentation.`,
|
||||
assert: async (rig, result) => {
|
||||
const wasToolCalled = await rig.waitForToolCall('save_memory');
|
||||
expect(wasToolCalled, 'Expected save_memory tool to be called').toBe(
|
||||
true,
|
||||
);
|
||||
|
||||
assertModelHasOutput(result);
|
||||
checkModelOutputContent(result, {
|
||||
expectedContent: [/tabs instead of spaces|ok|remember|will do/i],
|
||||
testName: `${TEST_PREFIX}${rememberingCodingStyle}`,
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
const ignoringBuildArtifactLocation =
|
||||
'Agent ignores workspace build artifact location';
|
||||
evalTest('ALWAYS_PASSES', {
|
||||
name: ignoringBuildArtifactLocation,
|
||||
params: {
|
||||
settings: {
|
||||
tools: {
|
||||
core: [
|
||||
'save_memory',
|
||||
'list_directory',
|
||||
'read_file',
|
||||
'run_shell_command',
|
||||
],
|
||||
},
|
||||
},
|
||||
},
|
||||
prompt: `In this workspace, build artifacts are stored in the \`dist/artifacts\` directory.`,
|
||||
assert: async (rig, result) => {
|
||||
await rig.waitForTelemetryReady();
|
||||
const wasToolCalled = rig
|
||||
.readToolLogs()
|
||||
.some((log) => log.toolRequest.name === 'save_memory');
|
||||
expect(
|
||||
wasToolCalled,
|
||||
'save_memory should not be called for workspace-specific information',
|
||||
).toBe(false);
|
||||
|
||||
assertModelHasOutput(result);
|
||||
},
|
||||
});
|
||||
|
||||
const ignoringMainEntryPoint = "Agent ignores workspace's main entry point";
|
||||
evalTest('ALWAYS_PASSES', {
|
||||
name: ignoringMainEntryPoint,
|
||||
params: {
|
||||
settings: {
|
||||
tools: {
|
||||
core: [
|
||||
'save_memory',
|
||||
'list_directory',
|
||||
'read_file',
|
||||
'run_shell_command',
|
||||
],
|
||||
},
|
||||
},
|
||||
},
|
||||
prompt: `The main entry point for this workspace is \`src/index.js\`.`,
|
||||
assert: async (rig, result) => {
|
||||
await rig.waitForTelemetryReady();
|
||||
const wasToolCalled = rig
|
||||
.readToolLogs()
|
||||
.some((log) => log.toolRequest.name === 'save_memory');
|
||||
expect(
|
||||
wasToolCalled,
|
||||
'save_memory should not be called for workspace-specific information',
|
||||
).toBe(false);
|
||||
|
||||
assertModelHasOutput(result);
|
||||
},
|
||||
});
|
||||
|
||||
const rememberingBirthday = "Agent remembers user's birthday";
|
||||
evalTest('ALWAYS_PASSES', {
|
||||
name: rememberingBirthday,
|
||||
params: {
|
||||
settings: { tools: { core: ['save_memory'] } },
|
||||
},
|
||||
prompt: `My birthday is on June 15th.`,
|
||||
assert: async (rig, result) => {
|
||||
const wasToolCalled = await rig.waitForToolCall('save_memory');
|
||||
expect(wasToolCalled, 'Expected save_memory tool to be called').toBe(
|
||||
true,
|
||||
);
|
||||
|
||||
assertModelHasOutput(result);
|
||||
checkModelOutputContent(result, {
|
||||
expectedContent: [/June 15th|ok|remember|will do/i],
|
||||
testName: `${TEST_PREFIX}${rememberingBirthday}`,
|
||||
});
|
||||
validateModelOutput(result, 'blue', 'Save memory test');
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
@@ -49,7 +49,7 @@ export function evalTest(policy: EvalPolicy, evalCase: EvalCase) {
|
||||
// bootstrap test projects.
|
||||
const rootNodeModules = path.join(process.cwd(), 'node_modules');
|
||||
const testNodeModules = path.join(rig.testDir || '', 'node_modules');
|
||||
if (fs.existsSync(rootNodeModules) && !fs.existsSync(testNodeModules)) {
|
||||
if (fs.existsSync(rootNodeModules)) {
|
||||
fs.symlinkSync(rootNodeModules, testNodeModules, 'dir');
|
||||
}
|
||||
|
||||
@@ -125,7 +125,7 @@ export function evalTest(policy: EvalPolicy, evalCase: EvalCase) {
|
||||
approvalMode: evalCase.approvalMode ?? 'yolo',
|
||||
timeout: evalCase.timeout,
|
||||
env: {
|
||||
GEMINI_CLI_ACTIVITY_LOG_TARGET: activityLogFile,
|
||||
GEMINI_CLI_ACTIVITY_LOG_FILE: activityLogFile,
|
||||
},
|
||||
});
|
||||
|
||||
@@ -162,7 +162,7 @@ export function evalTest(policy: EvalPolicy, evalCase: EvalCase) {
|
||||
if (policy === 'USUALLY_PASSES' && !process.env['RUN_EVALS']) {
|
||||
it.skip(evalCase.name, fn);
|
||||
} else {
|
||||
it(evalCase.name, fn, evalCase.timeout);
|
||||
it(evalCase.name, fn);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,85 +0,0 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2026 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { describe, expect } from 'vitest';
|
||||
import { evalTest } from './test-helper.js';
|
||||
|
||||
describe('validation_fidelity', () => {
|
||||
evalTest('ALWAYS_PASSES', {
|
||||
name: 'should perform exhaustive validation autonomously when guided by system instructions',
|
||||
files: {
|
||||
'src/types.ts': `
|
||||
export interface LogEntry {
|
||||
level: 'info' | 'warn' | 'error';
|
||||
message: string;
|
||||
}
|
||||
`,
|
||||
'src/logger.ts': `
|
||||
import { LogEntry } from './types.js';
|
||||
|
||||
export function formatLog(entry: LogEntry): string {
|
||||
return \`[\${entry.level.toUpperCase()}] \${entry.message}\`;
|
||||
}
|
||||
`,
|
||||
'src/logger.test.ts': `
|
||||
import { expect, test } from 'vitest';
|
||||
import { formatLog } from './logger.js';
|
||||
import { LogEntry } from './types.js';
|
||||
|
||||
test('formats log correctly', () => {
|
||||
const entry: LogEntry = { level: 'info', message: 'test message' };
|
||||
expect(formatLog(entry)).toBe('[INFO] test message');
|
||||
});
|
||||
`,
|
||||
'package.json': JSON.stringify({
|
||||
name: 'test-project',
|
||||
type: 'module',
|
||||
scripts: {
|
||||
test: 'vitest run',
|
||||
build: 'tsc --noEmit',
|
||||
},
|
||||
}),
|
||||
'tsconfig.json': JSON.stringify({
|
||||
compilerOptions: {
|
||||
target: 'ESNext',
|
||||
module: 'ESNext',
|
||||
moduleResolution: 'node',
|
||||
strict: true,
|
||||
esModuleInterop: true,
|
||||
skipLibCheck: true,
|
||||
forceConsistentCasingInFileNames: true,
|
||||
},
|
||||
}),
|
||||
},
|
||||
prompt:
|
||||
"Refactor the 'LogEntry' interface in 'src/types.ts' to rename the 'message' field to 'payload'.",
|
||||
timeout: 600000,
|
||||
assert: async (rig) => {
|
||||
// The goal of this eval is to see if the agent realizes it needs to update usages
|
||||
// AND run 'npm run build' or 'tsc' autonomously to ensure project-wide structural integrity.
|
||||
|
||||
const toolLogs = rig.readToolLogs();
|
||||
const shellCalls = toolLogs.filter(
|
||||
(log) => log.toolRequest.name === 'run_shell_command',
|
||||
);
|
||||
|
||||
const hasBuildOrTsc = shellCalls.some((log) => {
|
||||
const cmd = JSON.parse(log.toolRequest.args).command.toLowerCase();
|
||||
return (
|
||||
cmd.includes('npm run build') ||
|
||||
cmd.includes('tsc') ||
|
||||
cmd.includes('typecheck') ||
|
||||
cmd.includes('npm run verify')
|
||||
);
|
||||
});
|
||||
|
||||
expect(
|
||||
hasBuildOrTsc,
|
||||
'Expected the agent to autonomously run a build or type-check command to verify the refactoring',
|
||||
).toBe(true);
|
||||
},
|
||||
});
|
||||
});
|
||||
@@ -1,79 +0,0 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2026 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { describe, expect } from 'vitest';
|
||||
import { evalTest } from './test-helper.js';
|
||||
|
||||
describe('validation_fidelity_pre_existing_errors', () => {
|
||||
evalTest('USUALLY_PASSES', {
|
||||
name: 'should handle pre-existing project errors gracefully during validation',
|
||||
files: {
|
||||
'src/math.ts': `
|
||||
export function add(a: number, b: number): number {
|
||||
return a + b;
|
||||
}
|
||||
`,
|
||||
'src/index.ts': `
|
||||
import { add } from './math.js';
|
||||
console.log(add(1, 2));
|
||||
`,
|
||||
'src/utils.ts': `
|
||||
export function multiply(a: number, b: number): number {
|
||||
return a * c; // 'c' is not defined - PRE-EXISTING ERROR
|
||||
}
|
||||
`,
|
||||
'package.json': JSON.stringify({
|
||||
name: 'test-project',
|
||||
type: 'module',
|
||||
scripts: {
|
||||
test: 'vitest run',
|
||||
build: 'tsc --noEmit',
|
||||
},
|
||||
}),
|
||||
'tsconfig.json': JSON.stringify({
|
||||
compilerOptions: {
|
||||
target: 'ESNext',
|
||||
module: 'ESNext',
|
||||
moduleResolution: 'node',
|
||||
strict: true,
|
||||
esModuleInterop: true,
|
||||
skipLibCheck: true,
|
||||
forceConsistentCasingInFileNames: true,
|
||||
},
|
||||
}),
|
||||
},
|
||||
prompt: "In src/math.ts, rename the 'add' function to 'sum'.",
|
||||
timeout: 600000,
|
||||
assert: async (rig) => {
|
||||
const toolLogs = rig.readToolLogs();
|
||||
const replaceCalls = toolLogs.filter(
|
||||
(log) => log.toolRequest.name === 'replace',
|
||||
);
|
||||
|
||||
// Verify it did the work in math.ts
|
||||
const mathRefactor = replaceCalls.some((log) => {
|
||||
const args = JSON.parse(log.toolRequest.args);
|
||||
return (
|
||||
args.file_path.endsWith('src/math.ts') &&
|
||||
args.new_string.includes('sum')
|
||||
);
|
||||
});
|
||||
expect(mathRefactor, 'Agent should have refactored math.ts').toBe(true);
|
||||
|
||||
const shellCalls = toolLogs.filter(
|
||||
(log) => log.toolRequest.name === 'run_shell_command',
|
||||
);
|
||||
const ranValidation = shellCalls.some((log) => {
|
||||
const cmd = JSON.parse(log.toolRequest.args).command.toLowerCase();
|
||||
return cmd.includes('build') || cmd.includes('tsc');
|
||||
});
|
||||
|
||||
expect(ranValidation, 'Agent should have attempted validation').toBe(
|
||||
true,
|
||||
);
|
||||
},
|
||||
});
|
||||
});
|
||||
@@ -7,12 +7,7 @@
|
||||
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
|
||||
import { existsSync } from 'node:fs';
|
||||
import * as path from 'node:path';
|
||||
import {
|
||||
TestRig,
|
||||
printDebugInfo,
|
||||
assertModelHasOutput,
|
||||
checkModelOutputContent,
|
||||
} from './test-helper.js';
|
||||
import { TestRig, printDebugInfo, validateModelOutput } from './test-helper.js';
|
||||
|
||||
describe('file-system', () => {
|
||||
let rig: TestRig;
|
||||
@@ -48,11 +43,8 @@ describe('file-system', () => {
|
||||
'Expected to find a read_file tool call',
|
||||
).toBeTruthy();
|
||||
|
||||
assertModelHasOutput(result);
|
||||
checkModelOutputContent(result, {
|
||||
expectedContent: 'hello world',
|
||||
testName: 'File read test',
|
||||
});
|
||||
// Validate model output - will throw if no output, warn if missing expected content
|
||||
validateModelOutput(result, 'hello world', 'File read test');
|
||||
});
|
||||
|
||||
it('should be able to write a file', async () => {
|
||||
@@ -82,8 +74,8 @@ describe('file-system', () => {
|
||||
'Expected to find a write_file, edit, or replace tool call',
|
||||
).toBeTruthy();
|
||||
|
||||
assertModelHasOutput(result);
|
||||
checkModelOutputContent(result, { testName: 'File write test' });
|
||||
// Validate model output - will throw if no output
|
||||
validateModelOutput(result, null, 'File write test');
|
||||
|
||||
const fileContent = rig.readFile('test.txt');
|
||||
|
||||
|
||||
@@ -6,12 +6,7 @@
|
||||
|
||||
import { WEB_SEARCH_TOOL_NAME } from '../packages/core/src/tools/tool-names.js';
|
||||
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
|
||||
import {
|
||||
TestRig,
|
||||
printDebugInfo,
|
||||
assertModelHasOutput,
|
||||
checkModelOutputContent,
|
||||
} from './test-helper.js';
|
||||
import { TestRig, printDebugInfo, validateModelOutput } from './test-helper.js';
|
||||
|
||||
describe('web search tool', () => {
|
||||
let rig: TestRig;
|
||||
@@ -73,11 +68,12 @@ describe('web search tool', () => {
|
||||
`Expected to find a call to ${WEB_SEARCH_TOOL_NAME}`,
|
||||
).toBeTruthy();
|
||||
|
||||
assertModelHasOutput(result);
|
||||
const hasExpectedContent = checkModelOutputContent(result, {
|
||||
expectedContent: ['weather', 'london'],
|
||||
testName: 'Google web search test',
|
||||
});
|
||||
// Validate model output - will throw if no output, warn if missing expected content
|
||||
const hasExpectedContent = validateModelOutput(
|
||||
result,
|
||||
['weather', 'london'],
|
||||
'Google web search test',
|
||||
);
|
||||
|
||||
// If content was missing, log the search queries used
|
||||
if (!hasExpectedContent) {
|
||||
|
||||
@@ -9,8 +9,7 @@ import {
|
||||
TestRig,
|
||||
poll,
|
||||
printDebugInfo,
|
||||
assertModelHasOutput,
|
||||
checkModelOutputContent,
|
||||
validateModelOutput,
|
||||
} from './test-helper.js';
|
||||
import { existsSync } from 'node:fs';
|
||||
import { join } from 'node:path';
|
||||
@@ -69,10 +68,7 @@ describe('list_directory', () => {
|
||||
throw e;
|
||||
}
|
||||
|
||||
assertModelHasOutput(result);
|
||||
checkModelOutputContent(result, {
|
||||
expectedContent: ['file1.txt', 'subdir'],
|
||||
testName: 'List directory test',
|
||||
});
|
||||
// Validate model output - will throw if no output, warn if missing expected content
|
||||
validateModelOutput(result, ['file1.txt', 'subdir'], 'List directory test');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -5,12 +5,7 @@
|
||||
*/
|
||||
|
||||
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
|
||||
import {
|
||||
TestRig,
|
||||
printDebugInfo,
|
||||
assertModelHasOutput,
|
||||
checkModelOutputContent,
|
||||
} from './test-helper.js';
|
||||
import { TestRig, printDebugInfo, validateModelOutput } from './test-helper.js';
|
||||
|
||||
describe('read_many_files', () => {
|
||||
let rig: TestRig;
|
||||
@@ -55,7 +50,7 @@ describe('read_many_files', () => {
|
||||
'Expected to find either read_many_files or multiple read_file tool calls',
|
||||
).toBeTruthy();
|
||||
|
||||
assertModelHasOutput(result);
|
||||
checkModelOutputContent(result, { testName: 'Read many files test' });
|
||||
// Validate model output - will throw if no output
|
||||
validateModelOutput(result, null, 'Read many files test');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1 +0,0 @@
|
||||
{"method":"generateContentStream","response":[{"candidates":[{"content":{"parts":[{"text":"Session started."}],"role":"model"},"finishReason":"STOP","index":0}]}]}
|
||||
@@ -1,42 +0,0 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
|
||||
import { TestRig } from './test-helper.js';
|
||||
import * as path from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||
|
||||
describe('resume-repro', () => {
|
||||
let rig: TestRig;
|
||||
|
||||
beforeEach(() => {
|
||||
rig = new TestRig();
|
||||
});
|
||||
|
||||
afterEach(async () => await rig.cleanup());
|
||||
|
||||
it('should be able to resume a session without "Storage must be initialized before use"', async () => {
|
||||
const responsesPath = path.join(__dirname, 'resume_repro.responses');
|
||||
await rig.setup('should be able to resume a session', {
|
||||
fakeResponsesPath: responsesPath,
|
||||
});
|
||||
|
||||
// 1. First run to create a session
|
||||
await rig.run({
|
||||
args: 'hello',
|
||||
});
|
||||
|
||||
// 2. Second run with --resume latest
|
||||
// This should NOT fail with "Storage must be initialized before use"
|
||||
const result = await rig.run({
|
||||
args: ['--resume', 'latest', 'continue'],
|
||||
});
|
||||
|
||||
expect(result).toContain('Session started');
|
||||
});
|
||||
});
|
||||
@@ -5,12 +5,7 @@
|
||||
*/
|
||||
|
||||
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
|
||||
import {
|
||||
TestRig,
|
||||
printDebugInfo,
|
||||
assertModelHasOutput,
|
||||
checkModelOutputContent,
|
||||
} from './test-helper.js';
|
||||
import { TestRig, printDebugInfo, validateModelOutput } from './test-helper.js';
|
||||
import { getShellConfiguration } from '../packages/core/src/utils/shell-utils.js';
|
||||
|
||||
const { shell } = getShellConfiguration();
|
||||
@@ -120,11 +115,13 @@ describe('run_shell_command', () => {
|
||||
'Expected to find a run_shell_command tool call',
|
||||
).toBeTruthy();
|
||||
|
||||
assertModelHasOutput(result);
|
||||
checkModelOutputContent(result, {
|
||||
expectedContent: ['hello-world', 'exit code 0'],
|
||||
testName: 'Shell command test',
|
||||
});
|
||||
// Validate model output - will throw if no output, warn if missing expected content
|
||||
// Model often reports exit code instead of showing output
|
||||
validateModelOutput(
|
||||
result,
|
||||
['hello-world', 'exit code 0'],
|
||||
'Shell command test',
|
||||
);
|
||||
});
|
||||
|
||||
it('should be able to run a shell command via stdin', async () => {
|
||||
@@ -152,11 +149,8 @@ describe('run_shell_command', () => {
|
||||
'Expected to find a run_shell_command tool call',
|
||||
).toBeTruthy();
|
||||
|
||||
assertModelHasOutput(result);
|
||||
checkModelOutputContent(result, {
|
||||
expectedContent: 'test-stdin',
|
||||
testName: 'Shell command stdin test',
|
||||
});
|
||||
// Validate model output - will throw if no output, warn if missing expected content
|
||||
validateModelOutput(result, 'test-stdin', 'Shell command stdin test');
|
||||
});
|
||||
|
||||
it.skip('should run allowed sub-command in non-interactive mode', async () => {
|
||||
@@ -500,11 +494,12 @@ describe('run_shell_command', () => {
|
||||
)[0];
|
||||
expect(toolCall.toolRequest.success).toBe(true);
|
||||
|
||||
assertModelHasOutput(result);
|
||||
checkModelOutputContent(result, {
|
||||
expectedContent: 'test-allow-all',
|
||||
testName: 'Shell command stdin allow all',
|
||||
});
|
||||
// Validate model output - will throw if no output, warn if missing expected content
|
||||
validateModelOutput(
|
||||
result,
|
||||
'test-allow-all',
|
||||
'Shell command stdin allow all',
|
||||
);
|
||||
});
|
||||
|
||||
it('should propagate environment variables to the child process', async () => {
|
||||
@@ -533,11 +528,7 @@ describe('run_shell_command', () => {
|
||||
foundToolCall,
|
||||
'Expected to find a run_shell_command tool call',
|
||||
).toBeTruthy();
|
||||
assertModelHasOutput(result);
|
||||
checkModelOutputContent(result, {
|
||||
expectedContent: varValue,
|
||||
testName: 'Env var propagation test',
|
||||
});
|
||||
validateModelOutput(result, varValue, 'Env var propagation test');
|
||||
expect(result).toContain(varValue);
|
||||
} finally {
|
||||
delete process.env[varName];
|
||||
@@ -567,11 +558,7 @@ describe('run_shell_command', () => {
|
||||
'Expected to find a run_shell_command tool call',
|
||||
).toBeTruthy();
|
||||
|
||||
assertModelHasOutput(result);
|
||||
checkModelOutputContent(result, {
|
||||
expectedContent: fileName,
|
||||
testName: 'Platform-specific listing test',
|
||||
});
|
||||
validateModelOutput(result, fileName, 'Platform-specific listing test');
|
||||
expect(result).toContain(fileName);
|
||||
});
|
||||
|
||||
|
||||
@@ -11,12 +11,7 @@
|
||||
*/
|
||||
|
||||
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
|
||||
import {
|
||||
TestRig,
|
||||
poll,
|
||||
assertModelHasOutput,
|
||||
checkModelOutputContent,
|
||||
} from './test-helper.js';
|
||||
import { TestRig, poll, validateModelOutput } from './test-helper.js';
|
||||
import { join } from 'node:path';
|
||||
import { writeFileSync } from 'node:fs';
|
||||
|
||||
@@ -231,11 +226,8 @@ describe.skip('simple-mcp-server', () => {
|
||||
|
||||
expect(foundToolCall, 'Expected to find an add tool call').toBeTruthy();
|
||||
|
||||
assertModelHasOutput(output);
|
||||
checkModelOutputContent(output, {
|
||||
expectedContent: '15',
|
||||
testName: 'MCP server test',
|
||||
});
|
||||
// Validate model output - will throw if no output, fail if missing expected content
|
||||
validateModelOutput(output, '15', 'MCP server test');
|
||||
expect(
|
||||
output.includes('15'),
|
||||
'Expected output to contain the sum (15)',
|
||||
|
||||
@@ -5,12 +5,7 @@
|
||||
*/
|
||||
|
||||
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
|
||||
import {
|
||||
TestRig,
|
||||
printDebugInfo,
|
||||
assertModelHasOutput,
|
||||
checkModelOutputContent,
|
||||
} from './test-helper.js';
|
||||
import { TestRig, printDebugInfo, validateModelOutput } from './test-helper.js';
|
||||
|
||||
describe.skip('stdin context', () => {
|
||||
let rig: TestRig;
|
||||
@@ -72,11 +67,7 @@ describe.skip('stdin context', () => {
|
||||
}
|
||||
|
||||
// Validate model output
|
||||
assertModelHasOutput(result);
|
||||
checkModelOutputContent(result, {
|
||||
expectedContent: randomString,
|
||||
testName: 'STDIN context test',
|
||||
});
|
||||
validateModelOutput(result, randomString, 'STDIN context test');
|
||||
|
||||
expect(
|
||||
result.toLowerCase().includes(randomString),
|
||||
|
||||
@@ -9,8 +9,7 @@ import {
|
||||
TestRig,
|
||||
createToolCallErrorMessage,
|
||||
printDebugInfo,
|
||||
assertModelHasOutput,
|
||||
checkModelOutputContent,
|
||||
validateModelOutput,
|
||||
} from './test-helper.js';
|
||||
|
||||
describe('write_file', () => {
|
||||
@@ -47,11 +46,8 @@ describe('write_file', () => {
|
||||
),
|
||||
).toBeTruthy();
|
||||
|
||||
assertModelHasOutput(result);
|
||||
checkModelOutputContent(result, {
|
||||
expectedContent: 'dad.txt',
|
||||
testName: 'Write file test',
|
||||
});
|
||||
// Validate model output - will throw if no output, warn if missing expected content
|
||||
validateModelOutput(result, 'dad.txt', 'Write file test');
|
||||
|
||||
const newFilePath = 'dad.txt';
|
||||
|
||||
|
||||
Generated
-57
@@ -13,7 +13,6 @@
|
||||
"dependencies": {
|
||||
"ink": "npm:@jrichman/ink@6.4.8",
|
||||
"latest-version": "^9.0.0",
|
||||
"proper-lockfile": "^4.1.2",
|
||||
"simple-git": "^3.28.0"
|
||||
},
|
||||
"bin": {
|
||||
@@ -27,7 +26,6 @@
|
||||
"@types/minimatch": "^5.1.2",
|
||||
"@types/mock-fs": "^4.13.4",
|
||||
"@types/prompts": "^2.4.9",
|
||||
"@types/proper-lockfile": "^4.1.4",
|
||||
"@types/react": "^19.2.0",
|
||||
"@types/react-dom": "^19.2.0",
|
||||
"@types/shell-quote": "^1.7.5",
|
||||
@@ -4110,16 +4108,6 @@
|
||||
"kleur": "^3.0.3"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/proper-lockfile": {
|
||||
"version": "4.1.4",
|
||||
"resolved": "https://registry.npmjs.org/@types/proper-lockfile/-/proper-lockfile-4.1.4.tgz",
|
||||
"integrity": "sha512-uo2ABllncSqg9F1D4nugVl9v93RmjxF6LJzQLMLDdPaXCUIDPeOJ21Gbqi43xNKzBi/WQ0Q0dICqufzQbMjipQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/retry": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/qs": {
|
||||
"version": "6.14.0",
|
||||
"resolved": "https://registry.npmjs.org/@types/qs/-/qs-6.14.0.tgz",
|
||||
@@ -4215,13 +4203,6 @@
|
||||
"node": ">= 0.6"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/retry": {
|
||||
"version": "0.12.5",
|
||||
"resolved": "https://registry.npmjs.org/@types/retry/-/retry-0.12.5.tgz",
|
||||
"integrity": "sha512-3xSjTp3v03X/lSQLkczaN9UIEwJMoMCA1+Nb5HfbJEQWogdeQIyVtTvxPXDQjZ5zws8rFQfVfRdz03ARihPJgw==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/sarif": {
|
||||
"version": "2.1.7",
|
||||
"resolved": "https://registry.npmjs.org/@types/sarif/-/sarif-2.1.7.tgz",
|
||||
@@ -4352,16 +4333,6 @@
|
||||
"boxen": "^7.1.1"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/ws": {
|
||||
"version": "8.18.1",
|
||||
"resolved": "https://registry.npmjs.org/@types/ws/-/ws-8.18.1.tgz",
|
||||
"integrity": "sha512-ThVF6DCVhA8kUGy+aazFQ4kXQ7E1Ty7A3ypFOe0IcJV8O/M511G99AW24irKrW56Wt44yG9+ij8FaqoBGkuBXg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/node": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/yargs": {
|
||||
"version": "17.0.33",
|
||||
"resolved": "https://registry.npmjs.org/@types/yargs/-/yargs-17.0.33.tgz",
|
||||
@@ -14081,32 +14052,6 @@
|
||||
"react-is": "^16.13.1"
|
||||
}
|
||||
},
|
||||
"node_modules/proper-lockfile": {
|
||||
"version": "4.1.2",
|
||||
"resolved": "https://registry.npmjs.org/proper-lockfile/-/proper-lockfile-4.1.2.tgz",
|
||||
"integrity": "sha512-TjNPblN4BwAWMXU8s9AEz4JmQxnD1NNL7bNOY/AKUzyamc379FWASUhc/K1pL2noVb+XmZKLL68cjzLsiOAMaA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"graceful-fs": "^4.2.4",
|
||||
"retry": "^0.12.0",
|
||||
"signal-exit": "^3.0.2"
|
||||
}
|
||||
},
|
||||
"node_modules/proper-lockfile/node_modules/retry": {
|
||||
"version": "0.12.0",
|
||||
"resolved": "https://registry.npmjs.org/retry/-/retry-0.12.0.tgz",
|
||||
"integrity": "sha512-9LkiTwjUh6rT555DtE9rTX+BKByPfrMzEAtnlEtdEwr3Nkffwiihqe2bWADg+OQRjt9gl6ICdmB/ZFDCGAtSow==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 4"
|
||||
}
|
||||
},
|
||||
"node_modules/proper-lockfile/node_modules/signal-exit": {
|
||||
"version": "3.0.7",
|
||||
"resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-3.0.7.tgz",
|
||||
"integrity": "sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ==",
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/proto-list": {
|
||||
"version": "1.2.4",
|
||||
"resolved": "https://registry.npmjs.org/proto-list/-/proto-list-1.2.4.tgz",
|
||||
@@ -18171,7 +18116,6 @@
|
||||
"tinygradient": "^1.1.5",
|
||||
"undici": "^7.10.0",
|
||||
"wrap-ansi": "9.0.2",
|
||||
"ws": "^8.16.0",
|
||||
"yargs": "^17.7.2",
|
||||
"zod": "^3.23.8"
|
||||
},
|
||||
@@ -18190,7 +18134,6 @@
|
||||
"@types/semver": "^7.7.0",
|
||||
"@types/shell-quote": "^1.7.5",
|
||||
"@types/tar": "^6.1.13",
|
||||
"@types/ws": "^8.5.10",
|
||||
"@types/yargs": "^17.0.32",
|
||||
"archiver": "^7.0.1",
|
||||
"ink-testing-library": "^4.0.0",
|
||||
|
||||
+1
-3
@@ -32,7 +32,7 @@
|
||||
"docs:settings": "tsx ./scripts/generate-settings-doc.ts",
|
||||
"docs:keybindings": "tsx ./scripts/generate-keybindings-doc.ts",
|
||||
"build": "node scripts/build.js",
|
||||
"build-and-start": "npm run build && npm run start --",
|
||||
"build-and-start": "npm run build && npm run start",
|
||||
"build:vscode": "node scripts/build_vscode_companion.js",
|
||||
"build:all": "npm run build && npm run build:sandbox && npm run build:vscode",
|
||||
"build:packages": "npm run build --workspaces",
|
||||
@@ -86,7 +86,6 @@
|
||||
"@types/minimatch": "^5.1.2",
|
||||
"@types/mock-fs": "^4.13.4",
|
||||
"@types/prompts": "^2.4.9",
|
||||
"@types/proper-lockfile": "^4.1.4",
|
||||
"@types/react": "^19.2.0",
|
||||
"@types/react-dom": "^19.2.0",
|
||||
"@types/shell-quote": "^1.7.5",
|
||||
@@ -127,7 +126,6 @@
|
||||
"dependencies": {
|
||||
"ink": "npm:@jrichman/ink@6.4.8",
|
||||
"latest-version": "^9.0.0",
|
||||
"proper-lockfile": "^4.1.2",
|
||||
"simple-git": "^3.28.0"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
|
||||
@@ -24,7 +24,6 @@ import {
|
||||
type ServerGeminiErrorEvent,
|
||||
type ServerGeminiStreamEvent,
|
||||
type ToolCallConfirmationDetails,
|
||||
type SerializableConfirmationDetails,
|
||||
type Config,
|
||||
type UserTierId,
|
||||
type AnsiOutput,
|
||||
@@ -66,10 +65,7 @@ export class Task {
|
||||
scheduler: CoreToolScheduler;
|
||||
config: Config;
|
||||
geminiClient: GeminiClient;
|
||||
pendingToolConfirmationDetails: Map<
|
||||
string,
|
||||
ToolCallConfirmationDetails | SerializableConfirmationDetails
|
||||
>;
|
||||
pendingToolConfirmationDetails: Map<string, ToolCallConfirmationDetails>;
|
||||
taskState: TaskState;
|
||||
eventBus?: ExecutionEventBus;
|
||||
completedToolCalls: CompletedToolCall[];
|
||||
@@ -382,7 +378,7 @@ export class Task {
|
||||
if (tc.status === 'awaiting_approval' && tc.confirmationDetails) {
|
||||
this.pendingToolConfirmationDetails.set(
|
||||
tc.request.callId,
|
||||
tc.confirmationDetails,
|
||||
tc.confirmationDetails as ToolCallConfirmationDetails,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -415,12 +411,10 @@ export class Task {
|
||||
);
|
||||
toolCalls.forEach((tc: ToolCall) => {
|
||||
if (tc.status === 'awaiting_approval' && tc.confirmationDetails) {
|
||||
if ('onConfirm' in tc.confirmationDetails) {
|
||||
// eslint-disable-next-line @typescript-eslint/no-floating-promises
|
||||
tc.confirmationDetails.onConfirm(
|
||||
ToolConfirmationOutcome.ProceedOnce,
|
||||
);
|
||||
}
|
||||
// eslint-disable-next-line @typescript-eslint/no-floating-promises
|
||||
(tc.confirmationDetails as ToolCallConfirmationDetails).onConfirm(
|
||||
ToolConfirmationOutcome.ProceedOnce,
|
||||
);
|
||||
this.pendingToolConfirmationDetails.delete(tc.request.callId);
|
||||
}
|
||||
});
|
||||
@@ -809,13 +803,6 @@ export class Task {
|
||||
// This will trigger the scheduler to continue or cancel the specific tool.
|
||||
// The scheduler's onToolCallsUpdate will then reflect the new state (e.g., executing or cancelled).
|
||||
|
||||
if (!('onConfirm' in confirmationDetails)) {
|
||||
logger.error(
|
||||
`[Task] Serializable confirmation details not supported yet in a2a-server for callId: ${callId}`,
|
||||
);
|
||||
return false;
|
||||
}
|
||||
|
||||
// If `edit` tool call, pass updated payload if presesent
|
||||
if (confirmationDetails.type === 'edit') {
|
||||
const payload = part.data['newContent']
|
||||
|
||||
@@ -18,6 +18,7 @@ import {
|
||||
loadServerHierarchicalMemory,
|
||||
GEMINI_DIR,
|
||||
DEFAULT_GEMINI_EMBEDDING_MODEL,
|
||||
DEFAULT_GEMINI_MODEL,
|
||||
type ExtensionLoader,
|
||||
startupProfiler,
|
||||
PREVIEW_GEMINI_MODEL,
|
||||
@@ -59,7 +60,9 @@ export async function loadConfig(
|
||||
|
||||
const configParams: ConfigParameters = {
|
||||
sessionId: taskId,
|
||||
model: PREVIEW_GEMINI_MODEL,
|
||||
model: settings.general?.previewFeatures
|
||||
? PREVIEW_GEMINI_MODEL
|
||||
: DEFAULT_GEMINI_MODEL,
|
||||
embeddingModel: DEFAULT_GEMINI_EMBEDDING_MODEL,
|
||||
sandbox: undefined, // Sandbox might not be relevant for a server-side agent
|
||||
targetDir: workspaceDir, // Or a specific directory the agent operates on
|
||||
@@ -101,6 +104,7 @@ export async function loadConfig(
|
||||
trustedFolder: true,
|
||||
extensionLoader,
|
||||
checkpointing,
|
||||
previewFeatures: settings.general?.previewFeatures,
|
||||
interactive: true,
|
||||
enableInteractiveShell: true,
|
||||
ptyInfo: 'auto',
|
||||
|
||||
@@ -89,6 +89,67 @@ describe('loadSettings', () => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
it('should load nested previewFeatures from user settings', () => {
|
||||
const settings = {
|
||||
general: {
|
||||
previewFeatures: true,
|
||||
},
|
||||
};
|
||||
fs.writeFileSync(USER_SETTINGS_PATH, JSON.stringify(settings));
|
||||
|
||||
const result = loadSettings(mockWorkspaceDir);
|
||||
expect(result.general?.previewFeatures).toBe(true);
|
||||
});
|
||||
|
||||
it('should load nested previewFeatures from workspace settings', () => {
|
||||
const settings = {
|
||||
general: {
|
||||
previewFeatures: true,
|
||||
},
|
||||
};
|
||||
const workspaceSettingsPath = path.join(
|
||||
mockGeminiWorkspaceDir,
|
||||
'settings.json',
|
||||
);
|
||||
fs.writeFileSync(workspaceSettingsPath, JSON.stringify(settings));
|
||||
|
||||
const result = loadSettings(mockWorkspaceDir);
|
||||
expect(result.general?.previewFeatures).toBe(true);
|
||||
});
|
||||
|
||||
it('should prioritize workspace settings over user settings', () => {
|
||||
const userSettings = {
|
||||
general: {
|
||||
previewFeatures: false,
|
||||
},
|
||||
};
|
||||
fs.writeFileSync(USER_SETTINGS_PATH, JSON.stringify(userSettings));
|
||||
|
||||
const workspaceSettings = {
|
||||
general: {
|
||||
previewFeatures: true,
|
||||
},
|
||||
};
|
||||
const workspaceSettingsPath = path.join(
|
||||
mockGeminiWorkspaceDir,
|
||||
'settings.json',
|
||||
);
|
||||
fs.writeFileSync(workspaceSettingsPath, JSON.stringify(workspaceSettings));
|
||||
|
||||
const result = loadSettings(mockWorkspaceDir);
|
||||
expect(result.general?.previewFeatures).toBe(true);
|
||||
});
|
||||
|
||||
it('should handle missing previewFeatures', () => {
|
||||
const settings = {
|
||||
general: {},
|
||||
};
|
||||
fs.writeFileSync(USER_SETTINGS_PATH, JSON.stringify(settings));
|
||||
|
||||
const result = loadSettings(mockWorkspaceDir);
|
||||
expect(result.general?.previewFeatures).toBeUndefined();
|
||||
});
|
||||
|
||||
it('should load other top-level settings correctly', () => {
|
||||
const settings = {
|
||||
showMemoryUsage: true,
|
||||
|
||||
@@ -31,6 +31,9 @@ export interface Settings {
|
||||
showMemoryUsage?: boolean;
|
||||
checkpointing?: CheckpointingSettings;
|
||||
folderTrust?: boolean;
|
||||
general?: {
|
||||
previewFeatures?: boolean;
|
||||
};
|
||||
|
||||
// Git-aware file filtering settings
|
||||
fileFiltering?: {
|
||||
|
||||
@@ -12,6 +12,7 @@ import type {
|
||||
import {
|
||||
ApprovalMode,
|
||||
DEFAULT_GEMINI_MODEL,
|
||||
DEFAULT_TRUNCATE_TOOL_OUTPUT_LINES,
|
||||
DEFAULT_TRUNCATE_TOOL_OUTPUT_THRESHOLD,
|
||||
GeminiClient,
|
||||
HookSystem,
|
||||
@@ -46,6 +47,7 @@ export function createMockConfig(
|
||||
} as Storage,
|
||||
getTruncateToolOutputThreshold: () =>
|
||||
DEFAULT_TRUNCATE_TOOL_OUTPUT_THRESHOLD,
|
||||
getTruncateToolOutputLines: () => DEFAULT_TRUNCATE_TOOL_OUTPUT_LINES,
|
||||
getActiveModel: vi.fn().mockReturnValue(DEFAULT_GEMINI_MODEL),
|
||||
getDebugMode: vi.fn().mockReturnValue(false),
|
||||
getContentGeneratorConfig: vi.fn().mockReturnValue({ model: 'gemini-pro' }),
|
||||
|
||||
@@ -65,7 +65,6 @@
|
||||
"tinygradient": "^1.1.5",
|
||||
"undici": "^7.10.0",
|
||||
"wrap-ansi": "9.0.2",
|
||||
"ws": "^8.16.0",
|
||||
"yargs": "^17.7.2",
|
||||
"zod": "^3.23.8"
|
||||
},
|
||||
@@ -81,7 +80,6 @@
|
||||
"@types/semver": "^7.7.0",
|
||||
"@types/shell-quote": "^1.7.5",
|
||||
"@types/tar": "^6.1.13",
|
||||
"@types/ws": "^8.5.10",
|
||||
"@types/yargs": "^17.0.32",
|
||||
"archiver": "^7.0.1",
|
||||
"ink-testing-library": "^4.0.0",
|
||||
|
||||
@@ -17,26 +17,32 @@ import yargs from 'yargs';
|
||||
import { debugLogger } from '@google/gemini-cli-core';
|
||||
import {
|
||||
updateSetting,
|
||||
promptForSetting,
|
||||
getScopedEnvContents,
|
||||
type ExtensionSetting,
|
||||
} from '../../config/extensions/extensionSettings.js';
|
||||
import prompts from 'prompts';
|
||||
import * as fs from 'node:fs';
|
||||
|
||||
const { mockExtensionManager, mockGetExtensionManager, mockLoadSettings } =
|
||||
vi.hoisted(() => {
|
||||
const extensionManager = {
|
||||
loadExtensionConfig: vi.fn(),
|
||||
getExtensions: vi.fn(),
|
||||
loadExtensions: vi.fn(),
|
||||
getSettings: vi.fn(),
|
||||
};
|
||||
return {
|
||||
mockExtensionManager: extensionManager,
|
||||
mockGetExtensionManager: vi.fn(),
|
||||
mockLoadSettings: vi.fn().mockReturnValue({ merged: {} }),
|
||||
};
|
||||
});
|
||||
const {
|
||||
mockExtensionManager,
|
||||
mockGetExtensionAndManager,
|
||||
mockGetExtensionManager,
|
||||
mockLoadSettings,
|
||||
} = vi.hoisted(() => {
|
||||
const extensionManager = {
|
||||
loadExtensionConfig: vi.fn(),
|
||||
getExtensions: vi.fn(),
|
||||
loadExtensions: vi.fn(),
|
||||
getSettings: vi.fn(),
|
||||
};
|
||||
return {
|
||||
mockExtensionManager: extensionManager,
|
||||
mockGetExtensionAndManager: vi.fn(),
|
||||
mockGetExtensionManager: vi.fn(),
|
||||
mockLoadSettings: vi.fn().mockReturnValue({ merged: {} }),
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock('../../config/extension-manager.js', () => ({
|
||||
ExtensionManager: vi.fn().mockImplementation(() => mockExtensionManager),
|
||||
@@ -56,13 +62,10 @@ vi.mock('../utils.js', () => ({
|
||||
exitCli: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('./utils.js', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import('./utils.js')>();
|
||||
return {
|
||||
...actual,
|
||||
getExtensionManager: mockGetExtensionManager,
|
||||
};
|
||||
});
|
||||
vi.mock('./utils.js', () => ({
|
||||
getExtensionAndManager: mockGetExtensionAndManager,
|
||||
getExtensionManager: mockGetExtensionManager,
|
||||
}));
|
||||
|
||||
vi.mock('prompts');
|
||||
|
||||
@@ -88,6 +91,10 @@ describe('extensions configure command', () => {
|
||||
vi.spyOn(process, 'cwd').mockReturnValue(tempWorkspaceDir);
|
||||
// Default behaviors
|
||||
mockLoadSettings.mockReturnValue({ merged: {} });
|
||||
mockGetExtensionAndManager.mockResolvedValue({
|
||||
extension: null,
|
||||
extensionManager: null,
|
||||
});
|
||||
mockGetExtensionManager.mockResolvedValue(mockExtensionManager);
|
||||
(ExtensionManager as unknown as Mock).mockImplementation(
|
||||
() => mockExtensionManager,
|
||||
@@ -110,6 +117,11 @@ describe('extensions configure command', () => {
|
||||
path = '/test/path',
|
||||
) => {
|
||||
const extension = { name, path, id };
|
||||
mockGetExtensionAndManager.mockImplementation(async (n) => {
|
||||
if (n === name)
|
||||
return { extension, extensionManager: mockExtensionManager };
|
||||
return { extension: null, extensionManager: null };
|
||||
});
|
||||
|
||||
mockExtensionManager.getExtensions.mockReturnValue([extension]);
|
||||
mockExtensionManager.loadExtensionConfig.mockResolvedValue({
|
||||
@@ -132,14 +144,17 @@ describe('extensions configure command', () => {
|
||||
expect.objectContaining({ name: 'test-ext' }),
|
||||
'test-id',
|
||||
'TEST_VAR',
|
||||
expect.any(Function),
|
||||
promptForSetting,
|
||||
'user',
|
||||
tempWorkspaceDir,
|
||||
);
|
||||
});
|
||||
|
||||
it('should handle missing extension', async () => {
|
||||
mockExtensionManager.getExtensions.mockReturnValue([]);
|
||||
mockGetExtensionAndManager.mockResolvedValue({
|
||||
extension: null,
|
||||
extensionManager: null,
|
||||
});
|
||||
|
||||
await runCommand('config missing-ext TEST_VAR');
|
||||
|
||||
@@ -175,7 +190,7 @@ describe('extensions configure command', () => {
|
||||
expect.objectContaining({ name: 'test-ext' }),
|
||||
'test-id',
|
||||
'VAR_1',
|
||||
expect.any(Function),
|
||||
promptForSetting,
|
||||
'user',
|
||||
tempWorkspaceDir,
|
||||
);
|
||||
@@ -190,7 +205,7 @@ describe('extensions configure command', () => {
|
||||
return {};
|
||||
},
|
||||
);
|
||||
(prompts as unknown as Mock).mockResolvedValue({ confirm: true });
|
||||
(prompts as unknown as Mock).mockResolvedValue({ overwrite: true });
|
||||
(updateSetting as Mock).mockResolvedValue(undefined);
|
||||
|
||||
await runCommand('config test-ext');
|
||||
@@ -226,7 +241,7 @@ describe('extensions configure command', () => {
|
||||
const settings = [{ name: 'Setting 1', envVar: 'VAR_1' }];
|
||||
setupExtension('test-ext', settings);
|
||||
(getScopedEnvContents as Mock).mockResolvedValue({ VAR_1: 'existing' });
|
||||
(prompts as unknown as Mock).mockResolvedValue({ confirm: false });
|
||||
(prompts as unknown as Mock).mockResolvedValue({ overwrite: false });
|
||||
|
||||
await runCommand('config test-ext');
|
||||
|
||||
|
||||
@@ -5,17 +5,18 @@
|
||||
*/
|
||||
|
||||
import type { CommandModule } from 'yargs';
|
||||
import type { ExtensionSettingScope } from '../../config/extensions/extensionSettings.js';
|
||||
import {
|
||||
configureAllExtensions,
|
||||
configureExtension,
|
||||
configureSpecificSetting,
|
||||
getExtensionManager,
|
||||
} from './utils.js';
|
||||
updateSetting,
|
||||
promptForSetting,
|
||||
ExtensionSettingScope,
|
||||
getScopedEnvContents,
|
||||
} from '../../config/extensions/extensionSettings.js';
|
||||
import { getExtensionAndManager, getExtensionManager } from './utils.js';
|
||||
import { loadSettings } from '../../config/settings.js';
|
||||
import { coreEvents, debugLogger } from '@google/gemini-cli-core';
|
||||
import { debugLogger, coreEvents } from '@google/gemini-cli-core';
|
||||
import { exitCli } from '../utils.js';
|
||||
|
||||
import prompts from 'prompts';
|
||||
import type { ExtensionConfig } from '../../config/extension.js';
|
||||
interface ConfigureArgs {
|
||||
name?: string;
|
||||
setting?: string;
|
||||
@@ -63,12 +64,9 @@ export const configureCommand: CommandModule<object, ConfigureArgs> = {
|
||||
}
|
||||
}
|
||||
|
||||
const extensionManager = await getExtensionManager();
|
||||
|
||||
// Case 1: Configure specific setting for an extension
|
||||
if (name && setting) {
|
||||
await configureSpecificSetting(
|
||||
extensionManager,
|
||||
name,
|
||||
setting,
|
||||
scope as ExtensionSettingScope,
|
||||
@@ -76,20 +74,152 @@ export const configureCommand: CommandModule<object, ConfigureArgs> = {
|
||||
}
|
||||
// Case 2: Configure all settings for an extension
|
||||
else if (name) {
|
||||
await configureExtension(
|
||||
extensionManager,
|
||||
name,
|
||||
scope as ExtensionSettingScope,
|
||||
);
|
||||
await configureExtension(name, scope as ExtensionSettingScope);
|
||||
}
|
||||
// Case 3: Configure all extensions
|
||||
else {
|
||||
await configureAllExtensions(
|
||||
extensionManager,
|
||||
scope as ExtensionSettingScope,
|
||||
);
|
||||
await configureAllExtensions(scope as ExtensionSettingScope);
|
||||
}
|
||||
|
||||
await exitCli();
|
||||
},
|
||||
};
|
||||
|
||||
async function configureSpecificSetting(
|
||||
extensionName: string,
|
||||
settingKey: string,
|
||||
scope: ExtensionSettingScope,
|
||||
) {
|
||||
const { extension, extensionManager } =
|
||||
await getExtensionAndManager(extensionName);
|
||||
if (!extension || !extensionManager) {
|
||||
return;
|
||||
}
|
||||
const extensionConfig = await extensionManager.loadExtensionConfig(
|
||||
extension.path,
|
||||
);
|
||||
if (!extensionConfig) {
|
||||
debugLogger.error(
|
||||
`Could not find configuration for extension "${extensionName}".`,
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
await updateSetting(
|
||||
extensionConfig,
|
||||
extension.id,
|
||||
settingKey,
|
||||
promptForSetting,
|
||||
scope,
|
||||
process.cwd(),
|
||||
);
|
||||
}
|
||||
|
||||
async function configureExtension(
|
||||
extensionName: string,
|
||||
scope: ExtensionSettingScope,
|
||||
) {
|
||||
const { extension, extensionManager } =
|
||||
await getExtensionAndManager(extensionName);
|
||||
if (!extension || !extensionManager) {
|
||||
return;
|
||||
}
|
||||
const extensionConfig = await extensionManager.loadExtensionConfig(
|
||||
extension.path,
|
||||
);
|
||||
if (
|
||||
!extensionConfig ||
|
||||
!extensionConfig.settings ||
|
||||
extensionConfig.settings.length === 0
|
||||
) {
|
||||
debugLogger.log(
|
||||
`Extension "${extensionName}" has no settings to configure.`,
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
debugLogger.log(`Configuring settings for "${extensionName}"...`);
|
||||
await configureExtensionSettings(extensionConfig, extension.id, scope);
|
||||
}
|
||||
|
||||
async function configureAllExtensions(scope: ExtensionSettingScope) {
|
||||
const extensionManager = await getExtensionManager();
|
||||
const extensions = extensionManager.getExtensions();
|
||||
|
||||
if (extensions.length === 0) {
|
||||
debugLogger.log('No extensions installed.');
|
||||
return;
|
||||
}
|
||||
|
||||
for (const extension of extensions) {
|
||||
const extensionConfig = await extensionManager.loadExtensionConfig(
|
||||
extension.path,
|
||||
);
|
||||
if (
|
||||
extensionConfig &&
|
||||
extensionConfig.settings &&
|
||||
extensionConfig.settings.length > 0
|
||||
) {
|
||||
debugLogger.log(`\nConfiguring settings for "${extension.name}"...`);
|
||||
await configureExtensionSettings(extensionConfig, extension.id, scope);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function configureExtensionSettings(
|
||||
extensionConfig: ExtensionConfig,
|
||||
extensionId: string,
|
||||
scope: ExtensionSettingScope,
|
||||
) {
|
||||
const currentScopedSettings = await getScopedEnvContents(
|
||||
extensionConfig,
|
||||
extensionId,
|
||||
scope,
|
||||
process.cwd(),
|
||||
);
|
||||
|
||||
let workspaceSettings: Record<string, string> = {};
|
||||
if (scope === ExtensionSettingScope.USER) {
|
||||
workspaceSettings = await getScopedEnvContents(
|
||||
extensionConfig,
|
||||
extensionId,
|
||||
ExtensionSettingScope.WORKSPACE,
|
||||
process.cwd(),
|
||||
);
|
||||
}
|
||||
|
||||
if (!extensionConfig.settings) return;
|
||||
|
||||
for (const setting of extensionConfig.settings) {
|
||||
const currentValue = currentScopedSettings[setting.envVar];
|
||||
const workspaceValue = workspaceSettings[setting.envVar];
|
||||
|
||||
if (workspaceValue !== undefined) {
|
||||
debugLogger.log(
|
||||
`Note: Setting "${setting.name}" is already configured in the workspace scope.`,
|
||||
);
|
||||
}
|
||||
|
||||
if (currentValue !== undefined) {
|
||||
const response = await prompts({
|
||||
type: 'confirm',
|
||||
name: 'overwrite',
|
||||
message: `Setting "${setting.name}" (${setting.envVar}) is already set. Overwrite?`,
|
||||
initial: false,
|
||||
});
|
||||
|
||||
if (!response.overwrite) {
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
await updateSetting(
|
||||
extensionConfig,
|
||||
extensionId,
|
||||
setting.envVar,
|
||||
promptForSetting,
|
||||
scope,
|
||||
process.cwd(),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,7 +5,6 @@
|
||||
*/
|
||||
|
||||
import type { CommandModule } from 'yargs';
|
||||
import chalk from 'chalk';
|
||||
import {
|
||||
debugLogger,
|
||||
type ExtensionInstallMetadata,
|
||||
@@ -50,9 +49,7 @@ export async function handleLink(args: InstallArgs) {
|
||||
const extension =
|
||||
await extensionManager.installOrUpdateExtension(installMetadata);
|
||||
debugLogger.log(
|
||||
chalk.green(
|
||||
`Extension "${extension.name}" linked successfully and enabled.`,
|
||||
),
|
||||
`Extension "${extension.name}" linked successfully and enabled.`,
|
||||
);
|
||||
} catch (error) {
|
||||
debugLogger.error(getErrorMessage(error));
|
||||
|
||||
@@ -1,54 +1,17 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2026 Google LLC
|
||||
* Copyright 2025 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { ExtensionManager } from '../../config/extension-manager.js';
|
||||
import { promptForSetting } from '../../config/extensions/extensionSettings.js';
|
||||
import { loadSettings } from '../../config/settings.js';
|
||||
import { requestConsentNonInteractive } from '../../config/extensions/consent.js';
|
||||
import {
|
||||
debugLogger,
|
||||
type ResolvedExtensionSetting,
|
||||
} from '@google/gemini-cli-core';
|
||||
import type { ExtensionConfig } from '../../config/extension.js';
|
||||
import prompts from 'prompts';
|
||||
import {
|
||||
promptForSetting,
|
||||
updateSetting,
|
||||
type ExtensionSetting,
|
||||
getScopedEnvContents,
|
||||
ExtensionSettingScope,
|
||||
} from '../../config/extensions/extensionSettings.js';
|
||||
|
||||
export interface ConfigLogger {
|
||||
log(message: string): void;
|
||||
error(message: string): void;
|
||||
}
|
||||
|
||||
export type RequestSettingCallback = (
|
||||
setting: ExtensionSetting,
|
||||
) => Promise<string>;
|
||||
export type RequestConfirmationCallback = (message: string) => Promise<boolean>;
|
||||
|
||||
const defaultLogger: ConfigLogger = {
|
||||
log: (message: string) => debugLogger.log(message),
|
||||
error: (message: string) => debugLogger.error(message),
|
||||
};
|
||||
|
||||
const defaultRequestSetting: RequestSettingCallback = async (setting) =>
|
||||
promptForSetting(setting);
|
||||
|
||||
const defaultRequestConfirmation: RequestConfirmationCallback = async (
|
||||
message,
|
||||
) => {
|
||||
const response = await prompts({
|
||||
type: 'confirm',
|
||||
name: 'confirm',
|
||||
message,
|
||||
initial: false,
|
||||
});
|
||||
return response.confirm;
|
||||
};
|
||||
|
||||
export async function getExtensionManager() {
|
||||
const workspaceDir = process.cwd();
|
||||
@@ -62,192 +25,18 @@ export async function getExtensionManager() {
|
||||
return extensionManager;
|
||||
}
|
||||
|
||||
export async function getExtensionAndManager(
|
||||
extensionManager: ExtensionManager,
|
||||
name: string,
|
||||
logger: ConfigLogger = defaultLogger,
|
||||
) {
|
||||
export async function getExtensionAndManager(name: string) {
|
||||
const extensionManager = await getExtensionManager();
|
||||
const extension = extensionManager
|
||||
.getExtensions()
|
||||
.find((ext) => ext.name === name);
|
||||
|
||||
if (!extension) {
|
||||
logger.error(`Extension "${name}" is not installed.`);
|
||||
return { extension: null };
|
||||
debugLogger.error(`Extension "${name}" is not installed.`);
|
||||
return { extension: null, extensionManager: null };
|
||||
}
|
||||
|
||||
return { extension };
|
||||
}
|
||||
|
||||
export async function configureSpecificSetting(
|
||||
extensionManager: ExtensionManager,
|
||||
extensionName: string,
|
||||
settingKey: string,
|
||||
scope: ExtensionSettingScope,
|
||||
logger: ConfigLogger = defaultLogger,
|
||||
requestSetting: RequestSettingCallback = defaultRequestSetting,
|
||||
) {
|
||||
const { extension } = await getExtensionAndManager(
|
||||
extensionManager,
|
||||
extensionName,
|
||||
logger,
|
||||
);
|
||||
if (!extension) {
|
||||
return;
|
||||
}
|
||||
const extensionConfig = await extensionManager.loadExtensionConfig(
|
||||
extension.path,
|
||||
);
|
||||
if (!extensionConfig) {
|
||||
logger.error(
|
||||
`Could not find configuration for extension "${extensionName}".`,
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
await updateSetting(
|
||||
extensionConfig,
|
||||
extension.id,
|
||||
settingKey,
|
||||
requestSetting,
|
||||
scope,
|
||||
process.cwd(),
|
||||
);
|
||||
|
||||
logger.log(`Setting "${settingKey}" updated.`);
|
||||
}
|
||||
|
||||
export async function configureExtension(
|
||||
extensionManager: ExtensionManager,
|
||||
extensionName: string,
|
||||
scope: ExtensionSettingScope,
|
||||
logger: ConfigLogger = defaultLogger,
|
||||
requestSetting: RequestSettingCallback = defaultRequestSetting,
|
||||
requestConfirmation: RequestConfirmationCallback = defaultRequestConfirmation,
|
||||
) {
|
||||
const { extension } = await getExtensionAndManager(
|
||||
extensionManager,
|
||||
extensionName,
|
||||
logger,
|
||||
);
|
||||
if (!extension) {
|
||||
return;
|
||||
}
|
||||
const extensionConfig = await extensionManager.loadExtensionConfig(
|
||||
extension.path,
|
||||
);
|
||||
if (
|
||||
!extensionConfig ||
|
||||
!extensionConfig.settings ||
|
||||
extensionConfig.settings.length === 0
|
||||
) {
|
||||
logger.log(`Extension "${extensionName}" has no settings to configure.`);
|
||||
return;
|
||||
}
|
||||
|
||||
logger.log(`Configuring settings for "${extensionName}"...`);
|
||||
await configureExtensionSettings(
|
||||
extensionConfig,
|
||||
extension.id,
|
||||
scope,
|
||||
logger,
|
||||
requestSetting,
|
||||
requestConfirmation,
|
||||
);
|
||||
}
|
||||
|
||||
export async function configureAllExtensions(
|
||||
extensionManager: ExtensionManager,
|
||||
scope: ExtensionSettingScope,
|
||||
logger: ConfigLogger = defaultLogger,
|
||||
requestSetting: RequestSettingCallback = defaultRequestSetting,
|
||||
requestConfirmation: RequestConfirmationCallback = defaultRequestConfirmation,
|
||||
) {
|
||||
const extensions = extensionManager.getExtensions();
|
||||
|
||||
if (extensions.length === 0) {
|
||||
logger.log('No extensions installed.');
|
||||
return;
|
||||
}
|
||||
|
||||
for (const extension of extensions) {
|
||||
const extensionConfig = await extensionManager.loadExtensionConfig(
|
||||
extension.path,
|
||||
);
|
||||
if (
|
||||
extensionConfig &&
|
||||
extensionConfig.settings &&
|
||||
extensionConfig.settings.length > 0
|
||||
) {
|
||||
logger.log(`\nConfiguring settings for "${extension.name}"...`);
|
||||
await configureExtensionSettings(
|
||||
extensionConfig,
|
||||
extension.id,
|
||||
scope,
|
||||
logger,
|
||||
requestSetting,
|
||||
requestConfirmation,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export async function configureExtensionSettings(
|
||||
extensionConfig: ExtensionConfig,
|
||||
extensionId: string,
|
||||
scope: ExtensionSettingScope,
|
||||
logger: ConfigLogger = defaultLogger,
|
||||
requestSetting: RequestSettingCallback = defaultRequestSetting,
|
||||
requestConfirmation: RequestConfirmationCallback = defaultRequestConfirmation,
|
||||
) {
|
||||
const currentScopedSettings = await getScopedEnvContents(
|
||||
extensionConfig,
|
||||
extensionId,
|
||||
scope,
|
||||
process.cwd(),
|
||||
);
|
||||
|
||||
let workspaceSettings: Record<string, string> = {};
|
||||
if (scope === ExtensionSettingScope.USER) {
|
||||
workspaceSettings = await getScopedEnvContents(
|
||||
extensionConfig,
|
||||
extensionId,
|
||||
ExtensionSettingScope.WORKSPACE,
|
||||
process.cwd(),
|
||||
);
|
||||
}
|
||||
|
||||
if (!extensionConfig.settings) return;
|
||||
|
||||
for (const setting of extensionConfig.settings) {
|
||||
const currentValue = currentScopedSettings[setting.envVar];
|
||||
const workspaceValue = workspaceSettings[setting.envVar];
|
||||
|
||||
if (workspaceValue !== undefined) {
|
||||
logger.log(
|
||||
`Note: Setting "${setting.name}" is already configured in the workspace scope.`,
|
||||
);
|
||||
}
|
||||
|
||||
if (currentValue !== undefined) {
|
||||
const confirmed = await requestConfirmation(
|
||||
`Setting "${setting.name}" (${setting.envVar}) is already set. Overwrite?`,
|
||||
);
|
||||
|
||||
if (!confirmed) {
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
await updateSetting(
|
||||
extensionConfig,
|
||||
extensionId,
|
||||
setting.envVar,
|
||||
requestSetting,
|
||||
scope,
|
||||
process.cwd(),
|
||||
);
|
||||
}
|
||||
return { extension, extensionManager };
|
||||
}
|
||||
|
||||
export function getFormattedSettingValue(
|
||||
|
||||
@@ -32,7 +32,6 @@ vi.mock('@google/gemini-cli-core', async (importOriginal) => {
|
||||
return {
|
||||
...original,
|
||||
createTransport: vi.fn(),
|
||||
|
||||
MCPServerStatus: {
|
||||
CONNECTED: 'CONNECTED',
|
||||
CONNECTING: 'CONNECTING',
|
||||
@@ -224,46 +223,4 @@ describe('mcp list command', () => {
|
||||
),
|
||||
);
|
||||
});
|
||||
|
||||
it('should filter servers based on admin allowlist passed in settings', async () => {
|
||||
const settingsWithAllowlist = mergeSettings({}, {}, {}, {}, true);
|
||||
settingsWithAllowlist.admin = {
|
||||
secureModeEnabled: false,
|
||||
extensions: { enabled: true },
|
||||
skills: { enabled: true },
|
||||
mcp: {
|
||||
enabled: true,
|
||||
config: {
|
||||
'allowed-server': { url: 'http://allowed' },
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
settingsWithAllowlist.mcpServers = {
|
||||
'allowed-server': { command: 'cmd1' },
|
||||
'forbidden-server': { command: 'cmd2' },
|
||||
};
|
||||
|
||||
mockedLoadSettings.mockReturnValue({
|
||||
merged: settingsWithAllowlist,
|
||||
});
|
||||
|
||||
mockClient.connect.mockResolvedValue(undefined);
|
||||
mockClient.ping.mockResolvedValue(undefined);
|
||||
|
||||
await listMcpServers(settingsWithAllowlist);
|
||||
|
||||
expect(debugLogger.log).toHaveBeenCalledWith(
|
||||
expect.stringContaining('allowed-server'),
|
||||
);
|
||||
expect(debugLogger.log).not.toHaveBeenCalledWith(
|
||||
expect.stringContaining('forbidden-server'),
|
||||
);
|
||||
expect(mockedCreateTransport).toHaveBeenCalledWith(
|
||||
'allowed-server',
|
||||
expect.objectContaining({ url: 'http://allowed' }), // Should use admin config
|
||||
false,
|
||||
expect.anything(),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -6,14 +6,12 @@
|
||||
|
||||
// File for 'gemini mcp list' command
|
||||
import type { CommandModule } from 'yargs';
|
||||
import { type MergedSettings, loadSettings } from '../../config/settings.js';
|
||||
import { loadSettings } from '../../config/settings.js';
|
||||
import type { MCPServerConfig } from '@google/gemini-cli-core';
|
||||
import {
|
||||
MCPServerStatus,
|
||||
createTransport,
|
||||
debugLogger,
|
||||
applyAdminAllowlist,
|
||||
getAdminBlockedMcpServersMessage,
|
||||
} from '@google/gemini-cli-core';
|
||||
import { Client } from '@modelcontextprotocol/sdk/client/index.js';
|
||||
import { ExtensionManager } from '../../config/extension-manager.js';
|
||||
@@ -26,24 +24,18 @@ const COLOR_YELLOW = '\u001b[33m';
|
||||
const COLOR_RED = '\u001b[31m';
|
||||
const RESET_COLOR = '\u001b[0m';
|
||||
|
||||
export async function getMcpServersFromConfig(
|
||||
settings?: MergedSettings,
|
||||
): Promise<{
|
||||
mcpServers: Record<string, MCPServerConfig>;
|
||||
blockedServerNames: string[];
|
||||
}> {
|
||||
if (!settings) {
|
||||
settings = loadSettings().merged;
|
||||
}
|
||||
|
||||
export async function getMcpServersFromConfig(): Promise<
|
||||
Record<string, MCPServerConfig>
|
||||
> {
|
||||
const settings = loadSettings();
|
||||
const extensionManager = new ExtensionManager({
|
||||
settings,
|
||||
settings: settings.merged,
|
||||
workspaceDir: process.cwd(),
|
||||
requestConsent: requestConsentNonInteractive,
|
||||
requestSetting: promptForSetting,
|
||||
});
|
||||
const extensions = await extensionManager.loadExtensions();
|
||||
const mcpServers = { ...settings.mcpServers };
|
||||
const mcpServers = { ...settings.merged.mcpServers };
|
||||
for (const extension of extensions) {
|
||||
Object.entries(extension.mcpServers || {}).forEach(([key, server]) => {
|
||||
if (mcpServers[key]) {
|
||||
@@ -55,11 +47,7 @@ export async function getMcpServersFromConfig(
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
const adminAllowlist = settings.admin?.mcp?.config;
|
||||
const filteredResult = applyAdminAllowlist(mcpServers, adminAllowlist);
|
||||
|
||||
return filteredResult;
|
||||
return mcpServers;
|
||||
}
|
||||
|
||||
async function testMCPConnection(
|
||||
@@ -115,23 +103,12 @@ async function getServerStatus(
|
||||
return testMCPConnection(serverName, server);
|
||||
}
|
||||
|
||||
export async function listMcpServers(settings?: MergedSettings): Promise<void> {
|
||||
const { mcpServers, blockedServerNames } =
|
||||
await getMcpServersFromConfig(settings);
|
||||
export async function listMcpServers(): Promise<void> {
|
||||
const mcpServers = await getMcpServersFromConfig();
|
||||
const serverNames = Object.keys(mcpServers);
|
||||
|
||||
if (blockedServerNames.length > 0) {
|
||||
const message = getAdminBlockedMcpServersMessage(
|
||||
blockedServerNames,
|
||||
undefined,
|
||||
);
|
||||
debugLogger.log(COLOR_YELLOW + message + RESET_COLOR + '\n');
|
||||
}
|
||||
|
||||
if (serverNames.length === 0) {
|
||||
if (blockedServerNames.length === 0) {
|
||||
debugLogger.log('No MCP servers configured.');
|
||||
}
|
||||
debugLogger.log('No MCP servers configured.');
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -177,15 +154,11 @@ export async function listMcpServers(settings?: MergedSettings): Promise<void> {
|
||||
}
|
||||
}
|
||||
|
||||
interface ListArgs {
|
||||
settings?: MergedSettings;
|
||||
}
|
||||
|
||||
export const listCommand: CommandModule<object, ListArgs> = {
|
||||
export const listCommand: CommandModule = {
|
||||
command: 'list',
|
||||
describe: 'List all configured MCP servers',
|
||||
handler: async (argv) => {
|
||||
await listMcpServers(argv.settings);
|
||||
handler: async () => {
|
||||
await listMcpServers();
|
||||
await exitCli();
|
||||
},
|
||||
};
|
||||
|
||||
@@ -18,7 +18,6 @@ import {
|
||||
type ExtensionLoader,
|
||||
debugLogger,
|
||||
ApprovalMode,
|
||||
type MCPServerConfig,
|
||||
} from '@google/gemini-cli-core';
|
||||
import { loadCliConfig, parseArguments, type CliArgs } from './config.js';
|
||||
import { type Settings, createTestMergedSettings } from './settings.js';
|
||||
@@ -1442,211 +1441,6 @@ describe('loadCliConfig with allowed-mcp-server-names', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('loadCliConfig with admin.mcp.config', () => {
|
||||
beforeEach(() => {
|
||||
vi.resetAllMocks();
|
||||
vi.mocked(os.homedir).mockReturnValue('/mock/home/user');
|
||||
vi.stubEnv('GEMINI_API_KEY', 'test-api-key');
|
||||
vi.spyOn(ExtensionManager.prototype, 'getExtensions').mockReturnValue([]);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllEnvs();
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
const localMcpServers: Record<string, MCPServerConfig> = {
|
||||
serverA: {
|
||||
command: 'npx',
|
||||
args: ['-y', '@mcp/server-a'],
|
||||
env: { KEY: 'VALUE' },
|
||||
cwd: '/local/cwd',
|
||||
trust: false,
|
||||
},
|
||||
serverB: {
|
||||
command: 'npx',
|
||||
args: ['-y', '@mcp/server-b'],
|
||||
trust: false,
|
||||
},
|
||||
};
|
||||
|
||||
const baseSettings = createTestMergedSettings({
|
||||
mcp: { serverCommand: 'npx -y @mcp/default-server' },
|
||||
mcpServers: localMcpServers,
|
||||
});
|
||||
|
||||
it('should use local configuration if admin allowlist is empty', async () => {
|
||||
process.argv = ['node', 'script.js'];
|
||||
const argv = await parseArguments(createTestMergedSettings());
|
||||
const settings = createTestMergedSettings({
|
||||
mcp: baseSettings.mcp,
|
||||
mcpServers: localMcpServers,
|
||||
admin: {
|
||||
...baseSettings.admin,
|
||||
mcp: { enabled: true, config: {} },
|
||||
},
|
||||
});
|
||||
const config = await loadCliConfig(settings, 'test-session', argv);
|
||||
expect(config.getMcpServers()).toEqual(localMcpServers);
|
||||
expect(config.getMcpServerCommand()).toBe('npx -y @mcp/default-server');
|
||||
});
|
||||
|
||||
it('should ignore locally configured servers not present in the allowlist', async () => {
|
||||
process.argv = ['node', 'script.js'];
|
||||
const argv = await parseArguments(createTestMergedSettings());
|
||||
const adminAllowlist: Record<string, MCPServerConfig> = {
|
||||
serverA: {
|
||||
type: 'sse',
|
||||
url: 'https://admin-server-a.com/sse',
|
||||
trust: true,
|
||||
},
|
||||
};
|
||||
const settings = createTestMergedSettings({
|
||||
mcp: baseSettings.mcp,
|
||||
mcpServers: localMcpServers,
|
||||
admin: {
|
||||
...baseSettings.admin,
|
||||
mcp: { enabled: true, config: adminAllowlist },
|
||||
},
|
||||
});
|
||||
const config = await loadCliConfig(settings, 'test-session', argv);
|
||||
|
||||
const mergedServers = config.getMcpServers() ?? {};
|
||||
expect(mergedServers).toHaveProperty('serverA');
|
||||
expect(mergedServers).not.toHaveProperty('serverB');
|
||||
});
|
||||
|
||||
it('should clear command, args, env, and cwd for present servers', async () => {
|
||||
process.argv = ['node', 'script.js'];
|
||||
const argv = await parseArguments(createTestMergedSettings());
|
||||
const adminAllowlist: Record<string, MCPServerConfig> = {
|
||||
serverA: {
|
||||
type: 'sse',
|
||||
url: 'https://admin-server-a.com/sse',
|
||||
trust: true,
|
||||
},
|
||||
};
|
||||
const settings = createTestMergedSettings({
|
||||
mcpServers: localMcpServers,
|
||||
admin: {
|
||||
...baseSettings.admin,
|
||||
mcp: { enabled: true, config: adminAllowlist },
|
||||
},
|
||||
});
|
||||
const config = await loadCliConfig(settings, 'test-session', argv);
|
||||
|
||||
const serverA = config.getMcpServers()?.['serverA'];
|
||||
expect(serverA).toEqual({
|
||||
...localMcpServers['serverA'],
|
||||
type: 'sse',
|
||||
url: 'https://admin-server-a.com/sse',
|
||||
trust: true,
|
||||
command: undefined,
|
||||
args: undefined,
|
||||
env: undefined,
|
||||
cwd: undefined,
|
||||
httpUrl: undefined,
|
||||
tcp: undefined,
|
||||
});
|
||||
});
|
||||
|
||||
it('should not initialize a server if it is in allowlist but missing locally', async () => {
|
||||
process.argv = ['node', 'script.js'];
|
||||
const argv = await parseArguments(createTestMergedSettings());
|
||||
const adminAllowlist: Record<string, MCPServerConfig> = {
|
||||
serverC: {
|
||||
type: 'sse',
|
||||
url: 'https://admin-server-c.com/sse',
|
||||
trust: true,
|
||||
},
|
||||
};
|
||||
const settings = createTestMergedSettings({
|
||||
mcpServers: localMcpServers,
|
||||
admin: {
|
||||
...baseSettings.admin,
|
||||
mcp: { enabled: true, config: adminAllowlist },
|
||||
},
|
||||
});
|
||||
const config = await loadCliConfig(settings, 'test-session', argv);
|
||||
|
||||
const mergedServers = config.getMcpServers() ?? {};
|
||||
expect(mergedServers).not.toHaveProperty('serverC');
|
||||
expect(Object.keys(mergedServers)).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('should merge local fields and prefer admin tool filters', async () => {
|
||||
process.argv = ['node', 'script.js'];
|
||||
const argv = await parseArguments(createTestMergedSettings());
|
||||
const adminAllowlist: Record<string, MCPServerConfig> = {
|
||||
serverA: {
|
||||
type: 'sse',
|
||||
url: 'https://admin-server-a.com/sse',
|
||||
trust: true,
|
||||
includeTools: ['admin_tool'],
|
||||
},
|
||||
};
|
||||
const localMcpServersWithTools: Record<string, MCPServerConfig> = {
|
||||
serverA: {
|
||||
...localMcpServers['serverA'],
|
||||
includeTools: ['local_tool'],
|
||||
timeout: 1234,
|
||||
},
|
||||
};
|
||||
const settings = createTestMergedSettings({
|
||||
mcpServers: localMcpServersWithTools,
|
||||
admin: {
|
||||
...baseSettings.admin,
|
||||
mcp: { enabled: true, config: adminAllowlist },
|
||||
},
|
||||
});
|
||||
const config = await loadCliConfig(settings, 'test-session', argv);
|
||||
|
||||
const serverA = (config.getMcpServers() ?? {})['serverA'];
|
||||
expect(serverA).toMatchObject({
|
||||
timeout: 1234,
|
||||
includeTools: ['admin_tool'],
|
||||
type: 'sse',
|
||||
url: 'https://admin-server-a.com/sse',
|
||||
trust: true,
|
||||
});
|
||||
expect(serverA).not.toHaveProperty('command');
|
||||
expect(serverA).not.toHaveProperty('args');
|
||||
expect(serverA).not.toHaveProperty('env');
|
||||
expect(serverA).not.toHaveProperty('cwd');
|
||||
expect(serverA).not.toHaveProperty('httpUrl');
|
||||
expect(serverA).not.toHaveProperty('tcp');
|
||||
});
|
||||
|
||||
it('should use local tool filters when admin does not define them', async () => {
|
||||
process.argv = ['node', 'script.js'];
|
||||
const argv = await parseArguments(createTestMergedSettings());
|
||||
const adminAllowlist: Record<string, MCPServerConfig> = {
|
||||
serverA: {
|
||||
type: 'sse',
|
||||
url: 'https://admin-server-a.com/sse',
|
||||
trust: true,
|
||||
},
|
||||
};
|
||||
const localMcpServersWithTools: Record<string, MCPServerConfig> = {
|
||||
serverA: {
|
||||
...localMcpServers['serverA'],
|
||||
includeTools: ['local_tool'],
|
||||
},
|
||||
};
|
||||
const settings = createTestMergedSettings({
|
||||
mcpServers: localMcpServersWithTools,
|
||||
admin: {
|
||||
...baseSettings.admin,
|
||||
mcp: { enabled: true, config: adminAllowlist },
|
||||
},
|
||||
});
|
||||
const config = await loadCliConfig(settings, 'test-session', argv);
|
||||
|
||||
const serverA = config.getMcpServers()?.['serverA'];
|
||||
expect(serverA?.includeTools).toEqual(['local_tool']);
|
||||
});
|
||||
});
|
||||
|
||||
describe('loadCliConfig model selection', () => {
|
||||
beforeEach(() => {
|
||||
vi.spyOn(ExtensionManager.prototype, 'getExtensions').mockReturnValue([]);
|
||||
@@ -1683,7 +1477,7 @@ describe('loadCliConfig model selection', () => {
|
||||
argv,
|
||||
);
|
||||
|
||||
expect(config.getModel()).toBe('auto-gemini-3');
|
||||
expect(config.getModel()).toBe('auto-gemini-2.5');
|
||||
});
|
||||
|
||||
it('always prefers model from argv', async () => {
|
||||
@@ -1727,7 +1521,7 @@ describe('loadCliConfig model selection', () => {
|
||||
argv,
|
||||
);
|
||||
|
||||
expect(config.getModel()).toBe('auto-gemini-3');
|
||||
expect(config.getModel()).toBe('auto-gemini-2.5');
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -12,9 +12,11 @@ import { extensionsCommand } from '../commands/extensions.js';
|
||||
import { skillsCommand } from '../commands/skills.js';
|
||||
import { hooksCommand } from '../commands/hooks.js';
|
||||
import {
|
||||
Config,
|
||||
setGeminiMdFilename as setServerGeminiMdFilename,
|
||||
getCurrentGeminiMdFilename,
|
||||
ApprovalMode,
|
||||
DEFAULT_GEMINI_MODEL_AUTO,
|
||||
DEFAULT_GEMINI_EMBEDDING_MODEL,
|
||||
DEFAULT_FILE_FILTERING_OPTIONS,
|
||||
DEFAULT_MEMORY_FILE_FILTERING_OPTIONS,
|
||||
@@ -32,17 +34,12 @@ import {
|
||||
ASK_USER_TOOL_NAME,
|
||||
getVersion,
|
||||
PREVIEW_GEMINI_MODEL_AUTO,
|
||||
type HookDefinition,
|
||||
type HookEventName,
|
||||
type OutputFormat,
|
||||
coreEvents,
|
||||
GEMINI_MODEL_ALIAS_AUTO,
|
||||
getAdminErrorMessage,
|
||||
Config,
|
||||
applyAdminAllowlist,
|
||||
getAdminBlockedMcpServersMessage,
|
||||
} from '@google/gemini-cli-core';
|
||||
import type {
|
||||
HookDefinition,
|
||||
HookEventName,
|
||||
OutputFormat,
|
||||
} from '@google/gemini-cli-core';
|
||||
import {
|
||||
type Settings,
|
||||
@@ -73,6 +70,11 @@ export interface CliArgs {
|
||||
prompt: string | undefined;
|
||||
promptInteractive: string | undefined;
|
||||
|
||||
ralphWiggum: boolean | undefined;
|
||||
completionPromise: string | undefined;
|
||||
maxIterations: number | undefined;
|
||||
memoryFile: string | undefined;
|
||||
|
||||
yolo: boolean | undefined;
|
||||
approvalMode: string | undefined;
|
||||
allowedMcpServerNames: string[] | undefined;
|
||||
@@ -144,6 +146,31 @@ export async function parseArguments(
|
||||
description: 'Run in sandbox?',
|
||||
})
|
||||
|
||||
.option('ralph-wiggum', {
|
||||
alias: 'ralphWiggum',
|
||||
type: 'boolean',
|
||||
description:
|
||||
'Enable Ralph Wiggum mode (iterative loop with YOLO mode).',
|
||||
})
|
||||
.option('completion-promise', {
|
||||
alias: 'completionPromise',
|
||||
type: 'string',
|
||||
description:
|
||||
'The string to look for in the output to signal completion in Ralph Wiggum mode.',
|
||||
})
|
||||
.option('max-iterations', {
|
||||
alias: 'maxIterations',
|
||||
type: 'number',
|
||||
description: 'Maximum number of iterations for Ralph Wiggum mode.',
|
||||
})
|
||||
.option('memory-file', {
|
||||
alias: 'memoryFile',
|
||||
type: 'string',
|
||||
description:
|
||||
'Task-specific memory file for Ralph Wiggum mode (defaults to memories.md).',
|
||||
default: 'memories.md',
|
||||
})
|
||||
|
||||
.option('yolo', {
|
||||
alias: 'y',
|
||||
type: 'boolean',
|
||||
@@ -662,7 +689,9 @@ export async function loadCliConfig(
|
||||
);
|
||||
policyEngineConfig.nonInteractive = !interactive;
|
||||
|
||||
const defaultModel = PREVIEW_GEMINI_MODEL_AUTO;
|
||||
const defaultModel = settings.general?.previewFeatures
|
||||
? PREVIEW_GEMINI_MODEL_AUTO
|
||||
: DEFAULT_GEMINI_MODEL_AUTO;
|
||||
const specifiedModel =
|
||||
argv.model || process.env['GEMINI_MODEL'] || settings.model?.name;
|
||||
|
||||
@@ -688,24 +717,6 @@ export async function loadCliConfig(
|
||||
? mcpEnablementManager.getEnablementCallbacks()
|
||||
: undefined;
|
||||
|
||||
const adminAllowlist = settings.admin?.mcp?.config;
|
||||
let mcpServerCommand = mcpEnabled ? settings.mcp?.serverCommand : undefined;
|
||||
let mcpServers = mcpEnabled ? settings.mcpServers : {};
|
||||
|
||||
if (mcpEnabled && adminAllowlist && Object.keys(adminAllowlist).length > 0) {
|
||||
const result = applyAdminAllowlist(mcpServers, adminAllowlist);
|
||||
mcpServers = result.mcpServers;
|
||||
mcpServerCommand = undefined;
|
||||
|
||||
if (result.blockedServerNames && result.blockedServerNames.length > 0) {
|
||||
const message = getAdminBlockedMcpServersMessage(
|
||||
result.blockedServerNames,
|
||||
undefined,
|
||||
);
|
||||
coreEvents.emitConsoleLog('warn', message);
|
||||
}
|
||||
}
|
||||
|
||||
return new Config({
|
||||
sessionId,
|
||||
clientVersion: await getVersion(),
|
||||
@@ -717,6 +728,7 @@ export async function loadCliConfig(
|
||||
settings.context?.loadMemoryFromIncludeDirectories || false,
|
||||
debugMode,
|
||||
question,
|
||||
previewFeatures: settings.general?.previewFeatures,
|
||||
|
||||
coreTools: settings.tools?.core || undefined,
|
||||
allowedTools: allowedTools.length > 0 ? allowedTools : undefined,
|
||||
@@ -724,8 +736,8 @@ export async function loadCliConfig(
|
||||
excludeTools,
|
||||
toolDiscoveryCommand: settings.tools?.discoveryCommand,
|
||||
toolCallCommand: settings.tools?.callCommand,
|
||||
mcpServerCommand,
|
||||
mcpServers,
|
||||
mcpServerCommand: mcpEnabled ? settings.mcp?.serverCommand : undefined,
|
||||
mcpServers: mcpEnabled ? settings.mcpServers : {},
|
||||
mcpEnablementCallbacks,
|
||||
mcpEnabled,
|
||||
extensionsEnabled,
|
||||
@@ -777,11 +789,11 @@ export async function loadCliConfig(
|
||||
enableExtensionReloading: settings.experimental?.extensionReloading,
|
||||
enableAgents: settings.experimental?.enableAgents,
|
||||
plan: settings.experimental?.plan,
|
||||
enableEventDrivenScheduler: true,
|
||||
enableEventDrivenScheduler:
|
||||
settings.experimental?.enableEventDrivenScheduler,
|
||||
skillsSupport: settings.skills?.enabled ?? true,
|
||||
disabledSkills: settings.skills?.disabled,
|
||||
experimentalJitContext: settings.experimental?.jitContext,
|
||||
toolOutputMasking: settings.experimental?.toolOutputMasking,
|
||||
noBrowser: !!process.env['NO_BROWSER'],
|
||||
summarizeToolOutput: settings.model?.summarizeToolOutput,
|
||||
ideMode,
|
||||
@@ -799,12 +811,13 @@ export async function loadCliConfig(
|
||||
skipNextSpeakerCheck: settings.model?.skipNextSpeakerCheck,
|
||||
enablePromptCompletion: settings.general?.enablePromptCompletion,
|
||||
truncateToolOutputThreshold: settings.tools?.truncateToolOutputThreshold,
|
||||
truncateToolOutputLines: settings.tools?.truncateToolOutputLines,
|
||||
enableToolOutputTruncation: settings.tools?.enableToolOutputTruncation,
|
||||
eventEmitter: coreEvents,
|
||||
useWriteTodos: argv.useWriteTodos ?? settings.useWriteTodos,
|
||||
output: {
|
||||
format: (argv.outputFormat ?? settings.output?.format) as OutputFormat,
|
||||
},
|
||||
adaptiveThinking: settings.experimental?.adaptiveThinking,
|
||||
fakeResponses: argv.fakeResponses,
|
||||
recordResponses: argv.recordResponses,
|
||||
retryFetchErrors: settings.general?.retryFetchErrors,
|
||||
|
||||
@@ -48,8 +48,6 @@ import {
|
||||
type HookEventName,
|
||||
type ResolvedExtensionSetting,
|
||||
coreEvents,
|
||||
applyAdminAllowlist,
|
||||
getAdminBlockedMcpServersMessage,
|
||||
} from '@google/gemini-cli-core';
|
||||
import { maybeRequestConsentOrFail } from './extensions/consent.js';
|
||||
import { resolveEnvVarsInObject } from '../utils/envVarResolver.js';
|
||||
@@ -663,33 +661,12 @@ Would you like to attempt to install via "git clone" instead?`,
|
||||
if (this.settings.admin.mcp.enabled === false) {
|
||||
config.mcpServers = undefined;
|
||||
} else {
|
||||
// Apply admin allowlist if configured
|
||||
const adminAllowlist = this.settings.admin.mcp.config;
|
||||
if (adminAllowlist && Object.keys(adminAllowlist).length > 0) {
|
||||
const result = applyAdminAllowlist(
|
||||
config.mcpServers,
|
||||
adminAllowlist,
|
||||
);
|
||||
config.mcpServers = result.mcpServers;
|
||||
|
||||
if (result.blockedServerNames.length > 0) {
|
||||
const message = getAdminBlockedMcpServersMessage(
|
||||
result.blockedServerNames,
|
||||
undefined,
|
||||
);
|
||||
coreEvents.emitConsoleLog('warn', message);
|
||||
}
|
||||
}
|
||||
|
||||
// Then apply local filtering/sanitization
|
||||
if (config.mcpServers) {
|
||||
config.mcpServers = Object.fromEntries(
|
||||
Object.entries(config.mcpServers).map(([key, value]) => [
|
||||
key,
|
||||
filterMcpConfig(value),
|
||||
]),
|
||||
);
|
||||
}
|
||||
config.mcpServers = Object.fromEntries(
|
||||
Object.entries(config.mcpServers).map(([key, value]) => [
|
||||
key,
|
||||
filterMcpConfig(value),
|
||||
]),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,227 +0,0 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2026 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import {
|
||||
describe,
|
||||
it,
|
||||
expect,
|
||||
vi,
|
||||
beforeEach,
|
||||
afterEach,
|
||||
type Mock,
|
||||
} from 'vitest';
|
||||
import {
|
||||
ExtensionRegistryClient,
|
||||
type RegistryExtension,
|
||||
} from './extensionRegistryClient.js';
|
||||
import { fetchWithTimeout } from '@google/gemini-cli-core';
|
||||
|
||||
vi.mock('@google/gemini-cli-core', () => ({
|
||||
fetchWithTimeout: vi.fn(),
|
||||
}));
|
||||
|
||||
const mockExtensions: RegistryExtension[] = [
|
||||
{
|
||||
id: 'ext1',
|
||||
rank: 1,
|
||||
url: 'https://github.com/test/ext1',
|
||||
fullName: 'test/ext1',
|
||||
repoDescription: 'Test extension 1',
|
||||
stars: 100,
|
||||
lastUpdated: '2025-01-01T00:00:00Z',
|
||||
extensionName: 'extension-one',
|
||||
extensionVersion: '1.0.0',
|
||||
extensionDescription: 'First test extension',
|
||||
avatarUrl: 'https://example.com/avatar1.png',
|
||||
hasMCP: true,
|
||||
hasContext: false,
|
||||
isGoogleOwned: false,
|
||||
licenseKey: 'mit',
|
||||
hasHooks: false,
|
||||
hasCustomCommands: false,
|
||||
hasSkills: false,
|
||||
},
|
||||
{
|
||||
id: 'ext2',
|
||||
rank: 2,
|
||||
url: 'https://github.com/test/ext2',
|
||||
fullName: 'test/ext2',
|
||||
repoDescription: 'Test extension 2',
|
||||
stars: 50,
|
||||
lastUpdated: '2025-01-02T00:00:00Z',
|
||||
extensionName: 'extension-two',
|
||||
extensionVersion: '0.5.0',
|
||||
extensionDescription: 'Second test extension',
|
||||
avatarUrl: 'https://example.com/avatar2.png',
|
||||
hasMCP: false,
|
||||
hasContext: true,
|
||||
isGoogleOwned: true,
|
||||
licenseKey: 'apache-2.0',
|
||||
hasHooks: false,
|
||||
hasCustomCommands: false,
|
||||
hasSkills: false,
|
||||
},
|
||||
{
|
||||
id: 'ext3',
|
||||
rank: 3,
|
||||
url: 'https://github.com/test/ext3',
|
||||
fullName: 'test/ext3',
|
||||
repoDescription: 'Test extension 3',
|
||||
stars: 10,
|
||||
lastUpdated: '2025-01-03T00:00:00Z',
|
||||
extensionName: 'extension-three',
|
||||
extensionVersion: '0.1.0',
|
||||
extensionDescription: 'Third test extension',
|
||||
avatarUrl: 'https://example.com/avatar3.png',
|
||||
hasMCP: true,
|
||||
hasContext: true,
|
||||
isGoogleOwned: false,
|
||||
licenseKey: 'gpl-3.0',
|
||||
hasHooks: false,
|
||||
hasCustomCommands: false,
|
||||
hasSkills: false,
|
||||
},
|
||||
];
|
||||
|
||||
describe('ExtensionRegistryClient', () => {
|
||||
let client: ExtensionRegistryClient;
|
||||
let fetchMock: Mock;
|
||||
|
||||
beforeEach(() => {
|
||||
ExtensionRegistryClient.resetCache();
|
||||
client = new ExtensionRegistryClient();
|
||||
fetchMock = fetchWithTimeout as Mock;
|
||||
fetchMock.mockReset();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
it('should fetch and return extensions with pagination (default ranking)', async () => {
|
||||
fetchMock.mockResolvedValue({
|
||||
ok: true,
|
||||
json: async () => mockExtensions,
|
||||
});
|
||||
|
||||
const result = await client.getExtensions(1, 2);
|
||||
expect(result.extensions).toHaveLength(2);
|
||||
expect(result.extensions[0].id).toBe('ext1'); // rank 1
|
||||
expect(result.extensions[1].id).toBe('ext2'); // rank 2
|
||||
expect(result.total).toBe(3);
|
||||
expect(fetchMock).toHaveBeenCalledTimes(1);
|
||||
expect(fetchMock).toHaveBeenCalledWith(
|
||||
'https://geminicli.com/extensions.json',
|
||||
10000,
|
||||
);
|
||||
});
|
||||
|
||||
it('should return extensions sorted alphabetically', async () => {
|
||||
fetchMock.mockResolvedValue({
|
||||
ok: true,
|
||||
json: async () => mockExtensions,
|
||||
});
|
||||
|
||||
const result = await client.getExtensions(1, 3, 'alphabetical');
|
||||
expect(result.extensions).toHaveLength(3);
|
||||
expect(result.extensions[0].id).toBe('ext1');
|
||||
expect(result.extensions[1].id).toBe('ext3');
|
||||
expect(result.extensions[2].id).toBe('ext2');
|
||||
});
|
||||
|
||||
it('should return the second page of extensions', async () => {
|
||||
fetchMock.mockResolvedValue({
|
||||
ok: true,
|
||||
json: async () => mockExtensions,
|
||||
});
|
||||
|
||||
const result = await client.getExtensions(2, 2);
|
||||
expect(result.extensions).toHaveLength(1);
|
||||
expect(result.extensions[0].id).toBe('ext3');
|
||||
expect(result.total).toBe(3);
|
||||
});
|
||||
|
||||
it('should search extensions by name', async () => {
|
||||
fetchMock.mockResolvedValue({
|
||||
ok: true,
|
||||
json: async () => mockExtensions,
|
||||
});
|
||||
|
||||
const results = await client.searchExtensions('one');
|
||||
expect(results.length).toBeGreaterThanOrEqual(1);
|
||||
expect(results[0].id).toBe('ext1');
|
||||
});
|
||||
|
||||
it('should search extensions by description', async () => {
|
||||
fetchMock.mockResolvedValue({
|
||||
ok: true,
|
||||
json: async () => mockExtensions,
|
||||
});
|
||||
|
||||
const results = await client.searchExtensions('Second');
|
||||
expect(results.length).toBeGreaterThanOrEqual(1);
|
||||
expect(results[0].id).toBe('ext2');
|
||||
});
|
||||
|
||||
it('should get an extension by ID', async () => {
|
||||
fetchMock.mockResolvedValue({
|
||||
ok: true,
|
||||
json: async () => mockExtensions,
|
||||
});
|
||||
|
||||
const result = await client.getExtension('ext2');
|
||||
expect(result).toBeDefined();
|
||||
expect(result?.id).toBe('ext2');
|
||||
});
|
||||
|
||||
it('should return undefined if extension not found', async () => {
|
||||
fetchMock.mockResolvedValue({
|
||||
ok: true,
|
||||
json: async () => mockExtensions,
|
||||
});
|
||||
|
||||
const result = await client.getExtension('non-existent');
|
||||
expect(result).toBeUndefined();
|
||||
});
|
||||
|
||||
it('should cache the fetch result', async () => {
|
||||
fetchMock.mockResolvedValue({
|
||||
ok: true,
|
||||
json: async () => mockExtensions,
|
||||
});
|
||||
|
||||
await client.getExtensions();
|
||||
await client.getExtensions();
|
||||
|
||||
expect(fetchMock).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('should share the fetch result across instances', async () => {
|
||||
fetchMock.mockResolvedValue({
|
||||
ok: true,
|
||||
json: async () => mockExtensions,
|
||||
});
|
||||
|
||||
const client1 = new ExtensionRegistryClient();
|
||||
const client2 = new ExtensionRegistryClient();
|
||||
|
||||
await client1.getExtensions();
|
||||
await client2.getExtensions();
|
||||
|
||||
expect(fetchMock).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('should throw an error if fetch fails', async () => {
|
||||
fetchMock.mockResolvedValue({
|
||||
ok: false,
|
||||
statusText: 'Not Found',
|
||||
});
|
||||
|
||||
await expect(client.getExtensions()).rejects.toThrow(
|
||||
'Failed to fetch extensions: Not Found',
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -1,118 +0,0 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2026 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { fetchWithTimeout } from '@google/gemini-cli-core';
|
||||
import { AsyncFzf } from 'fzf';
|
||||
|
||||
export interface RegistryExtension {
|
||||
id: string;
|
||||
rank: number;
|
||||
url: string;
|
||||
fullName: string;
|
||||
repoDescription: string;
|
||||
stars: number;
|
||||
lastUpdated: string;
|
||||
extensionName: string;
|
||||
extensionVersion: string;
|
||||
extensionDescription: string;
|
||||
avatarUrl: string;
|
||||
hasMCP: boolean;
|
||||
hasContext: boolean;
|
||||
hasHooks: boolean;
|
||||
hasSkills: boolean;
|
||||
hasCustomCommands: boolean;
|
||||
isGoogleOwned: boolean;
|
||||
licenseKey: string;
|
||||
}
|
||||
|
||||
export class ExtensionRegistryClient {
|
||||
private static readonly REGISTRY_URL =
|
||||
'https://geminicli.com/extensions.json';
|
||||
private static readonly FETCH_TIMEOUT_MS = 10000; // 10 seconds
|
||||
|
||||
private static fetchPromise: Promise<RegistryExtension[]> | null = null;
|
||||
|
||||
/** @internal */
|
||||
static resetCache() {
|
||||
ExtensionRegistryClient.fetchPromise = null;
|
||||
}
|
||||
|
||||
async getExtensions(
|
||||
page: number = 1,
|
||||
limit: number = 10,
|
||||
orderBy: 'ranking' | 'alphabetical' = 'ranking',
|
||||
): Promise<{ extensions: RegistryExtension[]; total: number }> {
|
||||
const allExtensions = [...(await this.fetchAllExtensions())];
|
||||
|
||||
switch (orderBy) {
|
||||
case 'ranking':
|
||||
allExtensions.sort((a, b) => a.rank - b.rank);
|
||||
break;
|
||||
case 'alphabetical':
|
||||
allExtensions.sort((a, b) =>
|
||||
a.extensionName.localeCompare(b.extensionName),
|
||||
);
|
||||
break;
|
||||
default: {
|
||||
const _exhaustiveCheck: never = orderBy;
|
||||
throw new Error(`Unhandled orderBy: ${_exhaustiveCheck}`);
|
||||
}
|
||||
}
|
||||
|
||||
const startIndex = (page - 1) * limit;
|
||||
const endIndex = startIndex + limit;
|
||||
return {
|
||||
extensions: allExtensions.slice(startIndex, endIndex),
|
||||
total: allExtensions.length,
|
||||
};
|
||||
}
|
||||
|
||||
async searchExtensions(query: string): Promise<RegistryExtension[]> {
|
||||
const allExtensions = await this.fetchAllExtensions();
|
||||
if (!query.trim()) {
|
||||
return allExtensions;
|
||||
}
|
||||
|
||||
const fzf = new AsyncFzf(allExtensions, {
|
||||
selector: (ext: RegistryExtension) =>
|
||||
`${ext.extensionName} ${ext.extensionDescription} ${ext.fullName}`,
|
||||
fuzzy: 'v2',
|
||||
});
|
||||
const results = await fzf.find(query);
|
||||
return results.map((r: { item: RegistryExtension }) => r.item);
|
||||
}
|
||||
|
||||
async getExtension(id: string): Promise<RegistryExtension | undefined> {
|
||||
const allExtensions = await this.fetchAllExtensions();
|
||||
return allExtensions.find((ext) => ext.id === id);
|
||||
}
|
||||
|
||||
private async fetchAllExtensions(): Promise<RegistryExtension[]> {
|
||||
if (ExtensionRegistryClient.fetchPromise) {
|
||||
return ExtensionRegistryClient.fetchPromise;
|
||||
}
|
||||
|
||||
ExtensionRegistryClient.fetchPromise = (async () => {
|
||||
try {
|
||||
const response = await fetchWithTimeout(
|
||||
ExtensionRegistryClient.REGISTRY_URL,
|
||||
ExtensionRegistryClient.FETCH_TIMEOUT_MS,
|
||||
);
|
||||
if (!response.ok) {
|
||||
throw new Error(`Failed to fetch extensions: ${response.statusText}`);
|
||||
}
|
||||
|
||||
return (await response.json()) as RegistryExtension[];
|
||||
} catch (error) {
|
||||
// Clear the promise on failure so that subsequent calls can try again
|
||||
ExtensionRegistryClient.fetchPromise = null;
|
||||
throw error;
|
||||
}
|
||||
})();
|
||||
|
||||
return ExtensionRegistryClient.fetchPromise;
|
||||
}
|
||||
}
|
||||
@@ -821,74 +821,5 @@ describe('extensionSettings', () => {
|
||||
);
|
||||
// Should complete without error
|
||||
});
|
||||
|
||||
it('should throw error if env var name contains invalid characters', async () => {
|
||||
const securityConfig: ExtensionConfig = {
|
||||
name: 'test-ext',
|
||||
version: '1.0.0',
|
||||
settings: [{ name: 's2', description: 'd2', envVar: 'VAR-BAD' }],
|
||||
};
|
||||
mockRequestSetting.mockResolvedValue('value');
|
||||
|
||||
await expect(
|
||||
updateSetting(
|
||||
securityConfig,
|
||||
'12345',
|
||||
'VAR-BAD',
|
||||
mockRequestSetting,
|
||||
ExtensionSettingScope.USER,
|
||||
tempWorkspaceDir,
|
||||
),
|
||||
).rejects.toThrow(/Invalid environment variable name/);
|
||||
});
|
||||
|
||||
it('should throw error if env var value contains newlines', async () => {
|
||||
mockRequestSetting.mockResolvedValue('value\nwith\nnewlines');
|
||||
|
||||
await expect(
|
||||
updateSetting(
|
||||
config,
|
||||
'12345',
|
||||
'VAR1',
|
||||
mockRequestSetting,
|
||||
ExtensionSettingScope.USER,
|
||||
tempWorkspaceDir,
|
||||
),
|
||||
).rejects.toThrow(/Invalid environment variable value/);
|
||||
});
|
||||
|
||||
it('should quote values with spaces', async () => {
|
||||
mockRequestSetting.mockResolvedValue('value with spaces');
|
||||
|
||||
await updateSetting(
|
||||
config,
|
||||
'12345',
|
||||
'VAR1',
|
||||
mockRequestSetting,
|
||||
ExtensionSettingScope.USER,
|
||||
tempWorkspaceDir,
|
||||
);
|
||||
|
||||
const expectedEnvPath = path.join(extensionDir, '.env');
|
||||
const actualContent = await fsPromises.readFile(expectedEnvPath, 'utf-8');
|
||||
expect(actualContent).toContain('VAR1="value with spaces"');
|
||||
});
|
||||
|
||||
it('should escape quotes in values', async () => {
|
||||
mockRequestSetting.mockResolvedValue('value with "quotes"');
|
||||
|
||||
await updateSetting(
|
||||
config,
|
||||
'12345',
|
||||
'VAR1',
|
||||
mockRequestSetting,
|
||||
ExtensionSettingScope.USER,
|
||||
tempWorkspaceDir,
|
||||
);
|
||||
|
||||
const expectedEnvPath = path.join(extensionDir, '.env');
|
||||
const actualContent = await fsPromises.readFile(expectedEnvPath, 'utf-8');
|
||||
expect(actualContent).toContain('VAR1="value with \\"quotes\\""');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -130,19 +130,7 @@ export async function maybePromptForSettings(
|
||||
function formatEnvContent(settings: Record<string, string>): string {
|
||||
let envContent = '';
|
||||
for (const [key, value] of Object.entries(settings)) {
|
||||
if (!/^[a-zA-Z_][a-zA-Z0-9_]*$/.test(key)) {
|
||||
throw new Error(
|
||||
`Invalid environment variable name: "${key}". Must contain only alphanumeric characters and underscores.`,
|
||||
);
|
||||
}
|
||||
if (value.includes('\n') || value.includes('\r')) {
|
||||
throw new Error(
|
||||
`Invalid environment variable value for "${key}". Values cannot contain newlines.`,
|
||||
);
|
||||
}
|
||||
const formattedValue = value.includes(' ')
|
||||
? `"${value.replace(/\\/g, '\\\\').replace(/"/g, '\\"')}"`
|
||||
: value;
|
||||
const formattedValue = value.includes(' ') ? `"${value}"` : value;
|
||||
envContent += `${key}=${formattedValue}\n`;
|
||||
}
|
||||
return envContent;
|
||||
|
||||
@@ -80,7 +80,6 @@ export enum Command {
|
||||
UNFOCUS_BACKGROUND_SHELL = 'backgroundShell.unfocus',
|
||||
UNFOCUS_BACKGROUND_SHELL_LIST = 'backgroundShell.listUnfocus',
|
||||
SHOW_BACKGROUND_SHELL_UNFOCUS_WARNING = 'backgroundShell.unfocusWarning',
|
||||
SHOW_SHELL_INPUT_UNFOCUS_WARNING = 'shellInput.unfocusWarning',
|
||||
|
||||
// App Controls
|
||||
SHOW_ERROR_DETAILS = 'app.showErrorDetails',
|
||||
@@ -282,7 +281,6 @@ export const defaultKeyBindings: KeyBindingConfig = {
|
||||
[Command.SHOW_BACKGROUND_SHELL_UNFOCUS_WARNING]: [
|
||||
{ key: 'tab', shift: false },
|
||||
],
|
||||
[Command.SHOW_SHELL_INPUT_UNFOCUS_WARNING]: [{ key: 'tab', shift: false }],
|
||||
[Command.BACKGROUND_SHELL_SELECT]: [{ key: 'return' }],
|
||||
[Command.BACKGROUND_SHELL_ESCAPE]: [{ key: 'escape' }],
|
||||
[Command.SHOW_MORE_LINES]: [
|
||||
@@ -290,7 +288,7 @@ export const defaultKeyBindings: KeyBindingConfig = {
|
||||
{ key: 's', ctrl: true },
|
||||
],
|
||||
[Command.FOCUS_SHELL_INPUT]: [{ key: 'tab', shift: false }],
|
||||
[Command.UNFOCUS_SHELL_INPUT]: [{ key: 'tab', shift: true }],
|
||||
[Command.UNFOCUS_SHELL_INPUT]: [{ key: 'tab' }],
|
||||
[Command.CLEAR_SCREEN]: [{ key: 'l', ctrl: true }],
|
||||
[Command.RESTART_APP]: [{ key: 'r' }],
|
||||
[Command.SUSPEND_APP]: [{ key: 'z', ctrl: true }],
|
||||
@@ -407,7 +405,6 @@ export const commandCategories: readonly CommandCategory[] = [
|
||||
Command.UNFOCUS_BACKGROUND_SHELL,
|
||||
Command.UNFOCUS_BACKGROUND_SHELL_LIST,
|
||||
Command.SHOW_BACKGROUND_SHELL_UNFOCUS_WARNING,
|
||||
Command.SHOW_SHELL_INPUT_UNFOCUS_WARNING,
|
||||
Command.FOCUS_SHELL_INPUT,
|
||||
Command.UNFOCUS_SHELL_INPUT,
|
||||
Command.CLEAR_SCREEN,
|
||||
@@ -499,23 +496,16 @@ export const commandDescriptions: Readonly<Record<Command, string>> = {
|
||||
'Cycle through approval modes: default (prompt), auto_edit (auto-approve edits), and plan (read-only).',
|
||||
[Command.SHOW_MORE_LINES]:
|
||||
'Expand a height-constrained response to show additional lines when not in alternate buffer mode.',
|
||||
[Command.BACKGROUND_SHELL_SELECT]:
|
||||
'Confirm selection in background shell list.',
|
||||
[Command.BACKGROUND_SHELL_ESCAPE]: 'Dismiss background shell list.',
|
||||
[Command.TOGGLE_BACKGROUND_SHELL]:
|
||||
'Toggle current background shell visibility.',
|
||||
[Command.TOGGLE_BACKGROUND_SHELL_LIST]: 'Toggle background shell list.',
|
||||
[Command.KILL_BACKGROUND_SHELL]: 'Kill the active background shell.',
|
||||
[Command.UNFOCUS_BACKGROUND_SHELL]:
|
||||
'Move focus from background shell to Gemini.',
|
||||
[Command.UNFOCUS_BACKGROUND_SHELL_LIST]:
|
||||
'Move focus from background shell list to Gemini.',
|
||||
[Command.SHOW_BACKGROUND_SHELL_UNFOCUS_WARNING]:
|
||||
'Show warning when trying to unfocus background shell via Tab.',
|
||||
[Command.SHOW_SHELL_INPUT_UNFOCUS_WARNING]:
|
||||
'Show warning when trying to unfocus shell input via Tab.',
|
||||
[Command.FOCUS_SHELL_INPUT]: 'Move focus from Gemini to the active shell.',
|
||||
[Command.UNFOCUS_SHELL_INPUT]: 'Move focus from the shell back to Gemini.',
|
||||
[Command.BACKGROUND_SHELL_SELECT]: 'Enter',
|
||||
[Command.BACKGROUND_SHELL_ESCAPE]: 'Esc',
|
||||
[Command.TOGGLE_BACKGROUND_SHELL]: 'Ctrl+B',
|
||||
[Command.TOGGLE_BACKGROUND_SHELL_LIST]: 'Ctrl+L',
|
||||
[Command.KILL_BACKGROUND_SHELL]: 'Ctrl+K',
|
||||
[Command.UNFOCUS_BACKGROUND_SHELL]: 'Shift+Tab',
|
||||
[Command.UNFOCUS_BACKGROUND_SHELL_LIST]: 'Tab',
|
||||
[Command.SHOW_BACKGROUND_SHELL_UNFOCUS_WARNING]: 'Tab',
|
||||
[Command.FOCUS_SHELL_INPUT]: 'Focus the shell input from the gemini input.',
|
||||
[Command.UNFOCUS_SHELL_INPUT]: 'Focus the Gemini input from the shell input.',
|
||||
[Command.CLEAR_SCREEN]: 'Clear the terminal screen and redraw the UI.',
|
||||
[Command.RESTART_APP]: 'Restart the application.',
|
||||
[Command.SUSPEND_APP]: 'Suspend the application (not yet implemented).',
|
||||
|
||||
@@ -323,65 +323,116 @@ describe('Policy Engine Integration Tests', () => {
|
||||
).toBe(PolicyDecision.DENY);
|
||||
});
|
||||
|
||||
describe.each(['write_file', 'replace'])(
|
||||
'Plan Mode policy for %s',
|
||||
(toolName) => {
|
||||
it(`should allow ${toolName} to plans directory`, async () => {
|
||||
const settings: Settings = {};
|
||||
const config = await createPolicyEngineConfig(
|
||||
settings,
|
||||
ApprovalMode.PLAN,
|
||||
);
|
||||
const engine = new PolicyEngine(config);
|
||||
it('should allow write_file to plans directory in Plan mode', async () => {
|
||||
const settings: Settings = {};
|
||||
|
||||
// Valid plan file paths
|
||||
const validPaths = [
|
||||
'/home/user/.gemini/tmp/a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2/plans/my-plan.md',
|
||||
'/home/user/.gemini/tmp/a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2/plans/feature_auth.md',
|
||||
'/home/user/.gemini/tmp/new-temp_dir_123/plans/plan.md', // new style of temp directory
|
||||
];
|
||||
const config = await createPolicyEngineConfig(
|
||||
settings,
|
||||
ApprovalMode.PLAN,
|
||||
);
|
||||
const engine = new PolicyEngine(config);
|
||||
|
||||
for (const file_path of validPaths) {
|
||||
expect(
|
||||
(
|
||||
await engine.check(
|
||||
{ name: toolName, args: { file_path } },
|
||||
undefined,
|
||||
)
|
||||
).decision,
|
||||
).toBe(PolicyDecision.ALLOW);
|
||||
}
|
||||
});
|
||||
// Valid plan file path (64-char hex hash, .md extension, safe filename)
|
||||
const validPlanPath =
|
||||
'/home/user/.gemini/tmp/a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2/plans/my-plan.md';
|
||||
expect(
|
||||
(
|
||||
await engine.check(
|
||||
{ name: 'write_file', args: { file_path: validPlanPath } },
|
||||
undefined,
|
||||
)
|
||||
).decision,
|
||||
).toBe(PolicyDecision.ALLOW);
|
||||
|
||||
it(`should deny ${toolName} outside plans directory`, async () => {
|
||||
const settings: Settings = {};
|
||||
const config = await createPolicyEngineConfig(
|
||||
settings,
|
||||
ApprovalMode.PLAN,
|
||||
);
|
||||
const engine = new PolicyEngine(config);
|
||||
// Valid plan with underscore in filename
|
||||
const validPlanPath2 =
|
||||
'/home/user/.gemini/tmp/a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2/plans/feature_auth.md';
|
||||
expect(
|
||||
(
|
||||
await engine.check(
|
||||
{ name: 'write_file', args: { file_path: validPlanPath2 } },
|
||||
undefined,
|
||||
)
|
||||
).decision,
|
||||
).toBe(PolicyDecision.ALLOW);
|
||||
});
|
||||
|
||||
const invalidPaths = [
|
||||
'/project/src/file.ts', // Workspace
|
||||
'/home/user/.gemini/tmp/a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2/plans/script.js', // Wrong extension
|
||||
'/home/user/.gemini/tmp/a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2/plans/../../../etc/passwd.md', // Path traversal
|
||||
'/home/user/.gemini/tmp/a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2/plans/subdir/plan.md', // Subdirectory
|
||||
'/home/user/.gemini/non-tmp/new-temp_dir_123/plans/plan.md', // outside of temp dir
|
||||
];
|
||||
it('should deny write_file outside plans directory in Plan mode', async () => {
|
||||
const settings: Settings = {};
|
||||
|
||||
for (const file_path of invalidPaths) {
|
||||
expect(
|
||||
(
|
||||
await engine.check(
|
||||
{ name: toolName, args: { file_path } },
|
||||
undefined,
|
||||
)
|
||||
).decision,
|
||||
).toBe(PolicyDecision.DENY);
|
||||
}
|
||||
});
|
||||
},
|
||||
);
|
||||
const config = await createPolicyEngineConfig(
|
||||
settings,
|
||||
ApprovalMode.PLAN,
|
||||
);
|
||||
const engine = new PolicyEngine(config);
|
||||
|
||||
// Write to workspace (not plans dir) should be denied
|
||||
expect(
|
||||
(
|
||||
await engine.check(
|
||||
{ name: 'write_file', args: { file_path: '/project/src/file.ts' } },
|
||||
undefined,
|
||||
)
|
||||
).decision,
|
||||
).toBe(PolicyDecision.DENY);
|
||||
|
||||
// Write to plans dir but wrong extension should be denied
|
||||
const wrongExtPath =
|
||||
'/home/user/.gemini/tmp/a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2/plans/script.js';
|
||||
expect(
|
||||
(
|
||||
await engine.check(
|
||||
{ name: 'write_file', args: { file_path: wrongExtPath } },
|
||||
undefined,
|
||||
)
|
||||
).decision,
|
||||
).toBe(PolicyDecision.DENY);
|
||||
|
||||
// Path traversal attempt should be denied (filename contains /)
|
||||
const traversalPath =
|
||||
'/home/user/.gemini/tmp/a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2/plans/../../../etc/passwd.md';
|
||||
expect(
|
||||
(
|
||||
await engine.check(
|
||||
{ name: 'write_file', args: { file_path: traversalPath } },
|
||||
undefined,
|
||||
)
|
||||
).decision,
|
||||
).toBe(PolicyDecision.DENY);
|
||||
|
||||
// Invalid hash length should be denied
|
||||
const shortHashPath = '/home/user/.gemini/tmp/abc123/plans/plan.md';
|
||||
expect(
|
||||
(
|
||||
await engine.check(
|
||||
{ name: 'write_file', args: { file_path: shortHashPath } },
|
||||
undefined,
|
||||
)
|
||||
).decision,
|
||||
).toBe(PolicyDecision.DENY);
|
||||
});
|
||||
|
||||
it('should deny write_file to subdirectories in Plan mode', async () => {
|
||||
const settings: Settings = {};
|
||||
|
||||
const config = await createPolicyEngineConfig(
|
||||
settings,
|
||||
ApprovalMode.PLAN,
|
||||
);
|
||||
const engine = new PolicyEngine(config);
|
||||
|
||||
// Write to subdirectory should be denied
|
||||
const subdirPath =
|
||||
'/home/user/.gemini/tmp/a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2/plans/subdir/plan.md';
|
||||
expect(
|
||||
(
|
||||
await engine.check(
|
||||
{ name: 'write_file', args: { file_path: subdirPath } },
|
||||
undefined,
|
||||
)
|
||||
).decision,
|
||||
).toBe(PolicyDecision.DENY);
|
||||
});
|
||||
|
||||
it('should verify priority ordering works correctly in practice', async () => {
|
||||
const settings: Settings = {
|
||||
@@ -434,8 +485,8 @@ describe('Policy Engine Integration Tests', () => {
|
||||
expect(mcpServerRule?.priority).toBe(2.1); // MCP allowed server
|
||||
|
||||
const readOnlyToolRule = rules.find((r) => r.toolName === 'glob');
|
||||
// Priority 70 in default tier → 1.07 (Overriding Plan Mode Deny)
|
||||
expect(readOnlyToolRule?.priority).toBeCloseTo(1.07, 5);
|
||||
// Priority 50 in default tier → 1.05
|
||||
expect(readOnlyToolRule?.priority).toBeCloseTo(1.05, 5);
|
||||
|
||||
// Verify the engine applies these priorities correctly
|
||||
expect(
|
||||
@@ -590,8 +641,8 @@ describe('Policy Engine Integration Tests', () => {
|
||||
expect(server1Rule?.priority).toBe(2.1); // Allowed servers (user tier)
|
||||
|
||||
const globRule = rules.find((r) => r.toolName === 'glob');
|
||||
// Priority 70 in default tier → 1.07
|
||||
expect(globRule?.priority).toBeCloseTo(1.07, 5); // Auto-accept read-only
|
||||
// Priority 50 in default tier → 1.05
|
||||
expect(globRule?.priority).toBeCloseTo(1.05, 5); // Auto-accept read-only
|
||||
|
||||
// The PolicyEngine will sort these by priority when it's created
|
||||
const engine = new PolicyEngine(config);
|
||||
|
||||
@@ -76,11 +76,7 @@ import {
|
||||
LoadedSettings,
|
||||
sanitizeEnvVar,
|
||||
} from './settings.js';
|
||||
import {
|
||||
FatalConfigError,
|
||||
GEMINI_DIR,
|
||||
type MCPServerConfig,
|
||||
} from '@google/gemini-cli-core';
|
||||
import { FatalConfigError, GEMINI_DIR } from '@google/gemini-cli-core';
|
||||
import { updateSettingsFilePreservingFormat } from '../utils/commentJson.js';
|
||||
import {
|
||||
getSettingsSchema,
|
||||
@@ -2078,7 +2074,7 @@ describe('Settings Loading and Merging', () => {
|
||||
);
|
||||
});
|
||||
|
||||
it('should migrate disableUpdateNag to enableAutoUpdateNotification in memory but not save for system and system defaults settings', () => {
|
||||
it('should migrate disableUpdateNag to enableAutoUpdateNotification in system and system defaults settings', () => {
|
||||
const systemSettingsContent = {
|
||||
general: {
|
||||
disableUpdateNag: true,
|
||||
@@ -2103,10 +2099,9 @@ describe('Settings Loading and Merging', () => {
|
||||
},
|
||||
);
|
||||
|
||||
const feedbackSpy = mockCoreEvents.emitFeedback;
|
||||
const settings = loadSettings(MOCK_WORKSPACE_DIR);
|
||||
|
||||
// Verify system settings were migrated in memory
|
||||
// Verify system settings were migrated
|
||||
expect(settings.system.settings.general).toHaveProperty(
|
||||
'enableAutoUpdateNotification',
|
||||
);
|
||||
@@ -2116,7 +2111,7 @@ describe('Settings Loading and Merging', () => {
|
||||
],
|
||||
).toBe(false);
|
||||
|
||||
// Verify system defaults settings were migrated in memory
|
||||
// Verify system defaults settings were migrated
|
||||
expect(settings.systemDefaults.settings.general).toHaveProperty(
|
||||
'enableAutoUpdateNotification',
|
||||
);
|
||||
@@ -2128,35 +2123,22 @@ describe('Settings Loading and Merging', () => {
|
||||
|
||||
// Merged should also reflect it (system overrides defaults, but both are migrated)
|
||||
expect(settings.merged.general?.enableAutoUpdateNotification).toBe(false);
|
||||
|
||||
// Verify it was NOT saved back to disk
|
||||
expect(updateSettingsFilePreservingFormat).not.toHaveBeenCalledWith(
|
||||
getSystemSettingsPath(),
|
||||
expect.anything(),
|
||||
);
|
||||
expect(updateSettingsFilePreservingFormat).not.toHaveBeenCalledWith(
|
||||
getSystemDefaultsPath(),
|
||||
expect.anything(),
|
||||
);
|
||||
|
||||
// Verify warnings were shown
|
||||
expect(feedbackSpy).toHaveBeenCalledWith(
|
||||
'warning',
|
||||
expect.stringContaining(
|
||||
'The system configuration contains deprecated settings',
|
||||
),
|
||||
);
|
||||
expect(feedbackSpy).toHaveBeenCalledWith(
|
||||
'warning',
|
||||
expect.stringContaining(
|
||||
'The system default configuration contains deprecated settings',
|
||||
),
|
||||
);
|
||||
});
|
||||
|
||||
it('should not throw if experimental settings are missing', () => {
|
||||
it('should migrate experimental agent settings to agents overrides', () => {
|
||||
const userSettingsContent = {
|
||||
experimental: {},
|
||||
experimental: {
|
||||
codebaseInvestigatorSettings: {
|
||||
enabled: true,
|
||||
maxNumTurns: 15,
|
||||
maxTimeMinutes: 5,
|
||||
thinkingBudget: 16384,
|
||||
model: 'gemini-1.5-pro',
|
||||
},
|
||||
cliHelpAgentSettings: {
|
||||
enabled: false,
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
vi.mocked(fs.existsSync).mockReturnValue(true);
|
||||
@@ -2168,7 +2150,29 @@ describe('Settings Loading and Merging', () => {
|
||||
},
|
||||
);
|
||||
|
||||
expect(() => loadSettings(MOCK_WORKSPACE_DIR)).not.toThrow();
|
||||
const settings = loadSettings(MOCK_WORKSPACE_DIR);
|
||||
|
||||
// Verify migration to agents.overrides
|
||||
expect(settings.user.settings.agents?.overrides).toMatchObject({
|
||||
codebase_investigator: {
|
||||
enabled: true,
|
||||
runConfig: {
|
||||
maxTurns: 15,
|
||||
maxTimeMinutes: 5,
|
||||
},
|
||||
modelConfig: {
|
||||
model: 'gemini-1.5-pro',
|
||||
generateContentConfig: {
|
||||
thinkingConfig: {
|
||||
thinkingBudget: 16384,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
cli_help: {
|
||||
enabled: false,
|
||||
},
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -2346,28 +2350,6 @@ describe('Settings Loading and Merging', () => {
|
||||
expect(loadedSettings.merged.admin?.extensions?.enabled).toBe(true);
|
||||
});
|
||||
|
||||
it('should un-nest MCP configuration from remote settings', () => {
|
||||
const loadedSettings = loadSettings(MOCK_WORKSPACE_DIR);
|
||||
const mcpServers: Record<string, MCPServerConfig> = {
|
||||
'admin-server': {
|
||||
url: 'http://admin-mcp.com',
|
||||
type: 'sse',
|
||||
trust: true,
|
||||
},
|
||||
};
|
||||
|
||||
loadedSettings.setRemoteAdminSettings({
|
||||
mcpSetting: {
|
||||
mcpEnabled: true,
|
||||
mcpConfig: {
|
||||
mcpServers,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
expect(loadedSettings.merged.admin?.mcp?.config).toEqual(mcpServers);
|
||||
});
|
||||
|
||||
it('should set skills based on unmanagedCapabilitiesEnabled', () => {
|
||||
const loadedSettings = loadSettings();
|
||||
loadedSettings.setRemoteAdminSettings({
|
||||
|
||||
@@ -194,7 +194,6 @@ export interface SettingsFile {
|
||||
originalSettings: Settings;
|
||||
path: string;
|
||||
rawJson?: string;
|
||||
readOnly?: boolean;
|
||||
}
|
||||
|
||||
function setNestedProperty(
|
||||
@@ -379,32 +378,25 @@ export class LoadedSettings {
|
||||
}
|
||||
}
|
||||
|
||||
private isPersistable(settingsFile: SettingsFile): boolean {
|
||||
return !settingsFile.readOnly;
|
||||
}
|
||||
|
||||
setValue(scope: LoadableSettingScope, key: string, value: unknown): void {
|
||||
const settingsFile = this.forScope(scope);
|
||||
|
||||
// Clone value to prevent reference sharing
|
||||
// Clone value to prevent reference sharing between settings and originalSettings
|
||||
const valueToSet =
|
||||
typeof value === 'object' && value !== null
|
||||
? structuredClone(value)
|
||||
: value;
|
||||
|
||||
setNestedProperty(settingsFile.settings, key, valueToSet);
|
||||
|
||||
if (this.isPersistable(settingsFile)) {
|
||||
// Use a fresh clone for originalSettings to ensure total independence
|
||||
setNestedProperty(
|
||||
settingsFile.originalSettings,
|
||||
key,
|
||||
structuredClone(valueToSet),
|
||||
);
|
||||
saveSettings(settingsFile);
|
||||
}
|
||||
// Use a fresh clone for originalSettings to ensure total independence
|
||||
setNestedProperty(
|
||||
settingsFile.originalSettings,
|
||||
key,
|
||||
structuredClone(valueToSet),
|
||||
);
|
||||
|
||||
this._merged = this.computeMergedSettings();
|
||||
saveSettings(settingsFile);
|
||||
coreEvents.emitSettingsChanged();
|
||||
}
|
||||
|
||||
@@ -420,10 +412,7 @@ export class LoadedSettings {
|
||||
}
|
||||
|
||||
admin.secureModeEnabled = !strictModeDisabled;
|
||||
admin.mcp = {
|
||||
enabled: mcpSetting?.mcpEnabled,
|
||||
config: mcpSetting?.mcpConfig?.mcpServers,
|
||||
};
|
||||
admin.mcp = { enabled: mcpSetting?.mcpEnabled };
|
||||
admin.extensions = {
|
||||
enabled: cliFeatureSetting?.extensionsSetting?.extensionsEnabled,
|
||||
};
|
||||
@@ -724,28 +713,24 @@ export function loadSettings(
|
||||
settings: systemSettings,
|
||||
originalSettings: systemOriginalSettings,
|
||||
rawJson: systemResult.rawJson,
|
||||
readOnly: true,
|
||||
},
|
||||
{
|
||||
path: systemDefaultsPath,
|
||||
settings: systemDefaultSettings,
|
||||
originalSettings: systemDefaultsOriginalSettings,
|
||||
rawJson: systemDefaultsResult.rawJson,
|
||||
readOnly: true,
|
||||
},
|
||||
{
|
||||
path: USER_SETTINGS_PATH,
|
||||
settings: userSettings,
|
||||
originalSettings: userOriginalSettings,
|
||||
rawJson: userResult.rawJson,
|
||||
readOnly: false,
|
||||
},
|
||||
{
|
||||
path: workspaceSettingsPath,
|
||||
settings: workspaceSettings,
|
||||
originalSettings: workspaceOriginalSettings,
|
||||
rawJson: workspaceResult.rawJson,
|
||||
readOnly: false,
|
||||
},
|
||||
isTrusted,
|
||||
settingsErrors,
|
||||
@@ -770,26 +755,17 @@ export function migrateDeprecatedSettings(
|
||||
removeDeprecated = false,
|
||||
): boolean {
|
||||
let anyModified = false;
|
||||
const systemWarnings: Map<LoadableSettingScope, string[]> = new Map();
|
||||
|
||||
/**
|
||||
* Helper to migrate a boolean setting and track it if it's deprecated.
|
||||
*/
|
||||
const migrateBoolean = (
|
||||
settings: Record<string, unknown>,
|
||||
oldKey: string,
|
||||
newKey: string,
|
||||
prefix: string,
|
||||
foundDeprecated?: string[],
|
||||
): boolean => {
|
||||
let modified = false;
|
||||
const oldValue = settings[oldKey];
|
||||
const newValue = settings[newKey];
|
||||
|
||||
if (typeof oldValue === 'boolean') {
|
||||
if (foundDeprecated) {
|
||||
foundDeprecated.push(prefix ? `${prefix}.${oldKey}` : oldKey);
|
||||
}
|
||||
if (typeof newValue === 'boolean') {
|
||||
// Both exist, trust the new one
|
||||
if (removeDeprecated) {
|
||||
@@ -809,9 +785,7 @@ export function migrateDeprecatedSettings(
|
||||
};
|
||||
|
||||
const processScope = (scope: LoadableSettingScope) => {
|
||||
const settingsFile = loadedSettings.forScope(scope);
|
||||
const settings = settingsFile.settings;
|
||||
const foundDeprecated: string[] = [];
|
||||
const settings = loadedSettings.forScope(scope).settings;
|
||||
|
||||
// Migrate general settings
|
||||
const generalSettings = settings.general as
|
||||
@@ -822,27 +796,18 @@ export function migrateDeprecatedSettings(
|
||||
let modified = false;
|
||||
|
||||
modified =
|
||||
migrateBoolean(
|
||||
newGeneral,
|
||||
'disableAutoUpdate',
|
||||
'enableAutoUpdate',
|
||||
'general',
|
||||
foundDeprecated,
|
||||
) || modified;
|
||||
migrateBoolean(newGeneral, 'disableAutoUpdate', 'enableAutoUpdate') ||
|
||||
modified;
|
||||
modified =
|
||||
migrateBoolean(
|
||||
newGeneral,
|
||||
'disableUpdateNag',
|
||||
'enableAutoUpdateNotification',
|
||||
'general',
|
||||
foundDeprecated,
|
||||
) || modified;
|
||||
|
||||
if (modified) {
|
||||
loadedSettings.setValue(scope, 'general', newGeneral);
|
||||
if (!settingsFile.readOnly) {
|
||||
anyModified = true;
|
||||
}
|
||||
anyModified = true;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -861,15 +826,11 @@ export function migrateDeprecatedSettings(
|
||||
newAccessibility,
|
||||
'disableLoadingPhrases',
|
||||
'enableLoadingPhrases',
|
||||
'ui.accessibility',
|
||||
foundDeprecated,
|
||||
)
|
||||
) {
|
||||
newUi['accessibility'] = newAccessibility;
|
||||
loadedSettings.setValue(scope, 'ui', newUi);
|
||||
if (!settingsFile.readOnly) {
|
||||
anyModified = true;
|
||||
}
|
||||
anyModified = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -891,22 +852,23 @@ export function migrateDeprecatedSettings(
|
||||
newFileFiltering,
|
||||
'disableFuzzySearch',
|
||||
'enableFuzzySearch',
|
||||
'context.fileFiltering',
|
||||
foundDeprecated,
|
||||
)
|
||||
) {
|
||||
newContext['fileFiltering'] = newFileFiltering;
|
||||
loadedSettings.setValue(scope, 'context', newContext);
|
||||
if (!settingsFile.readOnly) {
|
||||
anyModified = true;
|
||||
}
|
||||
anyModified = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (settingsFile.readOnly && foundDeprecated.length > 0) {
|
||||
systemWarnings.set(scope, foundDeprecated);
|
||||
}
|
||||
// Migrate experimental agent settings
|
||||
anyModified =
|
||||
migrateExperimentalSettings(
|
||||
settings,
|
||||
loadedSettings,
|
||||
scope,
|
||||
removeDeprecated,
|
||||
) || anyModified;
|
||||
};
|
||||
|
||||
processScope(SettingScope.User);
|
||||
@@ -914,19 +876,6 @@ export function migrateDeprecatedSettings(
|
||||
processScope(SettingScope.System);
|
||||
processScope(SettingScope.SystemDefaults);
|
||||
|
||||
if (systemWarnings.size > 0) {
|
||||
for (const [scope, flags] of systemWarnings) {
|
||||
const scopeName =
|
||||
scope === SettingScope.SystemDefaults
|
||||
? 'system default'
|
||||
: scope.toLowerCase();
|
||||
coreEvents.emitFeedback(
|
||||
'warning',
|
||||
`The ${scopeName} configuration contains deprecated settings: [${flags.join(', ')}]. These could not be migrated automatically as system settings are read-only. Please update the system configuration manually.`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return anyModified;
|
||||
}
|
||||
|
||||
@@ -968,3 +917,100 @@ export function saveModelChange(
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
function migrateExperimentalSettings(
|
||||
settings: Settings,
|
||||
loadedSettings: LoadedSettings,
|
||||
scope: LoadableSettingScope,
|
||||
removeDeprecated: boolean,
|
||||
): boolean {
|
||||
const experimentalSettings = settings.experimental as
|
||||
| Record<string, unknown>
|
||||
| undefined;
|
||||
if (experimentalSettings) {
|
||||
const agentsSettings = {
|
||||
...(settings.agents as Record<string, unknown> | undefined),
|
||||
};
|
||||
const agentsOverrides = {
|
||||
...((agentsSettings['overrides'] as Record<string, unknown>) || {}),
|
||||
};
|
||||
let modified = false;
|
||||
|
||||
// Migrate codebaseInvestigatorSettings -> agents.overrides.codebase_investigator
|
||||
if (experimentalSettings['codebaseInvestigatorSettings']) {
|
||||
const old = experimentalSettings[
|
||||
'codebaseInvestigatorSettings'
|
||||
] as Record<string, unknown>;
|
||||
const override = {
|
||||
...(agentsOverrides['codebase_investigator'] as
|
||||
| Record<string, unknown>
|
||||
| undefined),
|
||||
};
|
||||
|
||||
if (old['enabled'] !== undefined) override['enabled'] = old['enabled'];
|
||||
|
||||
const runConfig = {
|
||||
...(override['runConfig'] as Record<string, unknown> | undefined),
|
||||
};
|
||||
if (old['maxNumTurns'] !== undefined)
|
||||
runConfig['maxTurns'] = old['maxNumTurns'];
|
||||
if (old['maxTimeMinutes'] !== undefined)
|
||||
runConfig['maxTimeMinutes'] = old['maxTimeMinutes'];
|
||||
if (Object.keys(runConfig).length > 0) override['runConfig'] = runConfig;
|
||||
|
||||
if (old['model'] !== undefined || old['thinkingBudget'] !== undefined) {
|
||||
const modelConfig = {
|
||||
...(override['modelConfig'] as Record<string, unknown> | undefined),
|
||||
};
|
||||
if (old['model'] !== undefined) modelConfig['model'] = old['model'];
|
||||
if (old['thinkingBudget'] !== undefined) {
|
||||
const generateContentConfig = {
|
||||
...(modelConfig['generateContentConfig'] as
|
||||
| Record<string, unknown>
|
||||
| undefined),
|
||||
};
|
||||
const thinkingConfig = {
|
||||
...(generateContentConfig['thinkingConfig'] as
|
||||
| Record<string, unknown>
|
||||
| undefined),
|
||||
};
|
||||
thinkingConfig['thinkingBudget'] = old['thinkingBudget'];
|
||||
generateContentConfig['thinkingConfig'] = thinkingConfig;
|
||||
modelConfig['generateContentConfig'] = generateContentConfig;
|
||||
}
|
||||
override['modelConfig'] = modelConfig;
|
||||
}
|
||||
|
||||
agentsOverrides['codebase_investigator'] = override;
|
||||
modified = true;
|
||||
}
|
||||
|
||||
// Migrate cliHelpAgentSettings -> agents.overrides.cli_help
|
||||
if (experimentalSettings['cliHelpAgentSettings']) {
|
||||
const old = experimentalSettings['cliHelpAgentSettings'] as Record<
|
||||
string,
|
||||
unknown
|
||||
>;
|
||||
const override = {
|
||||
...(agentsOverrides['cli_help'] as Record<string, unknown> | undefined),
|
||||
};
|
||||
if (old['enabled'] !== undefined) override['enabled'] = old['enabled'];
|
||||
agentsOverrides['cli_help'] = override;
|
||||
modified = true;
|
||||
}
|
||||
|
||||
if (modified) {
|
||||
agentsSettings['overrides'] = agentsOverrides;
|
||||
loadedSettings.setValue(scope, 'agents', agentsSettings);
|
||||
|
||||
if (removeDeprecated) {
|
||||
const newExperimental = { ...experimentalSettings };
|
||||
delete newExperimental['codebaseInvestigatorSettings'];
|
||||
delete newExperimental['cliHelpAgentSettings'];
|
||||
loadedSettings.setValue(scope, 'experimental', newExperimental);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
@@ -328,6 +328,30 @@ describe('SettingsSchema', () => {
|
||||
).toBe('Enable debug logging of keystrokes to the console.');
|
||||
});
|
||||
|
||||
it('should have previewFeatures setting in schema', () => {
|
||||
expect(
|
||||
getSettingsSchema().general.properties.previewFeatures,
|
||||
).toBeDefined();
|
||||
expect(getSettingsSchema().general.properties.previewFeatures.type).toBe(
|
||||
'boolean',
|
||||
);
|
||||
expect(
|
||||
getSettingsSchema().general.properties.previewFeatures.category,
|
||||
).toBe('General');
|
||||
expect(
|
||||
getSettingsSchema().general.properties.previewFeatures.default,
|
||||
).toBe(false);
|
||||
expect(
|
||||
getSettingsSchema().general.properties.previewFeatures.requiresRestart,
|
||||
).toBe(false);
|
||||
expect(
|
||||
getSettingsSchema().general.properties.previewFeatures.showInDialog,
|
||||
).toBe(true);
|
||||
expect(
|
||||
getSettingsSchema().general.properties.previewFeatures.description,
|
||||
).toBe('Enable preview features (e.g., preview models).');
|
||||
});
|
||||
|
||||
it('should have enableAgents setting in schema', () => {
|
||||
const setting = getSettingsSchema().experimental.properties.enableAgents;
|
||||
expect(setting).toBeDefined();
|
||||
@@ -365,6 +389,20 @@ describe('SettingsSchema', () => {
|
||||
);
|
||||
});
|
||||
|
||||
it('should have enableEventDrivenScheduler setting in schema', () => {
|
||||
const setting =
|
||||
getSettingsSchema().experimental.properties.enableEventDrivenScheduler;
|
||||
expect(setting).toBeDefined();
|
||||
expect(setting.type).toBe('boolean');
|
||||
expect(setting.category).toBe('Experimental');
|
||||
expect(setting.default).toBe(true);
|
||||
expect(setting.requiresRestart).toBe(true);
|
||||
expect(setting.showInDialog).toBe(false);
|
||||
expect(setting.description).toBe(
|
||||
'Enables event-driven scheduler within the CLI session.',
|
||||
);
|
||||
});
|
||||
|
||||
it('should have hooksConfig.notifications setting in schema', () => {
|
||||
const setting = getSettingsSchema().hooksConfig?.properties.notifications;
|
||||
expect(setting).toBeDefined();
|
||||
|
||||
@@ -10,6 +10,7 @@
|
||||
// --------------------------------------------------------------------------
|
||||
|
||||
import {
|
||||
DEFAULT_TRUNCATE_TOOL_OUTPUT_LINES,
|
||||
DEFAULT_TRUNCATE_TOOL_OUTPUT_THRESHOLD,
|
||||
DEFAULT_MODEL_CONFIGS,
|
||||
type MCPServerConfig,
|
||||
@@ -161,6 +162,15 @@ const SETTINGS_SCHEMA = {
|
||||
description: 'General application settings.',
|
||||
showInDialog: false,
|
||||
properties: {
|
||||
previewFeatures: {
|
||||
type: 'boolean',
|
||||
label: 'Preview Features (e.g., models)',
|
||||
category: 'General',
|
||||
requiresRestart: false,
|
||||
default: false,
|
||||
description: 'Enable preview features (e.g., preview models).',
|
||||
showInDialog: true,
|
||||
},
|
||||
preferredEditor: {
|
||||
type: 'string',
|
||||
label: 'Preferred Editor',
|
||||
@@ -1148,6 +1158,15 @@ const SETTINGS_SCHEMA = {
|
||||
'Use ripgrep for file content search instead of the fallback implementation. Provides faster search performance.',
|
||||
showInDialog: true,
|
||||
},
|
||||
enableToolOutputTruncation: {
|
||||
type: 'boolean',
|
||||
label: 'Enable Tool Output Truncation',
|
||||
category: 'General',
|
||||
requiresRestart: true,
|
||||
default: true,
|
||||
description: 'Enable truncation of large tool outputs.',
|
||||
showInDialog: true,
|
||||
},
|
||||
truncateToolOutputThreshold: {
|
||||
type: 'number',
|
||||
label: 'Tool Output Truncation Threshold',
|
||||
@@ -1155,7 +1174,16 @@ const SETTINGS_SCHEMA = {
|
||||
requiresRestart: true,
|
||||
default: DEFAULT_TRUNCATE_TOOL_OUTPUT_THRESHOLD,
|
||||
description:
|
||||
'Maximum characters to show when truncating large tool outputs. Set to 0 or negative to disable truncation.',
|
||||
'Truncate tool output if it is larger than this many characters. Set to -1 to disable.',
|
||||
showInDialog: true,
|
||||
},
|
||||
truncateToolOutputLines: {
|
||||
type: 'number',
|
||||
label: 'Tool Output Truncation Lines',
|
||||
category: 'General',
|
||||
requiresRestart: true,
|
||||
default: DEFAULT_TRUNCATE_TOOL_OUTPUT_LINES,
|
||||
description: 'The number of lines to keep when truncating tool output.',
|
||||
showInDialog: true,
|
||||
},
|
||||
disableLLMCorrection: {
|
||||
@@ -1434,58 +1462,6 @@ const SETTINGS_SCHEMA = {
|
||||
description: 'Setting to enable experimental features',
|
||||
showInDialog: false,
|
||||
properties: {
|
||||
toolOutputMasking: {
|
||||
type: 'object',
|
||||
label: 'Tool Output Masking',
|
||||
category: 'Experimental',
|
||||
requiresRestart: true,
|
||||
ignoreInDocs: true,
|
||||
default: {},
|
||||
description:
|
||||
'Advanced settings for tool output masking to manage context window efficiency.',
|
||||
showInDialog: false,
|
||||
properties: {
|
||||
enabled: {
|
||||
type: 'boolean',
|
||||
label: 'Enable Tool Output Masking',
|
||||
category: 'Experimental',
|
||||
requiresRestart: true,
|
||||
default: false,
|
||||
description: 'Enables tool output masking to save tokens.',
|
||||
showInDialog: false,
|
||||
},
|
||||
toolProtectionThreshold: {
|
||||
type: 'number',
|
||||
label: 'Tool Protection Threshold',
|
||||
category: 'Experimental',
|
||||
requiresRestart: true,
|
||||
default: 50000,
|
||||
description:
|
||||
'Minimum number of tokens to protect from masking (most recent tool outputs).',
|
||||
showInDialog: false,
|
||||
},
|
||||
minPrunableTokensThreshold: {
|
||||
type: 'number',
|
||||
label: 'Min Prunable Tokens Threshold',
|
||||
category: 'Experimental',
|
||||
requiresRestart: true,
|
||||
default: 30000,
|
||||
description:
|
||||
'Minimum prunable tokens required to trigger a masking pass.',
|
||||
showInDialog: false,
|
||||
},
|
||||
protectLatestTurn: {
|
||||
type: 'boolean',
|
||||
label: 'Protect Latest Turn',
|
||||
category: 'Experimental',
|
||||
requiresRestart: true,
|
||||
default: true,
|
||||
description:
|
||||
'Ensures the absolute latest turn is never masked, regardless of token count.',
|
||||
showInDialog: false,
|
||||
},
|
||||
},
|
||||
},
|
||||
enableAgents: {
|
||||
type: 'boolean',
|
||||
label: 'Enable Agents',
|
||||
@@ -1510,10 +1486,19 @@ const SETTINGS_SCHEMA = {
|
||||
label: 'Extension Configuration',
|
||||
category: 'Experimental',
|
||||
requiresRestart: true,
|
||||
default: true,
|
||||
default: false,
|
||||
description: 'Enable requesting and fetching of extension settings.',
|
||||
showInDialog: false,
|
||||
},
|
||||
enableEventDrivenScheduler: {
|
||||
type: 'boolean',
|
||||
label: 'Event Driven Scheduler',
|
||||
category: 'Experimental',
|
||||
requiresRestart: true,
|
||||
default: true,
|
||||
description: 'Enables event-driven scheduler within the CLI session.',
|
||||
showInDialog: false,
|
||||
},
|
||||
extensionReloading: {
|
||||
type: 'boolean',
|
||||
label: 'Extension Reloading',
|
||||
@@ -1552,37 +1537,6 @@ const SETTINGS_SCHEMA = {
|
||||
description: 'Enable planning features (Plan Mode and tools).',
|
||||
showInDialog: true,
|
||||
},
|
||||
adaptiveThinking: {
|
||||
type: 'object',
|
||||
label: 'Adaptive Thinking Settings',
|
||||
category: 'Experimental',
|
||||
requiresRestart: false,
|
||||
default: {},
|
||||
description: 'Configuration for Adaptive Thinking Budget.',
|
||||
showInDialog: false,
|
||||
properties: {
|
||||
enabled: {
|
||||
type: 'boolean',
|
||||
label: 'Enable Adaptive Thinking',
|
||||
category: 'Experimental',
|
||||
requiresRestart: false,
|
||||
default: false,
|
||||
description:
|
||||
'Enable adaptive thinking budget based on task complexity.',
|
||||
showInDialog: true,
|
||||
},
|
||||
classifierModel: {
|
||||
type: 'string',
|
||||
label: 'Classifier Model',
|
||||
category: 'Experimental',
|
||||
requiresRestart: false,
|
||||
default: 'classifier',
|
||||
description:
|
||||
'The model (or alias) to use for complexity classification.',
|
||||
showInDialog: false,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
@@ -1913,20 +1867,6 @@ const SETTINGS_SCHEMA = {
|
||||
showInDialog: false,
|
||||
mergeStrategy: MergeStrategy.REPLACE,
|
||||
},
|
||||
config: {
|
||||
type: 'object',
|
||||
label: 'MCP Config',
|
||||
category: 'Admin',
|
||||
requiresRestart: false,
|
||||
default: {} as Record<string, MCPServerConfig>,
|
||||
description: 'Admin-configured MCP servers.',
|
||||
showInDialog: false,
|
||||
mergeStrategy: MergeStrategy.REPLACE,
|
||||
additionalProperties: {
|
||||
type: 'object',
|
||||
ref: 'MCPServerConfig',
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
skills: {
|
||||
|
||||
@@ -134,6 +134,7 @@ describe('Settings Repro', () => {
|
||||
enablePromptCompletion: false,
|
||||
preferredEditor: 'vim',
|
||||
vimMode: false,
|
||||
previewFeatures: false,
|
||||
},
|
||||
security: {
|
||||
auth: {
|
||||
@@ -149,6 +150,7 @@ describe('Settings Repro', () => {
|
||||
showColor: true,
|
||||
enableInteractiveShell: true,
|
||||
},
|
||||
truncateToolOutputLines: 100,
|
||||
},
|
||||
experimental: {
|
||||
useModelRouter: false,
|
||||
|
||||
@@ -167,15 +167,7 @@ describe('deferred', () => {
|
||||
|
||||
// Now manually run it to verify it captured correctly
|
||||
await runDeferredCommand(createMockSettings().merged);
|
||||
expect(originalHandler).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
settings: expect.objectContaining({
|
||||
admin: expect.objectContaining({
|
||||
extensions: expect.objectContaining({ enabled: true }),
|
||||
}),
|
||||
}),
|
||||
}),
|
||||
);
|
||||
expect(originalHandler).toHaveBeenCalledWith(argv);
|
||||
expect(mockExit).toHaveBeenCalledWith(ExitCodes.SUCCESS);
|
||||
});
|
||||
|
||||
|
||||
@@ -63,13 +63,7 @@ export async function runDeferredCommand(settings: MergedSettings) {
|
||||
process.exit(ExitCodes.FATAL_CONFIG_ERROR);
|
||||
}
|
||||
|
||||
// Inject settings into argv
|
||||
const argvWithSettings = {
|
||||
...deferredCommand.argv,
|
||||
settings,
|
||||
};
|
||||
|
||||
await deferredCommand.handler(argvWithSettings);
|
||||
await deferredCommand.handler(deferredCommand.argv);
|
||||
await runExitCleanup();
|
||||
process.exit(ExitCodes.SUCCESS);
|
||||
}
|
||||
|
||||
@@ -476,6 +476,10 @@ describe('gemini.tsx main function kitty protocol', () => {
|
||||
prompt: undefined,
|
||||
promptInteractive: undefined,
|
||||
query: undefined,
|
||||
ralphWiggum: undefined,
|
||||
completionPromise: undefined,
|
||||
maxIterations: undefined,
|
||||
memoryFile: undefined,
|
||||
yolo: undefined,
|
||||
approvalMode: undefined,
|
||||
allowedMcpServerNames: undefined,
|
||||
|
||||
+21
-14
@@ -25,6 +25,7 @@ import { getStartupWarnings } from './utils/startupWarnings.js';
|
||||
import { getUserStartupWarnings } from './utils/userStartupWarnings.js';
|
||||
import { ConsolePatcher } from './ui/utils/ConsolePatcher.js';
|
||||
import { runNonInteractive } from './nonInteractiveCli.js';
|
||||
import { runRalphWiggum } from './ralphWiggum.js';
|
||||
import {
|
||||
cleanupCheckpoints,
|
||||
registerCleanup,
|
||||
@@ -510,15 +511,9 @@ export async function main() {
|
||||
projectHooks: settings.workspace.settings.hooks,
|
||||
});
|
||||
loadConfigHandle?.end();
|
||||
|
||||
// Initialize storage immediately after loading config to ensure that
|
||||
// storage-related operations (like listing or resuming sessions) have
|
||||
// access to the project identifier.
|
||||
await config.storage.initialize();
|
||||
|
||||
adminControlsListner.setConfig(config);
|
||||
|
||||
if (config.isInteractive() && config.getDebugMode()) {
|
||||
if (config.isInteractive() && config.storage && config.getDebugMode()) {
|
||||
const { registerActivityLogger } = await import(
|
||||
'./utils/activityLogger.js'
|
||||
);
|
||||
@@ -746,13 +741,25 @@ export async function main() {
|
||||
|
||||
initializeOutputListenersAndFlush();
|
||||
|
||||
await runNonInteractive({
|
||||
config,
|
||||
settings,
|
||||
input,
|
||||
prompt_id,
|
||||
resumedSessionData,
|
||||
});
|
||||
if (argv.ralphWiggum) {
|
||||
await runRalphWiggum({
|
||||
config,
|
||||
settings,
|
||||
input,
|
||||
prompt_id,
|
||||
resumedSessionData,
|
||||
completionPromise: argv.completionPromise,
|
||||
maxIterations: argv.maxIterations,
|
||||
});
|
||||
} else {
|
||||
await runNonInteractive({
|
||||
config,
|
||||
settings,
|
||||
input,
|
||||
prompt_id,
|
||||
resumedSessionData,
|
||||
});
|
||||
}
|
||||
// Call cleanup before process.exit, which causes cleanup to not run
|
||||
await runExitCleanup();
|
||||
process.exit(ExitCodes.SUCCESS);
|
||||
|
||||
@@ -38,10 +38,6 @@ vi.mock('@google/gemini-cli-core', async (importOriginal) => {
|
||||
disableMouseEvents: vi.fn(),
|
||||
enterAlternateScreen: vi.fn(),
|
||||
disableLineWrapping: vi.fn(),
|
||||
ProjectRegistry: vi.fn().mockImplementation(() => ({
|
||||
initialize: vi.fn(),
|
||||
getShortId: vi.fn().mockReturnValue('project-slug'),
|
||||
})),
|
||||
};
|
||||
});
|
||||
|
||||
@@ -77,7 +73,6 @@ vi.mock('./config/config.js', () => ({
|
||||
getSandbox: vi.fn(() => false),
|
||||
getQuestion: vi.fn(() => ''),
|
||||
isInteractive: () => false,
|
||||
storage: { initialize: vi.fn().mockResolvedValue(undefined) },
|
||||
} as unknown as Config),
|
||||
parseArguments: vi.fn().mockResolvedValue({}),
|
||||
isDebugMode: vi.fn(() => false),
|
||||
@@ -196,7 +191,6 @@ describe('gemini.tsx main function cleanup', () => {
|
||||
getEnableHooks: vi.fn(() => false),
|
||||
getHookSystem: () => undefined,
|
||||
initialize: vi.fn(),
|
||||
storage: { initialize: vi.fn().mockResolvedValue(undefined) },
|
||||
getContentGeneratorConfig: vi.fn(),
|
||||
getMcpServers: () => ({}),
|
||||
getMcpClientManager: vi.fn(),
|
||||
|
||||
@@ -267,8 +267,8 @@ describe('runNonInteractive', () => {
|
||||
// so we no longer expect shutdownTelemetry to be called directly here
|
||||
});
|
||||
|
||||
it('should register activity logger when GEMINI_CLI_ACTIVITY_LOG_TARGET is set', async () => {
|
||||
vi.stubEnv('GEMINI_CLI_ACTIVITY_LOG_TARGET', '/tmp/test.jsonl');
|
||||
it('should register activity logger when GEMINI_CLI_ACTIVITY_LOG_FILE is set', async () => {
|
||||
vi.stubEnv('GEMINI_CLI_ACTIVITY_LOG_FILE', '/tmp/test.jsonl');
|
||||
const events: ServerGeminiStreamEvent[] = [
|
||||
{
|
||||
type: GeminiEventType.Finished,
|
||||
@@ -290,8 +290,8 @@ describe('runNonInteractive', () => {
|
||||
vi.unstubAllEnvs();
|
||||
});
|
||||
|
||||
it('should not register activity logger when GEMINI_CLI_ACTIVITY_LOG_TARGET is not set', async () => {
|
||||
vi.stubEnv('GEMINI_CLI_ACTIVITY_LOG_TARGET', '');
|
||||
it('should not register activity logger when GEMINI_CLI_ACTIVITY_LOG_FILE is not set', async () => {
|
||||
vi.stubEnv('GEMINI_CLI_ACTIVITY_LOG_FILE', '');
|
||||
const events: ServerGeminiStreamEvent[] = [
|
||||
{
|
||||
type: GeminiEventType.Finished,
|
||||
|
||||
@@ -47,7 +47,7 @@ import {
|
||||
} from './utils/errors.js';
|
||||
import { TextOutput } from './ui/utils/textOutput.js';
|
||||
|
||||
interface RunNonInteractiveParams {
|
||||
export interface RunNonInteractiveParams {
|
||||
config: Config;
|
||||
settings: LoadedSettings;
|
||||
input: string;
|
||||
@@ -55,13 +55,15 @@ interface RunNonInteractiveParams {
|
||||
resumedSessionData?: ResumedSessionData;
|
||||
}
|
||||
|
||||
// Moved to ralphWiggum.ts
|
||||
|
||||
export async function runNonInteractive({
|
||||
config,
|
||||
settings,
|
||||
input,
|
||||
prompt_id,
|
||||
resumedSessionData,
|
||||
}: RunNonInteractiveParams): Promise<void> {
|
||||
}: RunNonInteractiveParams): Promise<string> {
|
||||
return promptIdContext.run(prompt_id, async () => {
|
||||
const consolePatcher = new ConsolePatcher({
|
||||
stderr: true,
|
||||
@@ -71,7 +73,7 @@ export async function runNonInteractive({
|
||||
},
|
||||
});
|
||||
|
||||
if (process.env['GEMINI_CLI_ACTIVITY_LOG_TARGET']) {
|
||||
if (config.storage && process.env['GEMINI_CLI_ACTIVITY_LOG_FILE']) {
|
||||
const { registerActivityLogger } = await import(
|
||||
'./utils/activityLogger.js'
|
||||
);
|
||||
@@ -181,6 +183,9 @@ export async function runNonInteractive({
|
||||
}
|
||||
};
|
||||
|
||||
// Store accumulated response text to return
|
||||
let fullResponseText = '';
|
||||
|
||||
let errorToHandle: unknown | undefined;
|
||||
try {
|
||||
consolePatcher.patch();
|
||||
@@ -316,6 +321,13 @@ export async function runNonInteractive({
|
||||
const isRaw =
|
||||
config.getRawOutput() || config.getAcceptRawOutputRisk();
|
||||
const output = isRaw ? event.value : stripAnsi(event.value);
|
||||
|
||||
// Accumulate full response
|
||||
if (event.value) {
|
||||
fullResponseText += event.value;
|
||||
responseText += output;
|
||||
}
|
||||
|
||||
if (streamFormatter) {
|
||||
streamFormatter.emitEvent({
|
||||
type: JsonStreamEventType.MESSAGE,
|
||||
@@ -325,7 +337,7 @@ export async function runNonInteractive({
|
||||
delta: true,
|
||||
});
|
||||
} else if (config.getOutputFormat() === OutputFormat.JSON) {
|
||||
responseText += output;
|
||||
// responseText is already updated
|
||||
} else {
|
||||
if (event.value) {
|
||||
textOutput.write(output);
|
||||
@@ -381,7 +393,7 @@ export async function runNonInteractive({
|
||||
),
|
||||
});
|
||||
}
|
||||
return;
|
||||
return fullResponseText;
|
||||
} else if (event.type === GeminiEventType.AgentExecutionBlocked) {
|
||||
const blockMessage = `Agent execution blocked: ${event.value.systemMessage?.trim() || event.value.reason}`;
|
||||
if (config.getOutputFormat() === OutputFormat.TEXT) {
|
||||
@@ -488,7 +500,7 @@ export async function runNonInteractive({
|
||||
} else {
|
||||
textOutput.ensureTrailingNewline(); // Ensure a final newline
|
||||
}
|
||||
return;
|
||||
return fullResponseText;
|
||||
}
|
||||
|
||||
currentMessages = [{ role: 'user', parts: toolResponseParts }];
|
||||
@@ -512,7 +524,7 @@ export async function runNonInteractive({
|
||||
} else {
|
||||
textOutput.ensureTrailingNewline(); // Ensure a final newline
|
||||
}
|
||||
return;
|
||||
return fullResponseText;
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
@@ -528,5 +540,6 @@ export async function runNonInteractive({
|
||||
if (errorToHandle) {
|
||||
handleError(errorToHandle, config);
|
||||
}
|
||||
return fullResponseText;
|
||||
});
|
||||
}
|
||||
|
||||
@@ -0,0 +1,67 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import { runRalphWiggum } from './ralphWiggum.js';
|
||||
import * as nonInteractiveCli from './nonInteractiveCli.js';
|
||||
import fs from 'node:fs';
|
||||
import type { Config, ResumedSessionData } from '@google/gemini-cli-core';
|
||||
import type { LoadedSettings } from './config/settings.js';
|
||||
|
||||
// Mock dependencies
|
||||
vi.mock('node:fs');
|
||||
vi.mock('./nonInteractiveCli.js');
|
||||
|
||||
describe('runRalphWiggum', () => {
|
||||
const mockConfig = {} as unknown as Config;
|
||||
const mockSettings = {} as unknown as LoadedSettings;
|
||||
const mockInput = 'Fix bugs';
|
||||
const mockPromptId = 'prompt-123';
|
||||
const mockResumedSessionData = {
|
||||
conversation: { messages: [], sessionId: 'session-123' },
|
||||
filePath: 'session.json',
|
||||
} as unknown as ResumedSessionData;
|
||||
|
||||
beforeEach(() => {
|
||||
vi.resetAllMocks();
|
||||
// Default mock implementation for fs
|
||||
vi.mocked(fs.existsSync).mockReturnValue(false);
|
||||
vi.mocked(fs.writeFileSync).mockReturnValue(undefined);
|
||||
vi.mocked(fs.readFileSync).mockReturnValue('');
|
||||
});
|
||||
|
||||
it('should preserve resumedSessionData in second iteration', async () => {
|
||||
// Setup runNonInteractive to return "Failed" first, then "Success"
|
||||
const runNonInteractiveMock = vi.mocked(
|
||||
nonInteractiveCli.runNonInteractive,
|
||||
);
|
||||
runNonInteractiveMock
|
||||
.mockResolvedValueOnce('Test failed')
|
||||
.mockResolvedValueOnce('Test Success');
|
||||
|
||||
await runRalphWiggum({
|
||||
config: mockConfig,
|
||||
settings: mockSettings,
|
||||
input: mockInput,
|
||||
prompt_id: mockPromptId,
|
||||
resumedSessionData: mockResumedSessionData,
|
||||
completionPromise: 'Success',
|
||||
maxIterations: 2,
|
||||
});
|
||||
|
||||
expect(runNonInteractiveMock).toHaveBeenCalledTimes(2);
|
||||
|
||||
// First call should have resumedSessionData
|
||||
expect(runNonInteractiveMock.mock.calls[0][0].resumedSessionData).toBe(
|
||||
mockResumedSessionData,
|
||||
);
|
||||
|
||||
// Second call should have resumedSessionData (FIXED)
|
||||
expect(runNonInteractiveMock.mock.calls[1][0].resumedSessionData).toBe(
|
||||
mockResumedSessionData,
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,180 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import {
|
||||
runNonInteractive,
|
||||
type RunNonInteractiveParams,
|
||||
} from './nonInteractiveCli.js';
|
||||
|
||||
interface IterationResult {
|
||||
iteration: number;
|
||||
status: 'Success' | 'Failed';
|
||||
testsPassed?: number;
|
||||
testsFailed?: number;
|
||||
testsTotal?: number;
|
||||
}
|
||||
|
||||
function extractTestStats(output: string): {
|
||||
passed?: number;
|
||||
failed?: number;
|
||||
total?: number;
|
||||
} {
|
||||
// Common patterns for test runners (Vitest, Jest, Mocha, etc.)
|
||||
const patterns = [
|
||||
// Vitest/Jest: "Tests: 3 passed, 1 failed, 4 total"
|
||||
/Tests:\s*(?:(\d+)\s+passed)?(?:,\s*)?(?:(\d+)\s+failed)?(?:,\s*)?(?:(\d+)\s+total)?/i,
|
||||
// Mocha: "3 passing (10ms)"
|
||||
/(\d+)\s+passing/i,
|
||||
// Mocha: "1 failing"
|
||||
/(\d+)\s+failing/i,
|
||||
// Generic: "Passed: 3, Failed: 1"
|
||||
/Passed:\s*(\d+)/i,
|
||||
/Failed:\s*(\d+)/i,
|
||||
];
|
||||
|
||||
let passed: number | undefined;
|
||||
let failed: number | undefined;
|
||||
let total: number | undefined;
|
||||
|
||||
// Try Vitest/Jest pattern first as it is most comprehensive
|
||||
const vitestMatch = output.match(patterns[0]);
|
||||
if (vitestMatch && (vitestMatch[1] || vitestMatch[2] || vitestMatch[3])) {
|
||||
passed = vitestMatch[1] ? parseInt(vitestMatch[1], 10) : 0;
|
||||
failed = vitestMatch[2] ? parseInt(vitestMatch[2], 10) : 0;
|
||||
total = vitestMatch[3] ? parseInt(vitestMatch[3], 10) : 0;
|
||||
return { passed, failed, total };
|
||||
}
|
||||
|
||||
// Fallback to individual patterns
|
||||
const passingMatch = output.match(patterns[1]);
|
||||
if (passingMatch) {
|
||||
passed = parseInt(passingMatch[1], 10);
|
||||
} else {
|
||||
const passedMatch = output.match(patterns[3]);
|
||||
if (passedMatch) passed = parseInt(passedMatch[1], 10);
|
||||
}
|
||||
|
||||
const failingMatch = output.match(patterns[2]);
|
||||
if (failingMatch) {
|
||||
failed = parseInt(failingMatch[1], 10);
|
||||
} else {
|
||||
const failedMatch = output.match(patterns[4]);
|
||||
if (failedMatch) failed = parseInt(failedMatch[1], 10);
|
||||
}
|
||||
|
||||
return { passed, failed, total };
|
||||
}
|
||||
|
||||
function printSummary(results: IterationResult[]) {
|
||||
process.stderr.write('\n--- Ralph Wiggum Mode Summary ---\n');
|
||||
process.stderr.write(
|
||||
'| Iteration | Status | Tests Passed | Tests Failed |\n',
|
||||
);
|
||||
process.stderr.write(
|
||||
'|-----------|---------|--------------|--------------|\n',
|
||||
);
|
||||
for (const result of results) {
|
||||
const passed = result.testsPassed !== undefined ? result.testsPassed : '-';
|
||||
const failed = result.testsFailed !== undefined ? result.testsFailed : '-';
|
||||
process.stderr.write(
|
||||
`| ${result.iteration.toString().padEnd(9)} | ${result.status.padEnd(7)} | ${passed.toString().padEnd(12)} | ${failed.toString().padEnd(12)} |\n`,
|
||||
);
|
||||
}
|
||||
process.stderr.write('---------------------------------\n\n');
|
||||
}
|
||||
|
||||
export async function runRalphWiggum({
|
||||
config,
|
||||
settings,
|
||||
input,
|
||||
prompt_id,
|
||||
resumedSessionData,
|
||||
completionPromise,
|
||||
maxIterations,
|
||||
memoryFile,
|
||||
}: RunNonInteractiveParams & {
|
||||
completionPromise?: string;
|
||||
maxIterations?: number;
|
||||
memoryFile?: string;
|
||||
}): Promise<void> {
|
||||
const effectiveMaxIterations = maxIterations ?? 10;
|
||||
let iterations = 0;
|
||||
const currentResumedSessionData = resumedSessionData;
|
||||
const results: IterationResult[] = [];
|
||||
const effectiveMemoryFile = memoryFile || 'memories.md';
|
||||
const memoriesPath = path.join(process.cwd(), effectiveMemoryFile);
|
||||
|
||||
if (!fs.existsSync(memoriesPath)) {
|
||||
fs.writeFileSync(
|
||||
memoriesPath,
|
||||
`# Ralph Wiggum Memories\n\nTask: ${input}\n\nUse this file (${effectiveMemoryFile}) to store notes on what worked and what didn't work across iterations. The agent will read this at the start of each run.\n\n`,
|
||||
);
|
||||
}
|
||||
|
||||
process.stderr.write(
|
||||
`[Ralph Wiggum] Starting loop. Max iterations: ${effectiveMaxIterations}\n`,
|
||||
);
|
||||
|
||||
while (iterations < effectiveMaxIterations) {
|
||||
iterations++;
|
||||
process.stderr.write(
|
||||
`[Ralph Wiggum] Iteration ${iterations}/${effectiveMaxIterations}\n`,
|
||||
);
|
||||
|
||||
let currentInput = input;
|
||||
try {
|
||||
if (fs.existsSync(memoriesPath)) {
|
||||
const memories = fs.readFileSync(memoriesPath, 'utf-8');
|
||||
if (memories.trim()) {
|
||||
currentInput = `Context from previous iterations (${effectiveMemoryFile}):\n${memories}\n\nTask:\n${input}`;
|
||||
process.stderr.write(
|
||||
`[Ralph Wiggum] Loaded context from ${effectiveMemoryFile}\n`,
|
||||
);
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
process.stderr.write(
|
||||
`[Ralph Wiggum] Failed to read ${effectiveMemoryFile}: ${error}\n`,
|
||||
);
|
||||
}
|
||||
|
||||
const output = await runNonInteractive({
|
||||
config,
|
||||
settings,
|
||||
input: currentInput,
|
||||
prompt_id,
|
||||
resumedSessionData: currentResumedSessionData,
|
||||
});
|
||||
|
||||
const stats = extractTestStats(output);
|
||||
const success =
|
||||
completionPromise && output.includes(completionPromise) ? true : false;
|
||||
|
||||
results.push({
|
||||
iteration: iterations,
|
||||
status: success ? 'Success' : 'Failed',
|
||||
testsPassed: stats.passed,
|
||||
testsFailed: stats.failed,
|
||||
testsTotal: stats.total,
|
||||
});
|
||||
|
||||
if (success) {
|
||||
process.stderr.write(
|
||||
`[Ralph Wiggum] Completion promise "${completionPromise}" met. Exiting.\n`,
|
||||
);
|
||||
printSummary(results);
|
||||
return;
|
||||
}
|
||||
|
||||
// currentResumedSessionData = undefined; // Fixed: Keep resumedSessionData for subsequent iterations
|
||||
}
|
||||
process.stderr.write(
|
||||
`[Ralph Wiggum] Max iterations reached without meeting completion promise.\n`,
|
||||
);
|
||||
printSummary(results);
|
||||
}
|
||||
@@ -85,9 +85,6 @@ vi.mock('../ui/commands/extensionsCommand.js', () => ({
|
||||
extensionsCommand: () => ({}),
|
||||
}));
|
||||
vi.mock('../ui/commands/helpCommand.js', () => ({ helpCommand: {} }));
|
||||
vi.mock('../ui/commands/shortcutsCommand.js', () => ({
|
||||
shortcutsCommand: {},
|
||||
}));
|
||||
vi.mock('../ui/commands/memoryCommand.js', () => ({ memoryCommand: {} }));
|
||||
vi.mock('../ui/commands/modelCommand.js', () => ({
|
||||
modelCommand: { name: 'model' },
|
||||
|
||||
@@ -31,7 +31,6 @@ import { directoryCommand } from '../ui/commands/directoryCommand.js';
|
||||
import { editorCommand } from '../ui/commands/editorCommand.js';
|
||||
import { extensionsCommand } from '../ui/commands/extensionsCommand.js';
|
||||
import { helpCommand } from '../ui/commands/helpCommand.js';
|
||||
import { shortcutsCommand } from '../ui/commands/shortcutsCommand.js';
|
||||
import { rewindCommand } from '../ui/commands/rewindCommand.js';
|
||||
import { hooksCommand } from '../ui/commands/hooksCommand.js';
|
||||
import { ideCommand } from '../ui/commands/ideCommand.js';
|
||||
@@ -117,7 +116,6 @@ export class BuiltinCommandLoader implements ICommandLoader {
|
||||
]
|
||||
: [extensionsCommand(this.config?.getEnableExtensionReloading())]),
|
||||
helpCommand,
|
||||
shortcutsCommand,
|
||||
...(this.config?.getEnableHooksUI() ? [hooksCommand] : []),
|
||||
rewindCommand,
|
||||
await ideCommand(),
|
||||
|
||||
@@ -60,7 +60,6 @@ export const createMockCommandContext = (
|
||||
setPendingItem: vi.fn(),
|
||||
loadHistory: vi.fn(),
|
||||
toggleCorgiMode: vi.fn(),
|
||||
toggleShortcutsHelp: vi.fn(),
|
||||
toggleVimEnabled: vi.fn(),
|
||||
openAgentConfigDialog: vi.fn(),
|
||||
closeAgentConfigDialog: vi.fn(),
|
||||
|
||||
@@ -20,7 +20,6 @@ export const createMockConfig = (overrides: Partial<Config> = {}): Config =>
|
||||
setTerminalBackground: vi.fn(),
|
||||
storage: {
|
||||
getProjectTempDir: vi.fn().mockReturnValue('/tmp/gemini-test'),
|
||||
initialize: vi.fn().mockResolvedValue(undefined),
|
||||
},
|
||||
getDebugMode: vi.fn(() => false),
|
||||
getProjectRoot: vi.fn(() => '/'),
|
||||
@@ -45,6 +44,7 @@ export const createMockConfig = (overrides: Partial<Config> = {}): Config =>
|
||||
setRemoteAdminSettings: vi.fn(),
|
||||
isYoloModeDisabled: vi.fn(() => false),
|
||||
isPlanEnabled: vi.fn(() => false),
|
||||
isEventDrivenSchedulerEnabled: vi.fn(() => false),
|
||||
getCoreTools: vi.fn(() => []),
|
||||
getAllowedTools: vi.fn(() => []),
|
||||
getApprovalMode: vi.fn(() => 'default'),
|
||||
@@ -151,6 +151,7 @@ export const createMockConfig = (overrides: Partial<Config> = {}): Config =>
|
||||
getAllowedMcpServers: vi.fn().mockReturnValue([]),
|
||||
getBlockedMcpServers: vi.fn().mockReturnValue([]),
|
||||
getExperiments: vi.fn().mockReturnValue(undefined),
|
||||
getPreviewFeatures: vi.fn().mockReturnValue(false),
|
||||
getHasAccessToPreviewModel: vi.fn().mockReturnValue(false),
|
||||
...overrides,
|
||||
}) as unknown as Config;
|
||||
|
||||
@@ -191,7 +191,6 @@ const mockUIActions: UIActions = {
|
||||
handleApiKeySubmit: vi.fn(),
|
||||
handleApiKeyCancel: vi.fn(),
|
||||
setBannerVisible: vi.fn(),
|
||||
setShortcutsHelpVisible: vi.fn(),
|
||||
setEmbeddedShellFocused: vi.fn(),
|
||||
dismissBackgroundShell: vi.fn(),
|
||||
setActiveBackgroundShellPid: vi.fn(),
|
||||
|
||||
@@ -1940,160 +1940,6 @@ describe('AppContainer State Management', () => {
|
||||
unmount();
|
||||
});
|
||||
});
|
||||
|
||||
describe('Focus Handling (Tab / Shift+Tab)', () => {
|
||||
beforeEach(() => {
|
||||
// Mock activePtyId to enable focus
|
||||
mockedUseGeminiStream.mockReturnValue({
|
||||
...DEFAULT_GEMINI_STREAM_MOCK,
|
||||
activePtyId: 1,
|
||||
});
|
||||
});
|
||||
|
||||
it('should focus shell input on Tab', async () => {
|
||||
await setupKeypressTest();
|
||||
|
||||
pressKey({ name: 'tab', shift: false });
|
||||
|
||||
expect(capturedUIState.embeddedShellFocused).toBe(true);
|
||||
unmount();
|
||||
});
|
||||
|
||||
it('should unfocus shell input on Shift+Tab', async () => {
|
||||
await setupKeypressTest();
|
||||
|
||||
// Focus first
|
||||
pressKey({ name: 'tab', shift: false });
|
||||
expect(capturedUIState.embeddedShellFocused).toBe(true);
|
||||
|
||||
// Unfocus via Shift+Tab
|
||||
pressKey({ name: 'tab', shift: true });
|
||||
expect(capturedUIState.embeddedShellFocused).toBe(false);
|
||||
unmount();
|
||||
});
|
||||
|
||||
it('should auto-unfocus when activePtyId becomes null', async () => {
|
||||
// Start with active pty and focused
|
||||
mockedUseGeminiStream.mockReturnValue({
|
||||
...DEFAULT_GEMINI_STREAM_MOCK,
|
||||
activePtyId: 1,
|
||||
});
|
||||
|
||||
const renderResult = render(getAppContainer());
|
||||
await act(async () => {
|
||||
vi.advanceTimersByTime(0);
|
||||
});
|
||||
|
||||
// Focus it
|
||||
act(() => {
|
||||
handleGlobalKeypress({
|
||||
name: 'tab',
|
||||
shift: false,
|
||||
alt: false,
|
||||
ctrl: false,
|
||||
cmd: false,
|
||||
} as Key);
|
||||
});
|
||||
expect(capturedUIState.embeddedShellFocused).toBe(true);
|
||||
|
||||
// Now mock activePtyId becoming null
|
||||
mockedUseGeminiStream.mockReturnValue({
|
||||
...DEFAULT_GEMINI_STREAM_MOCK,
|
||||
activePtyId: null,
|
||||
});
|
||||
|
||||
// Rerender to trigger useEffect
|
||||
await act(async () => {
|
||||
renderResult.rerender(getAppContainer());
|
||||
});
|
||||
|
||||
expect(capturedUIState.embeddedShellFocused).toBe(false);
|
||||
renderResult.unmount();
|
||||
});
|
||||
|
||||
it('should focus background shell on Tab when already visible (not toggle it off)', async () => {
|
||||
const mockToggleBackgroundShell = vi.fn();
|
||||
mockedUseGeminiStream.mockReturnValue({
|
||||
...DEFAULT_GEMINI_STREAM_MOCK,
|
||||
activePtyId: null,
|
||||
isBackgroundShellVisible: true,
|
||||
backgroundShells: new Map([[123, { pid: 123, status: 'running' }]]),
|
||||
toggleBackgroundShell: mockToggleBackgroundShell,
|
||||
});
|
||||
|
||||
await setupKeypressTest();
|
||||
|
||||
// Initially not focused
|
||||
expect(capturedUIState.embeddedShellFocused).toBe(false);
|
||||
|
||||
// Press Tab
|
||||
pressKey({ name: 'tab', shift: false });
|
||||
|
||||
// Should be focused
|
||||
expect(capturedUIState.embeddedShellFocused).toBe(true);
|
||||
// Should NOT have toggled (closed) the shell
|
||||
expect(mockToggleBackgroundShell).not.toHaveBeenCalled();
|
||||
|
||||
unmount();
|
||||
});
|
||||
});
|
||||
|
||||
describe('Background Shell Toggling (CTRL+B)', () => {
|
||||
it('should toggle background shell on Ctrl+B even if visible but not focused', async () => {
|
||||
const mockToggleBackgroundShell = vi.fn();
|
||||
mockedUseGeminiStream.mockReturnValue({
|
||||
...DEFAULT_GEMINI_STREAM_MOCK,
|
||||
activePtyId: null,
|
||||
isBackgroundShellVisible: true,
|
||||
backgroundShells: new Map([[123, { pid: 123, status: 'running' }]]),
|
||||
toggleBackgroundShell: mockToggleBackgroundShell,
|
||||
});
|
||||
|
||||
await setupKeypressTest();
|
||||
|
||||
// Initially not focused, but visible
|
||||
expect(capturedUIState.embeddedShellFocused).toBe(false);
|
||||
|
||||
// Press Ctrl+B
|
||||
pressKey({ name: 'b', ctrl: true });
|
||||
|
||||
// Should have toggled (closed) the shell
|
||||
expect(mockToggleBackgroundShell).toHaveBeenCalled();
|
||||
// Should be unfocused
|
||||
expect(capturedUIState.embeddedShellFocused).toBe(false);
|
||||
|
||||
unmount();
|
||||
});
|
||||
|
||||
it('should show and focus background shell on Ctrl+B if hidden', async () => {
|
||||
const mockToggleBackgroundShell = vi.fn();
|
||||
const geminiStreamMock = {
|
||||
...DEFAULT_GEMINI_STREAM_MOCK,
|
||||
activePtyId: null,
|
||||
isBackgroundShellVisible: false,
|
||||
backgroundShells: new Map([[123, { pid: 123, status: 'running' }]]),
|
||||
toggleBackgroundShell: mockToggleBackgroundShell,
|
||||
};
|
||||
mockedUseGeminiStream.mockReturnValue(geminiStreamMock);
|
||||
|
||||
await setupKeypressTest();
|
||||
|
||||
// Update the mock state when toggled to simulate real behavior
|
||||
mockToggleBackgroundShell.mockImplementation(() => {
|
||||
geminiStreamMock.isBackgroundShellVisible = true;
|
||||
});
|
||||
|
||||
// Press Ctrl+B
|
||||
pressKey({ name: 'b', ctrl: true });
|
||||
|
||||
// Should have toggled (shown) the shell
|
||||
expect(mockToggleBackgroundShell).toHaveBeenCalled();
|
||||
// Should be focused
|
||||
expect(capturedUIState.embeddedShellFocused).toBe(true);
|
||||
|
||||
unmount();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('Copy Mode (CTRL+S)', () => {
|
||||
|
||||
@@ -141,7 +141,6 @@ import { LoginWithGoogleRestartDialog } from './auth/LoginWithGoogleRestartDialo
|
||||
import { NewAgentsChoice } from './components/NewAgentsNotification.js';
|
||||
import { isSlashCommand } from './utils/commandUtils.js';
|
||||
import { useTerminalTheme } from './hooks/useTerminalTheme.js';
|
||||
import { isITerm2 } from './utils/terminalUtils.js';
|
||||
|
||||
function isToolExecuting(pendingHistoryItems: HistoryItemWithoutId[]) {
|
||||
return pendingHistoryItems.some((item) => {
|
||||
@@ -246,7 +245,7 @@ export const AppContainer = (props: AppContainerProps) => {
|
||||
[defaultBannerText, warningBannerText],
|
||||
);
|
||||
|
||||
const { bannerText } = useBanner(bannerData);
|
||||
const { bannerText } = useBanner(bannerData, config);
|
||||
|
||||
const extensionManager = config.getExtensionLoader() as ExtensionManager;
|
||||
// We are in the interactive CLI, update how we request consent and settings.
|
||||
@@ -525,22 +524,12 @@ export const AppContainer = (props: AppContainerProps) => {
|
||||
refreshStatic();
|
||||
}, [refreshStatic, isAlternateBuffer, app, config]);
|
||||
|
||||
const [editorError, setEditorError] = useState<string | null>(null);
|
||||
const {
|
||||
isEditorDialogOpen,
|
||||
openEditorDialog,
|
||||
handleEditorSelect,
|
||||
exitEditorDialog,
|
||||
} = useEditorSettings(settings, setEditorError, historyManager.addItem);
|
||||
|
||||
useEffect(() => {
|
||||
coreEvents.on(CoreEvent.ExternalEditorClosed, handleEditorClose);
|
||||
coreEvents.on(CoreEvent.RequestEditorSelection, openEditorDialog);
|
||||
return () => {
|
||||
coreEvents.off(CoreEvent.ExternalEditorClosed, handleEditorClose);
|
||||
coreEvents.off(CoreEvent.RequestEditorSelection, openEditorDialog);
|
||||
};
|
||||
}, [handleEditorClose, openEditorDialog]);
|
||||
}, [handleEditorClose]);
|
||||
|
||||
useEffect(() => {
|
||||
if (
|
||||
@@ -554,9 +543,6 @@ export const AppContainer = (props: AppContainerProps) => {
|
||||
}
|
||||
}, [bannerVisible, bannerText, settings, config, refreshStatic]);
|
||||
|
||||
const { isSettingsDialogOpen, openSettingsDialog, closeSettingsDialog } =
|
||||
useSettingsCommand();
|
||||
|
||||
const {
|
||||
isThemeDialogOpen,
|
||||
openThemeDialog,
|
||||
@@ -752,6 +738,17 @@ Logging in with Google... Restarting Gemini CLI to continue.
|
||||
onAuthError,
|
||||
]);
|
||||
|
||||
const [editorError, setEditorError] = useState<string | null>(null);
|
||||
const {
|
||||
isEditorDialogOpen,
|
||||
openEditorDialog,
|
||||
handleEditorSelect,
|
||||
exitEditorDialog,
|
||||
} = useEditorSettings(settings, setEditorError, historyManager.addItem);
|
||||
|
||||
const { isSettingsDialogOpen, openSettingsDialog, closeSettingsDialog } =
|
||||
useSettingsCommand();
|
||||
|
||||
const { isModelDialogOpen, openModelDialog, closeModelDialog } =
|
||||
useModelCommand();
|
||||
|
||||
@@ -760,7 +757,6 @@ Logging in with Google... Restarting Gemini CLI to continue.
|
||||
const setIsBackgroundShellListOpenRef = useRef<(open: boolean) => void>(
|
||||
() => {},
|
||||
);
|
||||
const [shortcutsHelpVisible, setShortcutsHelpVisible] = useState(false);
|
||||
|
||||
const slashCommandActions = useMemo(
|
||||
() => ({
|
||||
@@ -796,7 +792,6 @@ Logging in with Google... Restarting Gemini CLI to continue.
|
||||
}
|
||||
}
|
||||
},
|
||||
toggleShortcutsHelp: () => setShortcutsHelpVisible((visible) => !visible),
|
||||
setText: stableSetText,
|
||||
}),
|
||||
[
|
||||
@@ -815,7 +810,6 @@ Logging in with Google... Restarting Gemini CLI to continue.
|
||||
openPermissionsDialog,
|
||||
addConfirmUpdateExtensionRequest,
|
||||
toggleDebugProfiler,
|
||||
setShortcutsHelpVisible,
|
||||
stableSetText,
|
||||
],
|
||||
);
|
||||
@@ -1294,26 +1288,24 @@ Logging in with Google... Restarting Gemini CLI to continue.
|
||||
}, WARNING_PROMPT_DURATION_MS);
|
||||
}, []);
|
||||
|
||||
// Handle timeout cleanup on unmount
|
||||
useEffect(
|
||||
() => () => {
|
||||
useEffect(() => {
|
||||
const handleSelectionWarning = () => {
|
||||
handleWarning('Press Ctrl-S to enter selection mode to copy text.');
|
||||
};
|
||||
const handlePasteTimeout = () => {
|
||||
handleWarning('Paste Timed out. Possibly due to slow connection.');
|
||||
};
|
||||
appEvents.on(AppEvent.SelectionWarning, handleSelectionWarning);
|
||||
appEvents.on(AppEvent.PasteTimeout, handlePasteTimeout);
|
||||
return () => {
|
||||
appEvents.off(AppEvent.SelectionWarning, handleSelectionWarning);
|
||||
appEvents.off(AppEvent.PasteTimeout, handlePasteTimeout);
|
||||
if (warningTimeoutRef.current) {
|
||||
clearTimeout(warningTimeoutRef.current);
|
||||
}
|
||||
if (tabFocusTimeoutRef.current) {
|
||||
clearTimeout(tabFocusTimeoutRef.current);
|
||||
}
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
const handlePasteTimeout = () => {
|
||||
handleWarning('Paste Timed out. Possibly due to slow connection.');
|
||||
};
|
||||
appEvents.on(AppEvent.PasteTimeout, handlePasteTimeout);
|
||||
return () => {
|
||||
appEvents.off(AppEvent.PasteTimeout, handlePasteTimeout);
|
||||
};
|
||||
}, [handleWarning]);
|
||||
|
||||
@@ -1480,10 +1472,7 @@ Logging in with Google... Restarting Gemini CLI to continue.
|
||||
setShowErrorDetails((prev) => !prev);
|
||||
return true;
|
||||
} else if (keyMatchers[Command.SUSPEND_APP](key)) {
|
||||
const undoMessage = isITerm2()
|
||||
? 'Undo has been moved to Option + Z'
|
||||
: 'Undo has been moved to Alt/Option + Z or Cmd + Z';
|
||||
handleWarning(undoMessage);
|
||||
handleWarning('Undo has been moved to Cmd + Z or Alt/Opt + Z');
|
||||
return true;
|
||||
} else if (keyMatchers[Command.SHOW_FULL_TODOS](key)) {
|
||||
setShowFullTodos((prev) => !prev);
|
||||
@@ -1511,60 +1500,71 @@ Logging in with Google... Restarting Gemini CLI to continue.
|
||||
setConstrainHeight(false);
|
||||
return true;
|
||||
} else if (
|
||||
(keyMatchers[Command.FOCUS_SHELL_INPUT](key) ||
|
||||
keyMatchers[Command.UNFOCUS_BACKGROUND_SHELL_LIST](key)) &&
|
||||
keyMatchers[Command.FOCUS_SHELL_INPUT](key) &&
|
||||
(activePtyId || (isBackgroundShellVisible && backgroundShells.size > 0))
|
||||
) {
|
||||
if (embeddedShellFocused) {
|
||||
const capturedTime = lastOutputTimeRef.current;
|
||||
if (tabFocusTimeoutRef.current)
|
||||
clearTimeout(tabFocusTimeoutRef.current);
|
||||
tabFocusTimeoutRef.current = setTimeout(() => {
|
||||
if (lastOutputTimeRef.current === capturedTime) {
|
||||
setEmbeddedShellFocused(false);
|
||||
} else {
|
||||
handleWarning('Use Shift+Tab to unfocus');
|
||||
}
|
||||
}, 150);
|
||||
return false;
|
||||
}
|
||||
|
||||
const isIdle = Date.now() - lastOutputTimeRef.current >= 100;
|
||||
|
||||
if (isIdle && !activePtyId && !isBackgroundShellVisible) {
|
||||
if (tabFocusTimeoutRef.current)
|
||||
clearTimeout(tabFocusTimeoutRef.current);
|
||||
toggleBackgroundShell();
|
||||
setEmbeddedShellFocused(true);
|
||||
if (backgroundShells.size > 1) setIsBackgroundShellListOpen(true);
|
||||
return true;
|
||||
}
|
||||
|
||||
setEmbeddedShellFocused(true);
|
||||
return true;
|
||||
} else if (
|
||||
keyMatchers[Command.UNFOCUS_SHELL_INPUT](key) ||
|
||||
keyMatchers[Command.UNFOCUS_BACKGROUND_SHELL](key)
|
||||
) {
|
||||
if (embeddedShellFocused) {
|
||||
if (key.name === 'tab' && key.shift) {
|
||||
// Always change focus
|
||||
setEmbeddedShellFocused(false);
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
} else if (keyMatchers[Command.TOGGLE_BACKGROUND_SHELL](key)) {
|
||||
if (activePtyId) {
|
||||
backgroundCurrentShell();
|
||||
// After backgrounding, we explicitly do NOT show or focus the background UI.
|
||||
} else {
|
||||
|
||||
if (embeddedShellFocused) {
|
||||
handleWarning('Press Shift+Tab to focus out.');
|
||||
return true;
|
||||
}
|
||||
|
||||
const now = Date.now();
|
||||
// If the shell hasn't produced output in the last 100ms, it's considered idle.
|
||||
const isIdle = now - lastOutputTimeRef.current >= 100;
|
||||
if (isIdle && !activePtyId) {
|
||||
if (tabFocusTimeoutRef.current) {
|
||||
clearTimeout(tabFocusTimeoutRef.current);
|
||||
}
|
||||
toggleBackgroundShell();
|
||||
// Toggle focus based on intent: if we were hiding, unfocus; if showing, focus.
|
||||
if (!isBackgroundShellVisible && backgroundShells.size > 0) {
|
||||
if (!isBackgroundShellVisible) {
|
||||
// We are about to show it, so focus it
|
||||
setEmbeddedShellFocused(true);
|
||||
if (backgroundShells.size > 1) {
|
||||
setIsBackgroundShellListOpen(true);
|
||||
}
|
||||
} else {
|
||||
setEmbeddedShellFocused(false);
|
||||
// We are about to hide it
|
||||
tabFocusTimeoutRef.current = setTimeout(() => {
|
||||
tabFocusTimeoutRef.current = null;
|
||||
// If the shell produced output since the tab press, we assume it handled the tab
|
||||
// (e.g. autocomplete) so we should not toggle focus.
|
||||
if (lastOutputTimeRef.current > now) {
|
||||
handleWarning('Press Shift+Tab to focus out.');
|
||||
return;
|
||||
}
|
||||
setEmbeddedShellFocused(false);
|
||||
}, 100);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
// Not idle, just focus it
|
||||
setEmbeddedShellFocused(true);
|
||||
return true;
|
||||
} else if (keyMatchers[Command.TOGGLE_BACKGROUND_SHELL](key)) {
|
||||
if (activePtyId) {
|
||||
backgroundCurrentShell();
|
||||
// After backgrounding, we explicitly do NOT show or focus the background UI.
|
||||
} else {
|
||||
if (isBackgroundShellVisible && !embeddedShellFocused) {
|
||||
setEmbeddedShellFocused(true);
|
||||
} else {
|
||||
toggleBackgroundShell();
|
||||
// Toggle focus based on intent: if we were hiding, unfocus; if showing, focus.
|
||||
if (!isBackgroundShellVisible && backgroundShells.size > 0) {
|
||||
setEmbeddedShellFocused(true);
|
||||
if (backgroundShells.size > 1) {
|
||||
setIsBackgroundShellListOpen(true);
|
||||
}
|
||||
} else {
|
||||
setEmbeddedShellFocused(false);
|
||||
}
|
||||
}
|
||||
}
|
||||
return true;
|
||||
@@ -1607,7 +1607,7 @@ Logging in with Google... Restarting Gemini CLI to continue.
|
||||
],
|
||||
);
|
||||
|
||||
useKeypress(handleGlobalKeypress, { isActive: true, priority: true });
|
||||
useKeypress(handleGlobalKeypress, { isActive: true });
|
||||
|
||||
useEffect(() => {
|
||||
// Respect hideWindowTitle settings
|
||||
@@ -1766,8 +1766,7 @@ Logging in with Google... Restarting Gemini CLI to continue.
|
||||
|
||||
const fetchBannerTexts = async () => {
|
||||
const [defaultBanner, warningBanner] = await Promise.all([
|
||||
// TODO: temporarily disabling the banner, it will be re-added.
|
||||
'',
|
||||
config.getBannerTextNoCapacityIssues(),
|
||||
config.getBannerTextCapacityIssues(),
|
||||
]);
|
||||
|
||||
@@ -1775,6 +1774,15 @@ Logging in with Google... Restarting Gemini CLI to continue.
|
||||
setDefaultBannerText(defaultBanner);
|
||||
setWarningBannerText(warningBanner);
|
||||
setBannerVisible(true);
|
||||
const authType = config.getContentGeneratorConfig()?.authType;
|
||||
if (
|
||||
authType === AuthType.USE_GEMINI ||
|
||||
authType === AuthType.USE_VERTEX_AI
|
||||
) {
|
||||
setDefaultBannerText(
|
||||
'Gemini 3 Flash and Pro are now available. \nEnable "Preview features" in /settings. \nLearn more at https://goo.gle/enable-preview-features',
|
||||
);
|
||||
}
|
||||
}
|
||||
};
|
||||
// eslint-disable-next-line @typescript-eslint/no-floating-promises
|
||||
@@ -1843,7 +1851,6 @@ Logging in with Google... Restarting Gemini CLI to continue.
|
||||
ctrlCPressedOnce: ctrlCPressCount >= 1,
|
||||
ctrlDPressedOnce: ctrlDPressCount >= 1,
|
||||
showEscapePrompt,
|
||||
shortcutsHelpVisible,
|
||||
isFocused,
|
||||
elapsedTime,
|
||||
currentLoadingPhrase,
|
||||
@@ -1949,7 +1956,6 @@ Logging in with Google... Restarting Gemini CLI to continue.
|
||||
ctrlCPressCount,
|
||||
ctrlDPressCount,
|
||||
showEscapePrompt,
|
||||
shortcutsHelpVisible,
|
||||
isFocused,
|
||||
elapsedTime,
|
||||
currentLoadingPhrase,
|
||||
@@ -2049,7 +2055,6 @@ Logging in with Google... Restarting Gemini CLI to continue.
|
||||
handleApiKeySubmit,
|
||||
handleApiKeyCancel,
|
||||
setBannerVisible,
|
||||
setShortcutsHelpVisible,
|
||||
handleWarning,
|
||||
setEmbeddedShellFocused,
|
||||
dismissBackgroundShell,
|
||||
@@ -2126,7 +2131,6 @@ Logging in with Google... Restarting Gemini CLI to continue.
|
||||
handleApiKeySubmit,
|
||||
handleApiKeyCancel,
|
||||
setBannerVisible,
|
||||
setShortcutsHelpVisible,
|
||||
handleWarning,
|
||||
setEmbeddedShellFocused,
|
||||
dismissBackgroundShell,
|
||||
|
||||
@@ -4,8 +4,6 @@
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { type ReactElement } from 'react';
|
||||
|
||||
import type {
|
||||
ExtensionLoader,
|
||||
GeminiCLIExtension,
|
||||
@@ -17,12 +15,7 @@ import {
|
||||
completeExtensionsAndScopes,
|
||||
extensionsCommand,
|
||||
} from './extensionsCommand.js';
|
||||
import {
|
||||
ConfigExtensionDialog,
|
||||
type ConfigExtensionDialogProps,
|
||||
} from '../components/ConfigExtensionDialog.js';
|
||||
import { type CommandContext, type SlashCommand } from './types.js';
|
||||
|
||||
import {
|
||||
describe,
|
||||
it,
|
||||
@@ -60,20 +53,6 @@ vi.mock('node:fs/promises', () => ({
|
||||
stat: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('../../config/extensions/extensionSettings.js', () => ({
|
||||
ExtensionSettingScope: {
|
||||
USER: 'user',
|
||||
WORKSPACE: 'workspace',
|
||||
},
|
||||
getScopedEnvContents: vi.fn().mockResolvedValue({}),
|
||||
promptForSetting: vi.fn(),
|
||||
updateSetting: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('prompts', () => ({
|
||||
default: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('../../config/extensions/update.js', () => ({
|
||||
updateExtension: vi.fn(),
|
||||
checkForAllExtensionUpdates: vi.fn(),
|
||||
@@ -128,41 +107,28 @@ const allExt: GeminiCLIExtension = {
|
||||
describe('extensionsCommand', () => {
|
||||
let mockContext: CommandContext;
|
||||
const mockDispatchExtensionState = vi.fn();
|
||||
let mockExtensionLoader: unknown;
|
||||
let mockReloadSkills: MockedFunction<() => Promise<void>>;
|
||||
let mockReloadAgents: MockedFunction<() => Promise<void>>;
|
||||
|
||||
beforeEach(() => {
|
||||
vi.resetAllMocks();
|
||||
|
||||
mockExtensionLoader = Object.create(ExtensionManager.prototype);
|
||||
Object.assign(mockExtensionLoader as object, {
|
||||
enableExtension: mockEnableExtension,
|
||||
disableExtension: mockDisableExtension,
|
||||
installOrUpdateExtension: mockInstallExtension,
|
||||
uninstallExtension: mockUninstallExtension,
|
||||
getExtensions: mockGetExtensions,
|
||||
loadExtensionConfig: vi.fn().mockResolvedValue({
|
||||
name: 'test-ext',
|
||||
settings: [{ name: 'setting1', envVar: 'SETTING1' }],
|
||||
}),
|
||||
});
|
||||
|
||||
mockGetExtensions.mockReturnValue([inactiveExt, activeExt, allExt]);
|
||||
vi.mocked(open).mockClear();
|
||||
mockReloadAgents = vi.fn().mockResolvedValue(undefined);
|
||||
mockReloadSkills = vi.fn().mockResolvedValue(undefined);
|
||||
|
||||
mockContext = createMockCommandContext({
|
||||
services: {
|
||||
config: {
|
||||
getExtensions: mockGetExtensions,
|
||||
getExtensionLoader: vi.fn().mockReturnValue(mockExtensionLoader),
|
||||
getWorkingDir: () => '/test/dir',
|
||||
reloadSkills: mockReloadSkills,
|
||||
getAgentRegistry: vi.fn().mockReturnValue({
|
||||
reload: mockReloadAgents,
|
||||
getExtensionLoader: vi.fn().mockImplementation(() => {
|
||||
const actual = Object.create(ExtensionManager.prototype);
|
||||
Object.assign(actual, {
|
||||
enableExtension: mockEnableExtension,
|
||||
disableExtension: mockDisableExtension,
|
||||
installOrUpdateExtension: mockInstallExtension,
|
||||
uninstallExtension: mockUninstallExtension,
|
||||
getExtensions: mockGetExtensions,
|
||||
});
|
||||
return actual;
|
||||
}),
|
||||
getWorkingDir: () => '/test/dir',
|
||||
},
|
||||
},
|
||||
ui: {
|
||||
@@ -901,27 +867,6 @@ describe('extensionsCommand', () => {
|
||||
type: 'RESTARTED',
|
||||
payload: { name: 'ext2' },
|
||||
});
|
||||
expect(mockReloadSkills).toHaveBeenCalled();
|
||||
expect(mockReloadAgents).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('handles errors during skill or agent reload', async () => {
|
||||
const mockExtensions = [
|
||||
{ name: 'ext1', isActive: true },
|
||||
] as GeminiCLIExtension[];
|
||||
mockGetExtensions.mockReturnValue(mockExtensions);
|
||||
mockReloadSkills.mockRejectedValue(new Error('Failed to reload skills'));
|
||||
|
||||
await restartAction!(mockContext, '--all');
|
||||
|
||||
expect(mockRestartExtension).toHaveBeenCalledWith(mockExtensions[0]);
|
||||
expect(mockReloadSkills).toHaveBeenCalled();
|
||||
expect(mockContext.ui.addItem).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
type: MessageType.ERROR,
|
||||
text: 'Failed to reload skills or agents: Failed to reload skills',
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('restarts only specified active extensions', async () => {
|
||||
@@ -1033,102 +978,4 @@ describe('extensionsCommand', () => {
|
||||
expect(suggestions).toEqual(['ext1']);
|
||||
});
|
||||
});
|
||||
|
||||
describe('config', () => {
|
||||
let configAction: SlashCommand['action'];
|
||||
|
||||
beforeEach(async () => {
|
||||
configAction = extensionsCommand(true).subCommands?.find(
|
||||
(cmd) => cmd.name === 'config',
|
||||
)?.action;
|
||||
|
||||
expect(configAction).not.toBeNull();
|
||||
mockContext.invocation!.name = 'config';
|
||||
|
||||
const prompts = (await import('prompts')).default;
|
||||
vi.mocked(prompts).mockResolvedValue({ overwrite: true });
|
||||
|
||||
const { getScopedEnvContents } = await import(
|
||||
'../../config/extensions/extensionSettings.js'
|
||||
);
|
||||
vi.mocked(getScopedEnvContents).mockResolvedValue({});
|
||||
});
|
||||
|
||||
it('should return dialog to configure all extensions if no args provided', async () => {
|
||||
const result = await configAction!(mockContext, '');
|
||||
if (result?.type !== 'custom_dialog') {
|
||||
throw new Error('Expected custom_dialog');
|
||||
}
|
||||
const dialogResult = result;
|
||||
const component =
|
||||
dialogResult.component as ReactElement<ConfigExtensionDialogProps>;
|
||||
expect(component.type).toBe(ConfigExtensionDialog);
|
||||
expect(component.props.configureAll).toBe(true);
|
||||
expect(component.props.extensionManager).toBeDefined();
|
||||
});
|
||||
|
||||
it('should return dialog to configure specific extension', async () => {
|
||||
const result = await configAction!(mockContext, 'ext-one');
|
||||
if (result?.type !== 'custom_dialog') {
|
||||
throw new Error('Expected custom_dialog');
|
||||
}
|
||||
const dialogResult = result;
|
||||
const component =
|
||||
dialogResult.component as ReactElement<ConfigExtensionDialogProps>;
|
||||
expect(component.type).toBe(ConfigExtensionDialog);
|
||||
expect(component.props.extensionName).toBe('ext-one');
|
||||
expect(component.props.settingKey).toBeUndefined();
|
||||
expect(component.props.configureAll).toBe(false);
|
||||
});
|
||||
|
||||
it('should return dialog to configure specific setting for an extension', async () => {
|
||||
const result = await configAction!(mockContext, 'ext-one SETTING1');
|
||||
if (result?.type !== 'custom_dialog') {
|
||||
throw new Error('Expected custom_dialog');
|
||||
}
|
||||
const dialogResult = result;
|
||||
const component =
|
||||
dialogResult.component as ReactElement<ConfigExtensionDialogProps>;
|
||||
expect(component.type).toBe(ConfigExtensionDialog);
|
||||
expect(component.props.extensionName).toBe('ext-one');
|
||||
expect(component.props.settingKey).toBe('SETTING1');
|
||||
expect(component.props.scope).toBe('user'); // Default scope
|
||||
});
|
||||
|
||||
it('should respect scope argument passed to dialog', async () => {
|
||||
const result = await configAction!(
|
||||
mockContext,
|
||||
'ext-one SETTING1 --scope=workspace',
|
||||
);
|
||||
if (result?.type !== 'custom_dialog') {
|
||||
throw new Error('Expected custom_dialog');
|
||||
}
|
||||
const dialogResult = result;
|
||||
const component =
|
||||
dialogResult.component as ReactElement<ConfigExtensionDialogProps>;
|
||||
expect(component.props.scope).toBe('workspace');
|
||||
});
|
||||
|
||||
it('should show error for invalid extension name', async () => {
|
||||
await configAction!(mockContext, '../invalid');
|
||||
expect(mockContext.ui.addItem).toHaveBeenCalledWith({
|
||||
type: MessageType.ERROR,
|
||||
text: 'Invalid extension name. Names cannot contain path separators or "..".',
|
||||
});
|
||||
});
|
||||
|
||||
// "should inform if extension has no settings" - This check is now inside ConfigExtensionDialog logic.
|
||||
// We can test that we still return a dialog, and the dialog will handle logical checks via utils.ts
|
||||
// For unit testing extensionsCommand, we just ensure delegation.
|
||||
it('should return dialog even if extension has no settings (dialog handles logic)', async () => {
|
||||
const result = await configAction!(mockContext, 'ext-one');
|
||||
if (result?.type !== 'custom_dialog') {
|
||||
throw new Error('Expected custom_dialog');
|
||||
}
|
||||
const dialogResult = result;
|
||||
const component =
|
||||
dialogResult.component as ReactElement<ConfigExtensionDialogProps>;
|
||||
expect(component.type).toBe(ConfigExtensionDialog);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -32,10 +32,6 @@ import { SettingScope } from '../../config/settings.js';
|
||||
import { McpServerEnablementManager } from '../../config/mcp/mcpServerEnablement.js';
|
||||
import { theme } from '../semantic-colors.js';
|
||||
import { stat } from 'node:fs/promises';
|
||||
import { ExtensionSettingScope } from '../../config/extensions/extensionSettings.js';
|
||||
import { type ConfigLogger } from '../../commands/extensions/utils.js';
|
||||
import { ConfigExtensionDialog } from '../components/ConfigExtensionDialog.js';
|
||||
import React from 'react';
|
||||
|
||||
function showMessageIfNoExtensions(
|
||||
context: CommandContext,
|
||||
@@ -231,18 +227,6 @@ async function restartAction(
|
||||
(result): result is PromiseRejectedResult => result.status === 'rejected',
|
||||
);
|
||||
|
||||
if (failures.length < extensionsToRestart.length) {
|
||||
try {
|
||||
await context.services.config?.reloadSkills();
|
||||
await context.services.config?.getAgentRegistry()?.reload();
|
||||
} catch (error) {
|
||||
context.ui.addItem({
|
||||
type: MessageType.ERROR,
|
||||
text: `Failed to reload skills or agents: ${getErrorMessage(error)}`,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if (failures.length > 0) {
|
||||
const errorMessages = failures
|
||||
.map((failure, index) => {
|
||||
@@ -599,77 +583,6 @@ async function uninstallAction(context: CommandContext, args: string) {
|
||||
}
|
||||
}
|
||||
|
||||
async function configAction(context: CommandContext, args: string) {
|
||||
const parts = args.trim().split(/\s+/).filter(Boolean);
|
||||
let scope = ExtensionSettingScope.USER;
|
||||
|
||||
const scopeEqIndex = parts.findIndex((p) => p.startsWith('--scope='));
|
||||
if (scopeEqIndex > -1) {
|
||||
const scopeVal = parts[scopeEqIndex].split('=')[1];
|
||||
if (scopeVal === 'workspace') {
|
||||
scope = ExtensionSettingScope.WORKSPACE;
|
||||
} else if (scopeVal === 'user') {
|
||||
scope = ExtensionSettingScope.USER;
|
||||
}
|
||||
parts.splice(scopeEqIndex, 1);
|
||||
} else {
|
||||
const scopeIndex = parts.indexOf('--scope');
|
||||
if (scopeIndex > -1) {
|
||||
const scopeVal = parts[scopeIndex + 1];
|
||||
if (scopeVal === 'workspace' || scopeVal === 'user') {
|
||||
scope =
|
||||
scopeVal === 'workspace'
|
||||
? ExtensionSettingScope.WORKSPACE
|
||||
: ExtensionSettingScope.USER;
|
||||
parts.splice(scopeIndex, 2);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const otherArgs = parts;
|
||||
const name = otherArgs[0];
|
||||
const setting = otherArgs[1];
|
||||
|
||||
if (name) {
|
||||
if (name.includes('/') || name.includes('\\') || name.includes('..')) {
|
||||
context.ui.addItem({
|
||||
type: MessageType.ERROR,
|
||||
text: 'Invalid extension name. Names cannot contain path separators or "..".',
|
||||
});
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
const extensionManager = context.services.config?.getExtensionLoader();
|
||||
if (!(extensionManager instanceof ExtensionManager)) {
|
||||
debugLogger.error(
|
||||
`Cannot ${context.invocation?.name} extensions in this environment`,
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
const logger: ConfigLogger = {
|
||||
log: (message: string) => {
|
||||
context.ui.addItem({ type: MessageType.INFO, text: message.trim() });
|
||||
},
|
||||
error: (message: string) =>
|
||||
context.ui.addItem({ type: MessageType.ERROR, text: message }),
|
||||
};
|
||||
|
||||
return {
|
||||
type: 'custom_dialog' as const,
|
||||
component: React.createElement(ConfigExtensionDialog, {
|
||||
extensionManager,
|
||||
onClose: () => context.ui.removeComponent(),
|
||||
extensionName: name,
|
||||
settingKey: setting,
|
||||
scope,
|
||||
configureAll: !name && !setting,
|
||||
loggerAdapter: logger,
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Exported for testing.
|
||||
*/
|
||||
@@ -788,14 +701,6 @@ const restartCommand: SlashCommand = {
|
||||
completion: completeExtensions,
|
||||
};
|
||||
|
||||
const configCommand: SlashCommand = {
|
||||
name: 'config',
|
||||
description: 'Configure extension settings',
|
||||
kind: CommandKind.BUILT_IN,
|
||||
autoExecute: false,
|
||||
action: configAction,
|
||||
};
|
||||
|
||||
export function extensionsCommand(
|
||||
enableExtensionReloading?: boolean,
|
||||
): SlashCommand {
|
||||
@@ -806,7 +711,6 @@ export function extensionsCommand(
|
||||
installCommand,
|
||||
uninstallCommand,
|
||||
linkCommand,
|
||||
configCommand,
|
||||
]
|
||||
: [];
|
||||
return {
|
||||
|
||||
@@ -10,6 +10,7 @@ import { MessageType, type HistoryItemHelp } from '../types.js';
|
||||
|
||||
export const helpCommand: SlashCommand = {
|
||||
name: 'help',
|
||||
altNames: ['?'],
|
||||
kind: CommandKind.BUILT_IN,
|
||||
description: 'For help on gemini-cli',
|
||||
autoExecute: true,
|
||||
|
||||
@@ -60,7 +60,6 @@ const createMockMCPTool = (
|
||||
{ type: 'object', properties: {} },
|
||||
mockMessageBus,
|
||||
undefined, // trust
|
||||
undefined, // isReadOnly
|
||||
undefined, // nameOverride
|
||||
undefined, // cliConfig
|
||||
undefined, // extensionName
|
||||
|
||||
@@ -1,19 +0,0 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import type { SlashCommand } from './types.js';
|
||||
import { CommandKind } from './types.js';
|
||||
|
||||
export const shortcutsCommand: SlashCommand = {
|
||||
name: 'shortcuts',
|
||||
altNames: [],
|
||||
kind: CommandKind.BUILT_IN,
|
||||
description: 'Toggle the shortcuts panel above the input',
|
||||
autoExecute: true,
|
||||
action: (context) => {
|
||||
context.ui.toggleShortcutsHelp();
|
||||
},
|
||||
};
|
||||
@@ -91,7 +91,6 @@ export interface CommandContext {
|
||||
setConfirmationRequest: (value: ConfirmationRequest) => void;
|
||||
removeComponent: () => void;
|
||||
toggleBackgroundShell: () => void;
|
||||
toggleShortcutsHelp: () => void;
|
||||
};
|
||||
// Session-specific data
|
||||
session: {
|
||||
|
||||
@@ -68,9 +68,8 @@ describe('<AnsiOutputText />', () => {
|
||||
const output = lastFrame();
|
||||
expect(output).toBeDefined();
|
||||
const lines = output!.split('\n');
|
||||
expect(lines[0].trim()).toBe('First line');
|
||||
expect(lines[1].trim()).toBe('');
|
||||
expect(lines[2].trim()).toBe('Third line');
|
||||
expect(lines[0]).toBe('First line');
|
||||
expect(lines[1]).toBe('Third line');
|
||||
});
|
||||
|
||||
it('respects the availableTerminalHeight prop and slices the lines correctly', () => {
|
||||
@@ -90,45 +89,6 @@ describe('<AnsiOutputText />', () => {
|
||||
expect(output).toContain('Line 4');
|
||||
});
|
||||
|
||||
it('respects the maxLines prop and slices the lines correctly', () => {
|
||||
const data: AnsiOutput = [
|
||||
[createAnsiToken({ text: 'Line 1' })],
|
||||
[createAnsiToken({ text: 'Line 2' })],
|
||||
[createAnsiToken({ text: 'Line 3' })],
|
||||
[createAnsiToken({ text: 'Line 4' })],
|
||||
];
|
||||
const { lastFrame } = render(
|
||||
<AnsiOutputText data={data} maxLines={2} width={80} />,
|
||||
);
|
||||
const output = lastFrame();
|
||||
expect(output).not.toContain('Line 1');
|
||||
expect(output).not.toContain('Line 2');
|
||||
expect(output).toContain('Line 3');
|
||||
expect(output).toContain('Line 4');
|
||||
});
|
||||
|
||||
it('prioritizes maxLines over availableTerminalHeight if maxLines is smaller', () => {
|
||||
const data: AnsiOutput = [
|
||||
[createAnsiToken({ text: 'Line 1' })],
|
||||
[createAnsiToken({ text: 'Line 2' })],
|
||||
[createAnsiToken({ text: 'Line 3' })],
|
||||
[createAnsiToken({ text: 'Line 4' })],
|
||||
];
|
||||
// availableTerminalHeight=3, maxLines=2 => show 2 lines
|
||||
const { lastFrame } = render(
|
||||
<AnsiOutputText
|
||||
data={data}
|
||||
availableTerminalHeight={3}
|
||||
maxLines={2}
|
||||
width={80}
|
||||
/>,
|
||||
);
|
||||
const output = lastFrame();
|
||||
expect(output).not.toContain('Line 2');
|
||||
expect(output).toContain('Line 3');
|
||||
expect(output).toContain('Line 4');
|
||||
});
|
||||
|
||||
it('renders a large AnsiOutput object without crashing', () => {
|
||||
const largeData: AnsiOutput = [];
|
||||
for (let i = 0; i < 1000; i++) {
|
||||
|
||||
@@ -14,56 +14,40 @@ interface AnsiOutputProps {
|
||||
data: AnsiOutput;
|
||||
availableTerminalHeight?: number;
|
||||
width: number;
|
||||
maxLines?: number;
|
||||
disableTruncation?: boolean;
|
||||
}
|
||||
|
||||
export const AnsiOutputText: React.FC<AnsiOutputProps> = ({
|
||||
data,
|
||||
availableTerminalHeight,
|
||||
width,
|
||||
maxLines,
|
||||
disableTruncation,
|
||||
}) => {
|
||||
const availableHeightLimit =
|
||||
availableTerminalHeight && availableTerminalHeight > 0
|
||||
const lastLines = data.slice(
|
||||
-(availableTerminalHeight && availableTerminalHeight > 0
|
||||
? availableTerminalHeight
|
||||
: undefined;
|
||||
|
||||
const numLinesRetained =
|
||||
availableHeightLimit !== undefined && maxLines !== undefined
|
||||
? Math.min(availableHeightLimit, maxLines)
|
||||
: (availableHeightLimit ?? maxLines ?? DEFAULT_HEIGHT);
|
||||
|
||||
const lastLines = disableTruncation ? data : data.slice(-numLinesRetained);
|
||||
: DEFAULT_HEIGHT),
|
||||
);
|
||||
return (
|
||||
<Box flexDirection="column" width={width} flexShrink={0} overflow="hidden">
|
||||
<Box flexDirection="column" width={width} flexShrink={0}>
|
||||
{lastLines.map((line: AnsiLine, lineIndex: number) => (
|
||||
<Box key={lineIndex} height={1} overflow="hidden">
|
||||
<AnsiLineText line={line} />
|
||||
</Box>
|
||||
<Text key={lineIndex} wrap="truncate">
|
||||
{line.length > 0
|
||||
? line.map((token: AnsiToken, tokenIndex: number) => (
|
||||
<Text
|
||||
key={tokenIndex}
|
||||
color={token.fg}
|
||||
backgroundColor={token.bg}
|
||||
inverse={token.inverse}
|
||||
dimColor={token.dim}
|
||||
bold={token.bold}
|
||||
italic={token.italic}
|
||||
underline={token.underline}
|
||||
>
|
||||
{token.text}
|
||||
</Text>
|
||||
))
|
||||
: null}
|
||||
</Text>
|
||||
))}
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
export const AnsiLineText: React.FC<{ line: AnsiLine }> = ({ line }) => (
|
||||
<Text>
|
||||
{line.length > 0
|
||||
? line.map((token: AnsiToken, tokenIndex: number) => (
|
||||
<Text
|
||||
key={tokenIndex}
|
||||
color={token.fg}
|
||||
backgroundColor={token.bg}
|
||||
inverse={token.inverse}
|
||||
dimColor={token.dim}
|
||||
bold={token.bold}
|
||||
italic={token.italic}
|
||||
underline={token.underline}
|
||||
>
|
||||
{token.text}
|
||||
</Text>
|
||||
))
|
||||
: null}
|
||||
</Text>
|
||||
);
|
||||
|
||||
@@ -89,6 +89,53 @@ describe('<AppHeader />', () => {
|
||||
unmount();
|
||||
});
|
||||
|
||||
it('should render the banner when previewFeatures is disabled', () => {
|
||||
const mockConfig = makeFakeConfig({ previewFeatures: false });
|
||||
const uiState = {
|
||||
history: [],
|
||||
bannerData: {
|
||||
defaultText: 'This is the default banner',
|
||||
warningText: '',
|
||||
},
|
||||
bannerVisible: true,
|
||||
};
|
||||
|
||||
const { lastFrame, unmount } = renderWithProviders(
|
||||
<AppHeader version="1.0.0" />,
|
||||
{
|
||||
config: mockConfig,
|
||||
uiState,
|
||||
},
|
||||
);
|
||||
|
||||
expect(lastFrame()).toContain('This is the default banner');
|
||||
expect(lastFrame()).toMatchSnapshot();
|
||||
unmount();
|
||||
});
|
||||
|
||||
it('should not render the banner when previewFeatures is enabled', () => {
|
||||
const mockConfig = makeFakeConfig({ previewFeatures: true });
|
||||
const uiState = {
|
||||
history: [],
|
||||
bannerData: {
|
||||
defaultText: 'This is the default banner',
|
||||
warningText: '',
|
||||
},
|
||||
};
|
||||
|
||||
const { lastFrame, unmount } = renderWithProviders(
|
||||
<AppHeader version="1.0.0" />,
|
||||
{
|
||||
config: mockConfig,
|
||||
uiState,
|
||||
},
|
||||
);
|
||||
|
||||
expect(lastFrame()).not.toContain('This is the default banner');
|
||||
expect(lastFrame()).toMatchSnapshot();
|
||||
unmount();
|
||||
});
|
||||
|
||||
it('should not render the default banner if shown count is 5 or more', () => {
|
||||
const mockConfig = makeFakeConfig();
|
||||
const uiState = {
|
||||
|
||||
@@ -24,7 +24,7 @@ export const AppHeader = ({ version }: AppHeaderProps) => {
|
||||
const config = useConfig();
|
||||
const { nightly, terminalWidth, bannerData, bannerVisible } = useUIState();
|
||||
|
||||
const { bannerText } = useBanner(bannerData);
|
||||
const { bannerText } = useBanner(bannerData, config);
|
||||
const { showTips } = useTips();
|
||||
|
||||
return (
|
||||
|
||||
@@ -15,20 +15,8 @@ describe('ApprovalModeIndicator', () => {
|
||||
<ApprovalModeIndicator approvalMode={ApprovalMode.AUTO_EDIT} />,
|
||||
);
|
||||
const output = lastFrame();
|
||||
expect(output).toContain('auto-edit');
|
||||
expect(output).toContain('shift + tab to enter default mode');
|
||||
});
|
||||
|
||||
it('renders correctly for AUTO_EDIT mode with plan enabled', () => {
|
||||
const { lastFrame } = render(
|
||||
<ApprovalModeIndicator
|
||||
approvalMode={ApprovalMode.AUTO_EDIT}
|
||||
isPlanEnabled={true}
|
||||
/>,
|
||||
);
|
||||
const output = lastFrame();
|
||||
expect(output).toContain('auto-edit');
|
||||
expect(output).toContain('shift + tab to enter default mode');
|
||||
expect(output).toContain('accepting edits');
|
||||
expect(output).toContain('(shift + tab to cycle)');
|
||||
});
|
||||
|
||||
it('renders correctly for PLAN mode', () => {
|
||||
@@ -36,8 +24,8 @@ describe('ApprovalModeIndicator', () => {
|
||||
<ApprovalModeIndicator approvalMode={ApprovalMode.PLAN} />,
|
||||
);
|
||||
const output = lastFrame();
|
||||
expect(output).toContain('plan');
|
||||
expect(output).toContain('shift + tab to enter auto-edit mode');
|
||||
expect(output).toContain('plan mode');
|
||||
expect(output).toContain('(shift + tab to cycle)');
|
||||
});
|
||||
|
||||
it('renders correctly for YOLO mode', () => {
|
||||
@@ -45,26 +33,16 @@ describe('ApprovalModeIndicator', () => {
|
||||
<ApprovalModeIndicator approvalMode={ApprovalMode.YOLO} />,
|
||||
);
|
||||
const output = lastFrame();
|
||||
expect(output).toContain('YOLO');
|
||||
expect(output).toContain('shift + tab to enter auto-edit mode');
|
||||
expect(output).toContain('YOLO mode');
|
||||
expect(output).toContain('(ctrl + y to toggle)');
|
||||
});
|
||||
|
||||
it('renders correctly for DEFAULT mode', () => {
|
||||
it('renders nothing for DEFAULT mode', () => {
|
||||
const { lastFrame } = render(
|
||||
<ApprovalModeIndicator approvalMode={ApprovalMode.DEFAULT} />,
|
||||
);
|
||||
const output = lastFrame();
|
||||
expect(output).toContain('shift + tab to enter auto-edit mode');
|
||||
});
|
||||
|
||||
it('renders correctly for DEFAULT mode with plan enabled', () => {
|
||||
const { lastFrame } = render(
|
||||
<ApprovalModeIndicator
|
||||
approvalMode={ApprovalMode.DEFAULT}
|
||||
isPlanEnabled={true}
|
||||
/>,
|
||||
);
|
||||
const output = lastFrame();
|
||||
expect(output).toContain('shift + tab to enter plan mode');
|
||||
expect(output).not.toContain('accepting edits');
|
||||
expect(output).not.toContain('YOLO mode');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -11,12 +11,10 @@ import { ApprovalMode } from '@google/gemini-cli-core';
|
||||
|
||||
interface ApprovalModeIndicatorProps {
|
||||
approvalMode: ApprovalMode;
|
||||
isPlanEnabled?: boolean;
|
||||
}
|
||||
|
||||
export const ApprovalModeIndicator: React.FC<ApprovalModeIndicatorProps> = ({
|
||||
approvalMode,
|
||||
isPlanEnabled,
|
||||
}) => {
|
||||
let textColor = '';
|
||||
let textContent = '';
|
||||
@@ -25,39 +23,29 @@ export const ApprovalModeIndicator: React.FC<ApprovalModeIndicatorProps> = ({
|
||||
switch (approvalMode) {
|
||||
case ApprovalMode.AUTO_EDIT:
|
||||
textColor = theme.status.warning;
|
||||
textContent = 'auto-edit';
|
||||
subText = 'shift + tab to enter default mode';
|
||||
textContent = 'accepting edits';
|
||||
subText = ' (shift + tab to cycle)';
|
||||
break;
|
||||
case ApprovalMode.PLAN:
|
||||
textColor = theme.status.success;
|
||||
textContent = 'plan';
|
||||
subText = 'shift + tab to enter auto-edit mode';
|
||||
textContent = 'plan mode';
|
||||
subText = ' (shift + tab to cycle)';
|
||||
break;
|
||||
case ApprovalMode.YOLO:
|
||||
textColor = theme.status.error;
|
||||
textContent = 'YOLO';
|
||||
subText = 'shift + tab to enter auto-edit mode';
|
||||
textContent = 'YOLO mode';
|
||||
subText = ' (ctrl + y to toggle)';
|
||||
break;
|
||||
case ApprovalMode.DEFAULT:
|
||||
default:
|
||||
textColor = theme.text.accent;
|
||||
textContent = '';
|
||||
subText = isPlanEnabled
|
||||
? 'shift + tab to enter plan mode'
|
||||
: 'shift + tab to enter auto-edit mode';
|
||||
break;
|
||||
}
|
||||
|
||||
return (
|
||||
<Box>
|
||||
<Text color={textColor}>
|
||||
{textContent ? textContent : null}
|
||||
{subText ? (
|
||||
<Text color={theme.text.secondary}>
|
||||
{textContent ? ' ' : ''}
|
||||
{subText}
|
||||
</Text>
|
||||
) : null}
|
||||
{textContent}
|
||||
{subText && <Text color={theme.text.secondary}>{subText}</Text>}
|
||||
</Text>
|
||||
</Box>
|
||||
);
|
||||
|
||||
@@ -405,4 +405,55 @@ describe('<BackgroundShellDisplay />', () => {
|
||||
|
||||
expect(lastFrame()).toMatchSnapshot();
|
||||
});
|
||||
|
||||
it('unfocuses the shell when Shift+Tab is pressed', async () => {
|
||||
render(
|
||||
<ScrollProvider>
|
||||
<BackgroundShellDisplay
|
||||
shells={mockShells}
|
||||
activePid={shell1.pid}
|
||||
width={80}
|
||||
height={24}
|
||||
isFocused={true}
|
||||
isListOpenProp={false}
|
||||
/>
|
||||
</ScrollProvider>,
|
||||
);
|
||||
await act(async () => {
|
||||
await delay(0);
|
||||
});
|
||||
|
||||
act(() => {
|
||||
simulateKey({ name: 'tab', shift: true });
|
||||
});
|
||||
|
||||
expect(mockSetEmbeddedShellFocused).toHaveBeenCalledWith(false);
|
||||
});
|
||||
|
||||
it('shows a warning when Tab is pressed', async () => {
|
||||
render(
|
||||
<ScrollProvider>
|
||||
<BackgroundShellDisplay
|
||||
shells={mockShells}
|
||||
activePid={shell1.pid}
|
||||
width={80}
|
||||
height={24}
|
||||
isFocused={true}
|
||||
isListOpenProp={false}
|
||||
/>
|
||||
</ScrollProvider>,
|
||||
);
|
||||
await act(async () => {
|
||||
await delay(0);
|
||||
});
|
||||
|
||||
act(() => {
|
||||
simulateKey({ name: 'tab' });
|
||||
});
|
||||
|
||||
expect(mockHandleWarning).toHaveBeenCalledWith(
|
||||
'Press Shift+Tab to focus out.',
|
||||
);
|
||||
expect(mockSetEmbeddedShellFocused).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -18,7 +18,7 @@ import { cpLen, cpSlice, getCachedStringWidth } from '../utils/textUtils.js';
|
||||
import { type BackgroundShell } from '../hooks/shellCommandProcessor.js';
|
||||
import { Command, keyMatchers } from '../keyMatchers.js';
|
||||
import { useKeypress } from '../hooks/useKeypress.js';
|
||||
import { formatCommand } from '../utils/keybindingUtils.js';
|
||||
import { commandDescriptions } from '../../config/keyBindings.js';
|
||||
import {
|
||||
ScrollableList,
|
||||
type ScrollableListRef,
|
||||
@@ -64,6 +64,8 @@ export const BackgroundShellDisplay = ({
|
||||
dismissBackgroundShell,
|
||||
setActiveBackgroundShellPid,
|
||||
setIsBackgroundShellListOpen,
|
||||
handleWarning,
|
||||
setEmbeddedShellFocused,
|
||||
} = useUIActions();
|
||||
const activeShell = shells.get(activePid);
|
||||
const [output, setOutput] = useState<string | AnsiOutput>(
|
||||
@@ -136,6 +138,27 @@ export const BackgroundShellDisplay = ({
|
||||
(key) => {
|
||||
if (!activeShell) return;
|
||||
|
||||
// Handle Shift+Tab or Tab (in list) to focus out
|
||||
if (
|
||||
keyMatchers[Command.UNFOCUS_BACKGROUND_SHELL](key) ||
|
||||
(isListOpenProp &&
|
||||
keyMatchers[Command.UNFOCUS_BACKGROUND_SHELL_LIST](key))
|
||||
) {
|
||||
setEmbeddedShellFocused(false);
|
||||
return true;
|
||||
}
|
||||
|
||||
// Handle Tab to warn but propagate
|
||||
if (
|
||||
!isListOpenProp &&
|
||||
keyMatchers[Command.SHOW_BACKGROUND_SHELL_UNFOCUS_WARNING](key)
|
||||
) {
|
||||
handleWarning(
|
||||
`Press ${commandDescriptions[Command.UNFOCUS_BACKGROUND_SHELL]} to focus out.`,
|
||||
);
|
||||
// Fall through to allow Tab to be sent to the shell
|
||||
}
|
||||
|
||||
if (isListOpenProp) {
|
||||
// Navigation (Up/Down/Enter) is handled by RadioButtonSelect
|
||||
// We only handle special keys not consumed by RadioButtonSelect or overriding them if needed
|
||||
@@ -165,7 +188,7 @@ export const BackgroundShellDisplay = ({
|
||||
}
|
||||
|
||||
if (keyMatchers[Command.TOGGLE_BACKGROUND_SHELL](key)) {
|
||||
return false;
|
||||
return true;
|
||||
}
|
||||
|
||||
if (keyMatchers[Command.KILL_BACKGROUND_SHELL](key)) {
|
||||
@@ -193,27 +216,7 @@ export const BackgroundShellDisplay = ({
|
||||
{ isActive: isFocused && !!activeShell },
|
||||
);
|
||||
|
||||
const helpTextParts = [
|
||||
{ label: 'Close', command: Command.TOGGLE_BACKGROUND_SHELL },
|
||||
{ label: 'Kill', command: Command.KILL_BACKGROUND_SHELL },
|
||||
{ label: 'List', command: Command.TOGGLE_BACKGROUND_SHELL_LIST },
|
||||
];
|
||||
|
||||
const helpTextStr = helpTextParts
|
||||
.map((p) => `${p.label} (${formatCommand(p.command)})`)
|
||||
.join(' | ');
|
||||
|
||||
const renderHelpText = () => (
|
||||
<Text>
|
||||
{helpTextParts.map((p, i) => (
|
||||
<Text key={p.label}>
|
||||
{i > 0 ? ' | ' : ''}
|
||||
{p.label} (
|
||||
<Text color={theme.text.accent}>{formatCommand(p.command)}</Text>)
|
||||
</Text>
|
||||
))}
|
||||
</Text>
|
||||
);
|
||||
const helpText = `${commandDescriptions[Command.TOGGLE_BACKGROUND_SHELL]} Hide | ${commandDescriptions[Command.KILL_BACKGROUND_SHELL]} Kill | ${commandDescriptions[Command.TOGGLE_BACKGROUND_SHELL_LIST]} List`;
|
||||
|
||||
const renderTabs = () => {
|
||||
const shellList = Array.from(shells.values()).filter(
|
||||
@@ -227,7 +230,7 @@ export const BackgroundShellDisplay = ({
|
||||
const availableWidth =
|
||||
width -
|
||||
TAB_DISPLAY_HORIZONTAL_PADDING -
|
||||
getCachedStringWidth(helpTextStr) -
|
||||
getCachedStringWidth(helpText) -
|
||||
pidInfoWidth;
|
||||
|
||||
let currentWidth = 0;
|
||||
@@ -269,7 +272,7 @@ export const BackgroundShellDisplay = ({
|
||||
}
|
||||
|
||||
if (shellList.length > tabs.length && !isListOpenProp) {
|
||||
const overflowLabel = ` ... (${formatCommand(Command.TOGGLE_BACKGROUND_SHELL_LIST)}) `;
|
||||
const overflowLabel = ` ... (${commandDescriptions[Command.TOGGLE_BACKGROUND_SHELL_LIST]}) `;
|
||||
const overflowWidth = getCachedStringWidth(overflowLabel);
|
||||
|
||||
// If we only have one tab, ensure we don't show the overflow if it's too cramped
|
||||
@@ -321,7 +324,7 @@ export const BackgroundShellDisplay = ({
|
||||
<Box flexDirection="column" height="100%" width="100%">
|
||||
<Box flexShrink={0} marginBottom={1} paddingTop={1}>
|
||||
<Text bold>
|
||||
{`Select Process (${formatCommand(Command.BACKGROUND_SHELL_SELECT)} to select, ${formatCommand(Command.KILL_BACKGROUND_SHELL)} to kill, ${formatCommand(Command.BACKGROUND_SHELL_ESCAPE)} to cancel):`}
|
||||
{`Select Process (${commandDescriptions[Command.BACKGROUND_SHELL_SELECT]} to select, ${commandDescriptions[Command.BACKGROUND_SHELL_ESCAPE]} to cancel):`}
|
||||
</Text>
|
||||
</Box>
|
||||
<Box flexGrow={1} width="100%">
|
||||
@@ -447,7 +450,7 @@ export const BackgroundShellDisplay = ({
|
||||
(PID: {activeShell?.pid}) {isFocused ? '(Focused)' : ''}
|
||||
</Text>
|
||||
</Box>
|
||||
{renderHelpText()}
|
||||
<Text color={theme.text.accent}>{helpText}</Text>
|
||||
</Box>
|
||||
<Box flexGrow={1} overflow="hidden" paddingX={CONTENT_PADDING_X}>
|
||||
{isListOpenProp ? renderProcessList() : renderOutput()}
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
|
||||
import { describe, it, expect, vi } from 'vitest';
|
||||
import { render } from '../../test-utils/render.js';
|
||||
import { Box, Text } from 'ink';
|
||||
import { Text } from 'ink';
|
||||
import { Composer } from './Composer.js';
|
||||
import { UIStateContext, type UIState } from '../contexts/UIStateContext.js';
|
||||
import {
|
||||
@@ -24,7 +24,7 @@ vi.mock('../contexts/VimModeContext.js', () => ({
|
||||
})),
|
||||
}));
|
||||
import { ApprovalMode } from '@google/gemini-cli-core';
|
||||
import { StreamingState, ToolCallStatus } from '../types.js';
|
||||
import { StreamingState } from '../types.js';
|
||||
|
||||
// Mock child components
|
||||
vi.mock('./LoadingIndicator.js', () => ({
|
||||
@@ -49,14 +49,6 @@ vi.mock('./ShellModeIndicator.js', () => ({
|
||||
ShellModeIndicator: () => <Text>ShellModeIndicator</Text>,
|
||||
}));
|
||||
|
||||
vi.mock('./ShortcutsHint.js', () => ({
|
||||
ShortcutsHint: () => <Text>ShortcutsHint</Text>,
|
||||
}));
|
||||
|
||||
vi.mock('./ShortcutsHelp.js', () => ({
|
||||
ShortcutsHelp: () => <Text>ShortcutsHelp</Text>,
|
||||
}));
|
||||
|
||||
vi.mock('./DetailedMessagesDisplay.js', () => ({
|
||||
DetailedMessagesDisplay: () => <Text>DetailedMessagesDisplay</Text>,
|
||||
}));
|
||||
@@ -103,8 +95,7 @@ vi.mock('../contexts/OverflowContext.js', () => ({
|
||||
// Create mock context providers
|
||||
const createMockUIState = (overrides: Partial<UIState> = {}): UIState =>
|
||||
({
|
||||
streamingState: StreamingState.Idle,
|
||||
isConfigInitialized: true,
|
||||
streamingState: null,
|
||||
contextFileNames: [],
|
||||
showApprovalModeIndicator: ApprovalMode.DEFAULT,
|
||||
messageQueue: [],
|
||||
@@ -125,7 +116,6 @@ const createMockUIState = (overrides: Partial<UIState> = {}): UIState =>
|
||||
ctrlCPressedOnce: false,
|
||||
ctrlDPressedOnce: false,
|
||||
showEscapePrompt: false,
|
||||
shortcutsHelpVisible: false,
|
||||
ideContextState: null,
|
||||
geminiMdFileCount: 0,
|
||||
renderMarkdown: true,
|
||||
@@ -164,7 +154,6 @@ const createMockConfig = (overrides = {}) => ({
|
||||
getDebugMode: vi.fn(() => false),
|
||||
getAccessibility: vi.fn(() => ({})),
|
||||
getMcpServers: vi.fn(() => ({})),
|
||||
isPlanEnabled: vi.fn(() => false),
|
||||
getToolRegistry: () => ({
|
||||
getTool: vi.fn(),
|
||||
}),
|
||||
@@ -279,19 +268,6 @@ describe('Composer', () => {
|
||||
expect(output).toContain('LoadingIndicator');
|
||||
});
|
||||
|
||||
it('keeps shortcuts hint visible while loading', () => {
|
||||
const uiState = createMockUIState({
|
||||
streamingState: StreamingState.Responding,
|
||||
elapsedTime: 1,
|
||||
});
|
||||
|
||||
const { lastFrame } = renderComposer(uiState);
|
||||
|
||||
const output = lastFrame();
|
||||
expect(output).toContain('LoadingIndicator');
|
||||
expect(output).toContain('ShortcutsHint');
|
||||
});
|
||||
|
||||
it('renders LoadingIndicator without thought when accessibility disables loading phrases', () => {
|
||||
const uiState = createMockUIState({
|
||||
streamingState: StreamingState.Responding,
|
||||
@@ -308,7 +284,7 @@ describe('Composer', () => {
|
||||
expect(output).not.toContain('Should not show');
|
||||
});
|
||||
|
||||
it('does not render LoadingIndicator when waiting for confirmation', () => {
|
||||
it('suppresses thought when waiting for confirmation', () => {
|
||||
const uiState = createMockUIState({
|
||||
streamingState: StreamingState.WaitingForConfirmation,
|
||||
thought: {
|
||||
@@ -320,34 +296,8 @@ describe('Composer', () => {
|
||||
const { lastFrame } = renderComposer(uiState);
|
||||
|
||||
const output = lastFrame();
|
||||
expect(output).not.toContain('LoadingIndicator');
|
||||
});
|
||||
|
||||
it('does not render LoadingIndicator when a tool confirmation is pending', () => {
|
||||
const uiState = createMockUIState({
|
||||
streamingState: StreamingState.Responding,
|
||||
pendingHistoryItems: [
|
||||
{
|
||||
type: 'tool_group',
|
||||
tools: [
|
||||
{
|
||||
callId: 'call-1',
|
||||
name: 'edit',
|
||||
description: 'edit file',
|
||||
status: ToolCallStatus.Confirming,
|
||||
resultDisplay: undefined,
|
||||
confirmationDetails: undefined,
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
const { lastFrame } = renderComposer(uiState);
|
||||
|
||||
const output = lastFrame();
|
||||
expect(output).not.toContain('LoadingIndicator');
|
||||
expect(output).not.toContain('esc to cancel');
|
||||
expect(output).toContain('LoadingIndicator');
|
||||
expect(output).not.toContain('Should not show during confirmation');
|
||||
});
|
||||
|
||||
it('renders LoadingIndicator when embedded shell is focused but background shell is visible', () => {
|
||||
@@ -486,24 +436,16 @@ describe('Composer', () => {
|
||||
expect(lastFrame()).not.toContain('InputPrompt');
|
||||
});
|
||||
|
||||
it.each([
|
||||
[ApprovalMode.DEFAULT],
|
||||
[ApprovalMode.AUTO_EDIT],
|
||||
[ApprovalMode.PLAN],
|
||||
[ApprovalMode.YOLO],
|
||||
])(
|
||||
'shows ApprovalModeIndicator when approval mode is %s and shell mode is inactive',
|
||||
(mode) => {
|
||||
const uiState = createMockUIState({
|
||||
showApprovalModeIndicator: mode,
|
||||
shellModeActive: false,
|
||||
});
|
||||
it('shows ApprovalModeIndicator when approval mode is not default and shell mode is inactive', () => {
|
||||
const uiState = createMockUIState({
|
||||
showApprovalModeIndicator: ApprovalMode.YOLO,
|
||||
shellModeActive: false,
|
||||
});
|
||||
|
||||
const { lastFrame } = renderComposer(uiState);
|
||||
const { lastFrame } = renderComposer(uiState);
|
||||
|
||||
expect(lastFrame()).toMatch(/ApprovalModeIndic[\s\S]*ator/);
|
||||
},
|
||||
);
|
||||
expect(lastFrame()).toContain('ApprovalModeIndicator');
|
||||
});
|
||||
|
||||
it('shows ShellModeIndicator when shell mode is active', () => {
|
||||
const uiState = createMockUIState({
|
||||
@@ -512,7 +454,7 @@ describe('Composer', () => {
|
||||
|
||||
const { lastFrame } = renderComposer(uiState);
|
||||
|
||||
expect(lastFrame()).toMatch(/ShellModeIndic[\s\S]*tor/);
|
||||
expect(lastFrame()).toContain('ShellModeIndicator');
|
||||
});
|
||||
|
||||
it('shows RawMarkdownIndicator when renderMarkdown is false', () => {
|
||||
@@ -598,29 +540,4 @@ describe('Composer', () => {
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Shortcuts Hint', () => {
|
||||
it('hides shortcuts hint when a action is required (e.g. dialog is open)', () => {
|
||||
const uiState = createMockUIState({
|
||||
customDialog: (
|
||||
<Box>
|
||||
<Text>Test Dialog</Text>
|
||||
<Text>Test Content</Text>
|
||||
</Box>
|
||||
),
|
||||
});
|
||||
|
||||
const { lastFrame } = renderComposer(uiState);
|
||||
|
||||
expect(lastFrame()).not.toContain('ShortcutsHint');
|
||||
});
|
||||
|
||||
it('keeps shortcuts hint visible when no action is required', () => {
|
||||
const uiState = createMockUIState();
|
||||
|
||||
const { lastFrame } = renderComposer(uiState);
|
||||
|
||||
expect(lastFrame()).toContain('ShortcutsHint');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -5,20 +5,17 @@
|
||||
*/
|
||||
|
||||
import { useState } from 'react';
|
||||
import { Box, Text, useIsScreenReaderEnabled } from 'ink';
|
||||
import { Box, useIsScreenReaderEnabled } from 'ink';
|
||||
import { LoadingIndicator } from './LoadingIndicator.js';
|
||||
import { StatusDisplay } from './StatusDisplay.js';
|
||||
import { ApprovalModeIndicator } from './ApprovalModeIndicator.js';
|
||||
import { ShellModeIndicator } from './ShellModeIndicator.js';
|
||||
import { DetailedMessagesDisplay } from './DetailedMessagesDisplay.js';
|
||||
import { RawMarkdownIndicator } from './RawMarkdownIndicator.js';
|
||||
import { ShortcutsHint } from './ShortcutsHint.js';
|
||||
import { ShortcutsHelp } from './ShortcutsHelp.js';
|
||||
import { InputPrompt } from './InputPrompt.js';
|
||||
import { Footer } from './Footer.js';
|
||||
import { ShowMoreLines } from './ShowMoreLines.js';
|
||||
import { QueuedMessageDisplay } from './QueuedMessageDisplay.js';
|
||||
import { HorizontalLine } from './shared/HorizontalLine.js';
|
||||
import { OverflowProvider } from '../contexts/OverflowContext.js';
|
||||
import { isNarrowWidth } from '../utils/isNarrowWidth.js';
|
||||
import { useUIState } from '../contexts/UIStateContext.js';
|
||||
@@ -27,10 +24,10 @@ import { useVimMode } from '../contexts/VimModeContext.js';
|
||||
import { useConfig } from '../contexts/ConfigContext.js';
|
||||
import { useSettings } from '../contexts/SettingsContext.js';
|
||||
import { useAlternateBuffer } from '../hooks/useAlternateBuffer.js';
|
||||
import { StreamingState, ToolCallStatus } from '../types.js';
|
||||
import { ApprovalMode } from '@google/gemini-cli-core';
|
||||
import { StreamingState } from '../types.js';
|
||||
import { ConfigInitDisplay } from '../components/ConfigInitDisplay.js';
|
||||
import { TodoTray } from './messages/Todo.js';
|
||||
import { theme } from '../semantic-colors.js';
|
||||
|
||||
export const Composer = ({ isFocused = true }: { isFocused?: boolean }) => {
|
||||
const config = useConfig();
|
||||
@@ -49,29 +46,6 @@ export const Composer = ({ isFocused = true }: { isFocused?: boolean }) => {
|
||||
const suggestionsPosition = isAlternateBuffer ? 'above' : 'below';
|
||||
const hideContextSummary =
|
||||
suggestionsVisible && suggestionsPosition === 'above';
|
||||
const hasPendingToolConfirmation = (uiState.pendingHistoryItems ?? []).some(
|
||||
(item) =>
|
||||
item.type === 'tool_group' &&
|
||||
item.tools.some((tool) => tool.status === ToolCallStatus.Confirming),
|
||||
);
|
||||
const hasPendingActionRequired =
|
||||
hasPendingToolConfirmation ||
|
||||
Boolean(uiState.commandConfirmationRequest) ||
|
||||
Boolean(uiState.authConsentRequest) ||
|
||||
(uiState.confirmUpdateExtensionRequests?.length ?? 0) > 0 ||
|
||||
Boolean(uiState.loopDetectionConfirmationRequest) ||
|
||||
Boolean(uiState.proQuotaRequest) ||
|
||||
Boolean(uiState.validationRequest) ||
|
||||
Boolean(uiState.customDialog);
|
||||
const showLoadingIndicator =
|
||||
(!uiState.embeddedShellFocused || uiState.isBackgroundShellVisible) &&
|
||||
uiState.streamingState === StreamingState.Responding &&
|
||||
!hasPendingActionRequired;
|
||||
const showApprovalIndicator = !uiState.shellModeActive;
|
||||
const showRawMarkdownIndicator = !uiState.renderMarkdown;
|
||||
const showEscToCancelHint =
|
||||
showLoadingIndicator &&
|
||||
uiState.streamingState !== StreamingState.WaitingForConfirmation;
|
||||
|
||||
return (
|
||||
<Box
|
||||
@@ -80,6 +54,23 @@ export const Composer = ({ isFocused = true }: { isFocused?: boolean }) => {
|
||||
flexGrow={0}
|
||||
flexShrink={0}
|
||||
>
|
||||
{(!uiState.embeddedShellFocused || uiState.isBackgroundShellVisible) && (
|
||||
<LoadingIndicator
|
||||
thought={
|
||||
uiState.streamingState === StreamingState.WaitingForConfirmation ||
|
||||
config.getAccessibility()?.enableLoadingPhrases === false
|
||||
? undefined
|
||||
: uiState.thought
|
||||
}
|
||||
currentLoadingPhrase={
|
||||
config.getAccessibility()?.enableLoadingPhrases === false
|
||||
? undefined
|
||||
: uiState.currentLoadingPhrase
|
||||
}
|
||||
elapsedTime={uiState.elapsedTime}
|
||||
/>
|
||||
)}
|
||||
|
||||
{(!uiState.slashCommands ||
|
||||
!uiState.isConfigInitialized ||
|
||||
uiState.isResuming) && (
|
||||
@@ -92,122 +83,25 @@ export const Composer = ({ isFocused = true }: { isFocused?: boolean }) => {
|
||||
|
||||
<TodoTray />
|
||||
|
||||
<Box marginTop={1} width="100%" flexDirection="column">
|
||||
{showEscToCancelHint && (
|
||||
<Box marginLeft={3}>
|
||||
<Text color={theme.text.secondary}>esc to cancel</Text>
|
||||
</Box>
|
||||
)}
|
||||
<Box
|
||||
width="100%"
|
||||
flexDirection={isNarrow ? 'column' : 'row'}
|
||||
alignItems={isNarrow ? 'flex-start' : 'center'}
|
||||
justifyContent={isNarrow ? 'flex-start' : 'space-between'}
|
||||
>
|
||||
<Box
|
||||
marginLeft={1}
|
||||
marginRight={isNarrow ? 0 : 1}
|
||||
flexDirection="row"
|
||||
alignItems="center"
|
||||
flexGrow={1}
|
||||
>
|
||||
{showLoadingIndicator && (
|
||||
<LoadingIndicator
|
||||
inline
|
||||
thought={
|
||||
uiState.streamingState ===
|
||||
StreamingState.WaitingForConfirmation ||
|
||||
config.getAccessibility()?.enableLoadingPhrases === false
|
||||
? undefined
|
||||
: uiState.thought
|
||||
}
|
||||
currentLoadingPhrase={
|
||||
config.getAccessibility()?.enableLoadingPhrases === false
|
||||
? undefined
|
||||
: uiState.currentLoadingPhrase
|
||||
}
|
||||
elapsedTime={uiState.elapsedTime}
|
||||
showCancelAndTimer={false}
|
||||
/>
|
||||
)}
|
||||
</Box>
|
||||
<Box
|
||||
marginTop={isNarrow ? 1 : 0}
|
||||
flexDirection="column"
|
||||
alignItems={isNarrow ? 'flex-start' : 'flex-end'}
|
||||
>
|
||||
{!hasPendingActionRequired && <ShortcutsHint />}
|
||||
</Box>
|
||||
<Box
|
||||
marginTop={1}
|
||||
justifyContent={
|
||||
settings.merged.ui.hideContextSummary ? 'flex-start' : 'space-between'
|
||||
}
|
||||
width="100%"
|
||||
flexDirection={isNarrow ? 'column' : 'row'}
|
||||
alignItems={isNarrow ? 'flex-start' : 'center'}
|
||||
>
|
||||
<Box marginRight={1}>
|
||||
<StatusDisplay hideContextSummary={hideContextSummary} />
|
||||
</Box>
|
||||
{uiState.shortcutsHelpVisible && <ShortcutsHelp />}
|
||||
<HorizontalLine />
|
||||
<Box
|
||||
justifyContent={
|
||||
settings.merged.ui.hideContextSummary
|
||||
? 'flex-start'
|
||||
: 'space-between'
|
||||
}
|
||||
width="100%"
|
||||
flexDirection={isNarrow ? 'column' : 'row'}
|
||||
alignItems={isNarrow ? 'flex-start' : 'center'}
|
||||
>
|
||||
<Box
|
||||
marginLeft={1}
|
||||
marginRight={isNarrow ? 0 : 1}
|
||||
flexDirection="row"
|
||||
alignItems="center"
|
||||
flexGrow={1}
|
||||
>
|
||||
{!showLoadingIndicator && (
|
||||
<Box
|
||||
flexDirection={isNarrow ? 'column' : 'row'}
|
||||
alignItems={isNarrow ? 'flex-start' : 'center'}
|
||||
>
|
||||
{showApprovalIndicator && (
|
||||
<ApprovalModeIndicator
|
||||
approvalMode={showApprovalModeIndicator}
|
||||
isPlanEnabled={config.isPlanEnabled()}
|
||||
/>
|
||||
)}
|
||||
{uiState.shellModeActive && (
|
||||
<Box
|
||||
marginLeft={showApprovalIndicator && !isNarrow ? 1 : 0}
|
||||
marginTop={showApprovalIndicator && isNarrow ? 1 : 0}
|
||||
>
|
||||
<ShellModeIndicator />
|
||||
</Box>
|
||||
)}
|
||||
{showRawMarkdownIndicator && (
|
||||
<Box
|
||||
marginLeft={
|
||||
(showApprovalIndicator || uiState.shellModeActive) &&
|
||||
!isNarrow
|
||||
? 1
|
||||
: 0
|
||||
}
|
||||
marginTop={
|
||||
(showApprovalIndicator || uiState.shellModeActive) &&
|
||||
isNarrow
|
||||
? 1
|
||||
: 0
|
||||
}
|
||||
>
|
||||
<RawMarkdownIndicator />
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
<Box paddingTop={isNarrow ? 1 : 0}>
|
||||
{showApprovalModeIndicator !== ApprovalMode.DEFAULT &&
|
||||
!uiState.shellModeActive && (
|
||||
<ApprovalModeIndicator approvalMode={showApprovalModeIndicator} />
|
||||
)}
|
||||
</Box>
|
||||
|
||||
<Box
|
||||
marginTop={isNarrow ? 1 : 0}
|
||||
flexDirection="column"
|
||||
alignItems={isNarrow ? 'flex-start' : 'flex-end'}
|
||||
>
|
||||
{!showLoadingIndicator && (
|
||||
<StatusDisplay hideContextSummary={hideContextSummary} />
|
||||
)}
|
||||
</Box>
|
||||
{uiState.shellModeActive && <ShellModeIndicator />}
|
||||
{!uiState.renderMarkdown && <RawMarkdownIndicator />}
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
|
||||
@@ -1,343 +0,0 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import type React from 'react';
|
||||
import { useEffect, useState, useRef, useCallback } from 'react';
|
||||
import { Box, Text } from 'ink';
|
||||
import { theme } from '../semantic-colors.js';
|
||||
import type { ExtensionManager } from '../../config/extension-manager.js';
|
||||
import {
|
||||
configureExtension,
|
||||
configureSpecificSetting,
|
||||
configureAllExtensions,
|
||||
type ConfigLogger,
|
||||
type RequestSettingCallback,
|
||||
type RequestConfirmationCallback,
|
||||
} from '../../commands/extensions/utils.js';
|
||||
import {
|
||||
ExtensionSettingScope,
|
||||
type ExtensionSetting,
|
||||
} from '../../config/extensions/extensionSettings.js';
|
||||
import { TextInput } from './shared/TextInput.js';
|
||||
import { useTextBuffer } from './shared/text-buffer.js';
|
||||
import { DialogFooter } from './shared/DialogFooter.js';
|
||||
import { type Key, useKeypress } from '../hooks/useKeypress.js';
|
||||
|
||||
export interface ConfigExtensionDialogProps {
|
||||
extensionManager: ExtensionManager;
|
||||
onClose: () => void;
|
||||
extensionName?: string;
|
||||
settingKey?: string;
|
||||
scope?: ExtensionSettingScope;
|
||||
configureAll?: boolean;
|
||||
loggerAdapter: ConfigLogger;
|
||||
}
|
||||
|
||||
type DialogState =
|
||||
| { type: 'IDLE' }
|
||||
| { type: 'BUSY'; message?: string }
|
||||
| {
|
||||
type: 'ASK_SETTING';
|
||||
setting: ExtensionSetting;
|
||||
resolve: (val: string) => void;
|
||||
initialValue?: string;
|
||||
}
|
||||
| {
|
||||
type: 'ASK_CONFIRMATION';
|
||||
message: string;
|
||||
resolve: (val: boolean) => void;
|
||||
}
|
||||
| { type: 'DONE' }
|
||||
| { type: 'ERROR'; error: Error };
|
||||
|
||||
export const ConfigExtensionDialog: React.FC<ConfigExtensionDialogProps> = ({
|
||||
extensionManager,
|
||||
onClose,
|
||||
extensionName,
|
||||
settingKey,
|
||||
scope = ExtensionSettingScope.USER,
|
||||
configureAll,
|
||||
loggerAdapter,
|
||||
}) => {
|
||||
const [state, setState] = useState<DialogState>({ type: 'IDLE' });
|
||||
const [logMessages, setLogMessages] = useState<string[]>([]);
|
||||
|
||||
// Buffers for input
|
||||
const settingBuffer = useTextBuffer({
|
||||
initialText: '',
|
||||
viewport: { width: 80, height: 1 },
|
||||
singleLine: true,
|
||||
isValidPath: () => true,
|
||||
});
|
||||
|
||||
const mounted = useRef(true);
|
||||
|
||||
useEffect(() => {
|
||||
mounted.current = true;
|
||||
return () => {
|
||||
mounted.current = false;
|
||||
};
|
||||
}, []);
|
||||
|
||||
const addLog = useCallback(
|
||||
(msg: string) => {
|
||||
setLogMessages((prev) => [...prev, msg].slice(-5)); // Keep last 5
|
||||
loggerAdapter.log(msg);
|
||||
},
|
||||
[loggerAdapter],
|
||||
);
|
||||
|
||||
const requestSetting: RequestSettingCallback = useCallback(
|
||||
async (setting) =>
|
||||
new Promise<string>((resolve) => {
|
||||
if (!mounted.current) return;
|
||||
settingBuffer.setText(''); // Clear buffer
|
||||
setState({
|
||||
type: 'ASK_SETTING',
|
||||
setting,
|
||||
resolve: (val) => {
|
||||
resolve(val);
|
||||
setState({ type: 'BUSY', message: 'Updating...' });
|
||||
},
|
||||
});
|
||||
}),
|
||||
[settingBuffer],
|
||||
);
|
||||
|
||||
const requestConfirmation: RequestConfirmationCallback = useCallback(
|
||||
async (message) =>
|
||||
new Promise<boolean>((resolve) => {
|
||||
if (!mounted.current) return;
|
||||
setState({
|
||||
type: 'ASK_CONFIRMATION',
|
||||
message,
|
||||
resolve: (val) => {
|
||||
resolve(val);
|
||||
setState({ type: 'BUSY', message: 'Processing...' });
|
||||
},
|
||||
});
|
||||
}),
|
||||
[],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
async function run() {
|
||||
try {
|
||||
setState({ type: 'BUSY', message: 'Initializing...' });
|
||||
|
||||
// Wrap logger to capture logs locally too
|
||||
const localLogger: ConfigLogger = {
|
||||
log: (msg) => {
|
||||
addLog(msg);
|
||||
},
|
||||
error: (msg) => {
|
||||
addLog('Error: ' + msg);
|
||||
loggerAdapter.error(msg);
|
||||
},
|
||||
};
|
||||
|
||||
if (configureAll) {
|
||||
await configureAllExtensions(
|
||||
extensionManager,
|
||||
scope,
|
||||
localLogger,
|
||||
requestSetting,
|
||||
requestConfirmation,
|
||||
);
|
||||
} else if (extensionName && settingKey) {
|
||||
await configureSpecificSetting(
|
||||
extensionManager,
|
||||
extensionName,
|
||||
settingKey,
|
||||
scope,
|
||||
localLogger,
|
||||
requestSetting,
|
||||
);
|
||||
} else if (extensionName) {
|
||||
await configureExtension(
|
||||
extensionManager,
|
||||
extensionName,
|
||||
scope,
|
||||
localLogger,
|
||||
requestSetting,
|
||||
requestConfirmation,
|
||||
);
|
||||
}
|
||||
|
||||
if (mounted.current) {
|
||||
setState({ type: 'DONE' });
|
||||
// Delay close slightly to show done
|
||||
setTimeout(onClose, 1000);
|
||||
}
|
||||
} catch (err: unknown) {
|
||||
if (mounted.current) {
|
||||
const error = err instanceof Error ? err : new Error(String(err));
|
||||
setState({ type: 'ERROR', error });
|
||||
loggerAdapter.error(error.message);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Only run once
|
||||
if (state.type === 'IDLE') {
|
||||
void run();
|
||||
}
|
||||
}, [
|
||||
extensionManager,
|
||||
extensionName,
|
||||
settingKey,
|
||||
scope,
|
||||
configureAll,
|
||||
loggerAdapter,
|
||||
requestSetting,
|
||||
requestConfirmation,
|
||||
addLog,
|
||||
onClose,
|
||||
state.type,
|
||||
]);
|
||||
|
||||
// Handle Input Submission
|
||||
const handleSettingSubmit = (val: string) => {
|
||||
if (state.type === 'ASK_SETTING') {
|
||||
state.resolve(val);
|
||||
}
|
||||
};
|
||||
|
||||
// Handle Keys for Confirmation
|
||||
useKeypress(
|
||||
(key: Key) => {
|
||||
if (state.type === 'ASK_CONFIRMATION') {
|
||||
if (key.name === 'y' || key.name === 'return') {
|
||||
state.resolve(true);
|
||||
return true;
|
||||
}
|
||||
if (key.name === 'n' || key.name === 'escape') {
|
||||
state.resolve(false);
|
||||
return true;
|
||||
}
|
||||
}
|
||||
if (state.type === 'DONE' || state.type === 'ERROR') {
|
||||
if (key.name === 'return' || key.name === 'escape') {
|
||||
onClose();
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
},
|
||||
{
|
||||
isActive:
|
||||
state.type === 'ASK_CONFIRMATION' ||
|
||||
state.type === 'DONE' ||
|
||||
state.type === 'ERROR',
|
||||
},
|
||||
);
|
||||
|
||||
if (state.type === 'BUSY' || state.type === 'IDLE') {
|
||||
return (
|
||||
<Box
|
||||
flexDirection="column"
|
||||
borderStyle="round"
|
||||
borderColor={theme.border.default}
|
||||
paddingX={1}
|
||||
>
|
||||
<Text color={theme.text.secondary}>
|
||||
{state.type === 'BUSY' ? state.message : 'Starting...'}
|
||||
</Text>
|
||||
{logMessages.map((msg, i) => (
|
||||
<Text key={i}>{msg}</Text>
|
||||
))}
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
if (state.type === 'ASK_SETTING') {
|
||||
return (
|
||||
<Box
|
||||
flexDirection="column"
|
||||
borderStyle="round"
|
||||
borderColor={theme.border.default}
|
||||
paddingX={1}
|
||||
>
|
||||
<Text bold color={theme.text.primary}>
|
||||
Configure {state.setting.name}
|
||||
</Text>
|
||||
<Text color={theme.text.secondary}>
|
||||
{state.setting.description || state.setting.envVar}
|
||||
</Text>
|
||||
<Box flexDirection="row" marginTop={1}>
|
||||
<Text color={theme.text.accent}>{'> '}</Text>
|
||||
<TextInput
|
||||
buffer={settingBuffer}
|
||||
onSubmit={handleSettingSubmit}
|
||||
focus={true}
|
||||
placeholder={`Enter value for ${state.setting.name}`}
|
||||
/>
|
||||
</Box>
|
||||
<DialogFooter primaryAction="Enter to submit" />
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
if (state.type === 'ASK_CONFIRMATION') {
|
||||
return (
|
||||
<Box
|
||||
flexDirection="column"
|
||||
borderStyle="round"
|
||||
borderColor={theme.border.default}
|
||||
paddingX={1}
|
||||
>
|
||||
<Text color={theme.status.warning} bold>
|
||||
Confirmation Required
|
||||
</Text>
|
||||
<Text>{state.message}</Text>
|
||||
<Box marginTop={1}>
|
||||
<Text color={theme.text.secondary}>
|
||||
Press{' '}
|
||||
<Text color={theme.text.accent} bold>
|
||||
Y
|
||||
</Text>{' '}
|
||||
to confirm or{' '}
|
||||
<Text color={theme.text.accent} bold>
|
||||
N
|
||||
</Text>{' '}
|
||||
to cancel
|
||||
</Text>
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
if (state.type === 'ERROR') {
|
||||
return (
|
||||
<Box
|
||||
flexDirection="column"
|
||||
borderStyle="round"
|
||||
borderColor={theme.status.error}
|
||||
paddingX={1}
|
||||
>
|
||||
<Text color={theme.status.error} bold>
|
||||
Error
|
||||
</Text>
|
||||
<Text>{state.error.message}</Text>
|
||||
<DialogFooter primaryAction="Enter to close" />
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Box
|
||||
flexDirection="column"
|
||||
borderStyle="round"
|
||||
borderColor={theme.status.success}
|
||||
paddingX={1}
|
||||
>
|
||||
<Text color={theme.status.success} bold>
|
||||
Configuration Complete
|
||||
</Text>
|
||||
<DialogFooter primaryAction="Enter to close" />
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
@@ -147,7 +147,7 @@ export const Footer: React.FC = () => {
|
||||
<Box alignItems="center" justifyContent="flex-end">
|
||||
<Box alignItems="center">
|
||||
<Text color={theme.text.accent}>
|
||||
{getDisplayString(model)}
|
||||
{getDisplayString(model, config.getPreviewFeatures())}
|
||||
<Text color={theme.text.secondary}> /model</Text>
|
||||
{!hideContextPercentage && (
|
||||
<>
|
||||
|
||||
@@ -43,7 +43,6 @@ import { StreamingState } from '../types.js';
|
||||
import { terminalCapabilityManager } from '../utils/terminalCapabilityManager.js';
|
||||
import type { UIState } from '../contexts/UIStateContext.js';
|
||||
import { isLowColorDepth } from '../utils/terminalUtils.js';
|
||||
import { cpLen } from '../utils/textUtils.js';
|
||||
import { keyMatchers, Command } from '../keyMatchers.js';
|
||||
import type { Key } from '../hooks/useKeypress.js';
|
||||
|
||||
@@ -157,25 +156,14 @@ describe('InputPrompt', () => {
|
||||
text: '',
|
||||
cursor: [0, 0],
|
||||
lines: [''],
|
||||
setText: vi.fn(
|
||||
(newText: string, cursorPosition?: 'start' | 'end' | number) => {
|
||||
mockBuffer.text = newText;
|
||||
mockBuffer.lines = [newText];
|
||||
let col = 0;
|
||||
if (typeof cursorPosition === 'number') {
|
||||
col = cursorPosition;
|
||||
} else if (cursorPosition === 'start') {
|
||||
col = 0;
|
||||
} else {
|
||||
col = newText.length;
|
||||
}
|
||||
mockBuffer.cursor = [0, col];
|
||||
mockBuffer.viewportVisualLines = [newText];
|
||||
mockBuffer.allVisualLines = [newText];
|
||||
mockBuffer.visualToLogicalMap = [[0, 0]];
|
||||
mockBuffer.visualCursor = [0, col];
|
||||
},
|
||||
),
|
||||
setText: vi.fn((newText: string) => {
|
||||
mockBuffer.text = newText;
|
||||
mockBuffer.lines = [newText];
|
||||
mockBuffer.cursor = [0, newText.length];
|
||||
mockBuffer.viewportVisualLines = [newText];
|
||||
mockBuffer.allVisualLines = [newText];
|
||||
mockBuffer.visualToLogicalMap = [[0, 0]];
|
||||
}),
|
||||
replaceRangeByOffset: vi.fn(),
|
||||
viewportVisualLines: [''],
|
||||
allVisualLines: [''],
|
||||
@@ -191,15 +179,7 @@ describe('InputPrompt', () => {
|
||||
}
|
||||
return false;
|
||||
}),
|
||||
move: vi.fn((dir: string) => {
|
||||
if (dir === 'home') {
|
||||
mockBuffer.visualCursor = [mockBuffer.visualCursor[0], 0];
|
||||
} else if (dir === 'end') {
|
||||
const line =
|
||||
mockBuffer.allVisualLines[mockBuffer.visualCursor[0]] || '';
|
||||
mockBuffer.visualCursor = [mockBuffer.visualCursor[0], cpLen(line)];
|
||||
}
|
||||
}),
|
||||
move: vi.fn(),
|
||||
moveToOffset: vi.fn((offset: number) => {
|
||||
mockBuffer.cursor = [0, offset];
|
||||
}),
|
||||
@@ -245,6 +225,7 @@ describe('InputPrompt', () => {
|
||||
navigateDown: vi.fn(),
|
||||
resetCompletionState: vi.fn(),
|
||||
setActiveSuggestionIndex: vi.fn(),
|
||||
setShowSuggestions: vi.fn(),
|
||||
handleAutocomplete: vi.fn(),
|
||||
promptCompletion: {
|
||||
text: '',
|
||||
@@ -400,12 +381,12 @@ describe('InputPrompt', () => {
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
stdin.write('\u0010'); // Ctrl+P
|
||||
stdin.write('\u001B[A'); // Up arrow
|
||||
});
|
||||
await waitFor(() => expect(mockInputHistory.navigateUp).toHaveBeenCalled());
|
||||
|
||||
await act(async () => {
|
||||
stdin.write('\u000E'); // Ctrl+N
|
||||
stdin.write('\u001B[B'); // Down arrow
|
||||
});
|
||||
await waitFor(() =>
|
||||
expect(mockInputHistory.navigateDown).toHaveBeenCalled(),
|
||||
@@ -424,100 +405,6 @@ describe('InputPrompt', () => {
|
||||
unmount();
|
||||
});
|
||||
|
||||
describe('arrow key navigation', () => {
|
||||
it('should move to start of line on Up arrow if on first line but not at start', async () => {
|
||||
mockBuffer.allVisualLines = ['line 1', 'line 2'];
|
||||
mockBuffer.visualCursor = [0, 5]; // First line, not at start
|
||||
mockBuffer.visualScrollRow = 0;
|
||||
|
||||
const { stdin, unmount } = renderWithProviders(
|
||||
<InputPrompt {...props} />,
|
||||
{
|
||||
uiActions,
|
||||
},
|
||||
);
|
||||
|
||||
await act(async () => {
|
||||
stdin.write('\u001B[A'); // Up arrow
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockBuffer.move).toHaveBeenCalledWith('home');
|
||||
expect(mockInputHistory.navigateUp).not.toHaveBeenCalled();
|
||||
});
|
||||
unmount();
|
||||
});
|
||||
|
||||
it('should navigate history on Up arrow if on first line and at start', async () => {
|
||||
mockBuffer.allVisualLines = ['line 1', 'line 2'];
|
||||
mockBuffer.visualCursor = [0, 0]; // First line, at start
|
||||
mockBuffer.visualScrollRow = 0;
|
||||
|
||||
const { stdin, unmount } = renderWithProviders(
|
||||
<InputPrompt {...props} />,
|
||||
{
|
||||
uiActions,
|
||||
},
|
||||
);
|
||||
|
||||
await act(async () => {
|
||||
stdin.write('\u001B[A'); // Up arrow
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockBuffer.move).not.toHaveBeenCalledWith('home');
|
||||
expect(mockInputHistory.navigateUp).toHaveBeenCalled();
|
||||
});
|
||||
unmount();
|
||||
});
|
||||
|
||||
it('should move to end of line on Down arrow if on last line but not at end', async () => {
|
||||
mockBuffer.allVisualLines = ['line 1', 'line 2'];
|
||||
mockBuffer.visualCursor = [1, 0]; // Last line, not at end
|
||||
mockBuffer.visualScrollRow = 0;
|
||||
|
||||
const { stdin, unmount } = renderWithProviders(
|
||||
<InputPrompt {...props} />,
|
||||
{
|
||||
uiActions,
|
||||
},
|
||||
);
|
||||
|
||||
await act(async () => {
|
||||
stdin.write('\u001B[B'); // Down arrow
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockBuffer.move).toHaveBeenCalledWith('end');
|
||||
expect(mockInputHistory.navigateDown).not.toHaveBeenCalled();
|
||||
});
|
||||
unmount();
|
||||
});
|
||||
|
||||
it('should navigate history on Down arrow if on last line and at end', async () => {
|
||||
mockBuffer.allVisualLines = ['line 1', 'line 2'];
|
||||
mockBuffer.visualCursor = [1, 6]; // Last line, at end ("line 2" is length 6)
|
||||
mockBuffer.visualScrollRow = 0;
|
||||
|
||||
const { stdin, unmount } = renderWithProviders(
|
||||
<InputPrompt {...props} />,
|
||||
{
|
||||
uiActions,
|
||||
},
|
||||
);
|
||||
|
||||
await act(async () => {
|
||||
stdin.write('\u001B[B'); // Down arrow
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockBuffer.move).not.toHaveBeenCalledWith('end');
|
||||
expect(mockInputHistory.navigateDown).toHaveBeenCalled();
|
||||
});
|
||||
unmount();
|
||||
});
|
||||
});
|
||||
|
||||
it('should call completion.navigateUp for both up arrow and Ctrl+P when suggestions are showing', async () => {
|
||||
mockedUseCommandCompletion.mockReturnValue({
|
||||
...mockCommandCompletion,
|
||||
@@ -598,11 +485,11 @@ describe('InputPrompt', () => {
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
stdin.write('\u0010'); // Ctrl+P
|
||||
stdin.write('\u001B[A'); // Up arrow
|
||||
});
|
||||
await waitFor(() => expect(mockInputHistory.navigateUp).toHaveBeenCalled());
|
||||
await act(async () => {
|
||||
stdin.write('\u000E'); // Ctrl+N
|
||||
stdin.write('\u001B[B'); // Down arrow
|
||||
});
|
||||
await waitFor(() =>
|
||||
expect(mockInputHistory.navigateDown).toHaveBeenCalled(),
|
||||
@@ -1047,33 +934,6 @@ describe('InputPrompt', () => {
|
||||
unmount();
|
||||
});
|
||||
|
||||
it('should NOT submit on Enter when an @-path is a perfect match', async () => {
|
||||
mockedUseCommandCompletion.mockReturnValue({
|
||||
...mockCommandCompletion,
|
||||
showSuggestions: true,
|
||||
suggestions: [{ label: 'file.txt', value: 'file.txt' }],
|
||||
activeSuggestionIndex: 0,
|
||||
isPerfectMatch: true,
|
||||
completionMode: CompletionMode.AT,
|
||||
});
|
||||
props.buffer.text = '@file.txt';
|
||||
|
||||
const { stdin, unmount } = renderWithProviders(<InputPrompt {...props} />, {
|
||||
uiActions,
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
stdin.write('\r');
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
// Should handle autocomplete but NOT submit
|
||||
expect(mockCommandCompletion.handleAutocomplete).toHaveBeenCalledWith(0);
|
||||
expect(props.onSubmit).not.toHaveBeenCalled();
|
||||
});
|
||||
unmount();
|
||||
});
|
||||
|
||||
it('should auto-execute commands with autoExecute: true on Enter', async () => {
|
||||
const aboutCommand: SlashCommand = {
|
||||
name: 'about',
|
||||
@@ -1765,16 +1625,15 @@ describe('InputPrompt', () => {
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockedUseCommandCompletion).toHaveBeenCalledWith({
|
||||
buffer: mockBuffer,
|
||||
cwd: path.join('test', 'project', 'src'),
|
||||
slashCommands: mockSlashCommands,
|
||||
commandContext: mockCommandContext,
|
||||
reverseSearchActive: false,
|
||||
shellModeActive: false,
|
||||
config: expect.any(Object),
|
||||
active: expect.anything(),
|
||||
});
|
||||
expect(mockedUseCommandCompletion).toHaveBeenCalledWith(
|
||||
mockBuffer,
|
||||
path.join('test', 'project', 'src'),
|
||||
mockSlashCommands,
|
||||
mockCommandContext,
|
||||
false,
|
||||
false,
|
||||
expect.any(Object),
|
||||
);
|
||||
});
|
||||
|
||||
unmount();
|
||||
@@ -3826,257 +3685,6 @@ describe('InputPrompt', () => {
|
||||
unmount();
|
||||
});
|
||||
});
|
||||
describe('History Navigation and Completion Suppression', () => {
|
||||
beforeEach(() => {
|
||||
props.userMessages = ['first message', 'second message'];
|
||||
// Mock useInputHistory to actually call onChange
|
||||
mockedUseInputHistory.mockImplementation(({ onChange }) => ({
|
||||
navigateUp: () => {
|
||||
onChange('second message', 'start');
|
||||
return true;
|
||||
},
|
||||
navigateDown: () => {
|
||||
onChange('first message', 'end');
|
||||
return true;
|
||||
},
|
||||
handleSubmit: vi.fn(),
|
||||
}));
|
||||
});
|
||||
|
||||
it.each([
|
||||
{ name: 'Up arrow', key: '\u001B[A', position: 'start' },
|
||||
{ name: 'Ctrl+P', key: '\u0010', position: 'start' },
|
||||
])(
|
||||
'should move cursor to $position on $name (older history)',
|
||||
async ({ key, position }) => {
|
||||
const { stdin } = renderWithProviders(<InputPrompt {...props} />, {
|
||||
uiActions,
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
stdin.write(key);
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockBuffer.setText).toHaveBeenCalledWith(
|
||||
'second message',
|
||||
position as 'start' | 'end',
|
||||
);
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
it.each([
|
||||
{ name: 'Down arrow', key: '\u001B[B', position: 'end' },
|
||||
{ name: 'Ctrl+N', key: '\u000E', position: 'end' },
|
||||
])(
|
||||
'should move cursor to $position on $name (newer history)',
|
||||
async ({ key, position }) => {
|
||||
const { stdin } = renderWithProviders(<InputPrompt {...props} />, {
|
||||
uiActions,
|
||||
});
|
||||
|
||||
// First go up
|
||||
await act(async () => {
|
||||
stdin.write('\u001B[A');
|
||||
});
|
||||
|
||||
// Then go down
|
||||
await act(async () => {
|
||||
stdin.write(key);
|
||||
if (key === '\u001B[B') {
|
||||
// Second press to actually navigate history
|
||||
stdin.write(key);
|
||||
}
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockBuffer.setText).toHaveBeenCalledWith(
|
||||
'first message',
|
||||
position as 'start' | 'end',
|
||||
);
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
it('should suppress completion after history navigation', async () => {
|
||||
const { stdin } = renderWithProviders(<InputPrompt {...props} />, {
|
||||
uiActions,
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
stdin.write('\u001B[A'); // Up arrow
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockedUseCommandCompletion).toHaveBeenLastCalledWith({
|
||||
buffer: mockBuffer,
|
||||
cwd: expect.anything(),
|
||||
slashCommands: expect.anything(),
|
||||
commandContext: expect.anything(),
|
||||
reverseSearchActive: expect.anything(),
|
||||
shellModeActive: expect.anything(),
|
||||
config: expect.anything(),
|
||||
active: false,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
it('should not render suggestions during history navigation', async () => {
|
||||
// 1. Set up a dynamic mock implementation BEFORE rendering
|
||||
mockedUseCommandCompletion.mockImplementation(({ active }) => ({
|
||||
...mockCommandCompletion,
|
||||
showSuggestions: active,
|
||||
suggestions: active
|
||||
? [{ value: 'suggestion', label: 'suggestion' }]
|
||||
: [],
|
||||
}));
|
||||
|
||||
const { stdout, stdin, unmount } = renderWithProviders(
|
||||
<InputPrompt {...props} />,
|
||||
{ uiActions },
|
||||
);
|
||||
|
||||
// 2. Verify suggestions ARE showing initially because active is true by default
|
||||
await waitFor(() => {
|
||||
expect(stdout.lastFrame()).toContain('suggestion');
|
||||
});
|
||||
|
||||
// 3. Trigger history navigation which should set suppressCompletion to true
|
||||
await act(async () => {
|
||||
stdin.write('\u001B[A');
|
||||
});
|
||||
|
||||
// 4. Verify that suggestions are NOT in the output frame after navigation
|
||||
await waitFor(() => {
|
||||
expect(stdout.lastFrame()).not.toContain('suggestion');
|
||||
});
|
||||
|
||||
expect(stdout.lastFrame()).toMatchSnapshot();
|
||||
unmount();
|
||||
});
|
||||
|
||||
it('should continue to suppress completion after manual cursor movement', async () => {
|
||||
const { stdin } = renderWithProviders(<InputPrompt {...props} />, {
|
||||
uiActions,
|
||||
});
|
||||
|
||||
// Navigate history (suppresses)
|
||||
await act(async () => {
|
||||
stdin.write('\u001B[A');
|
||||
});
|
||||
|
||||
// Wait for it to be suppressed
|
||||
await waitFor(() => {
|
||||
expect(mockedUseCommandCompletion).toHaveBeenLastCalledWith({
|
||||
buffer: mockBuffer,
|
||||
cwd: expect.anything(),
|
||||
slashCommands: expect.anything(),
|
||||
commandContext: expect.anything(),
|
||||
reverseSearchActive: expect.anything(),
|
||||
shellModeActive: expect.anything(),
|
||||
config: expect.anything(),
|
||||
active: false,
|
||||
});
|
||||
});
|
||||
|
||||
// Move cursor manually
|
||||
await act(async () => {
|
||||
stdin.write('\u001B[D'); // Left arrow
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockedUseCommandCompletion).toHaveBeenLastCalledWith({
|
||||
buffer: mockBuffer,
|
||||
cwd: expect.anything(),
|
||||
slashCommands: expect.anything(),
|
||||
commandContext: expect.anything(),
|
||||
reverseSearchActive: expect.anything(),
|
||||
shellModeActive: expect.anything(),
|
||||
config: expect.anything(),
|
||||
active: false,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
it('should re-enable completion after typing', async () => {
|
||||
const { stdin } = renderWithProviders(<InputPrompt {...props} />, {
|
||||
uiActions,
|
||||
});
|
||||
|
||||
// Navigate history (suppresses)
|
||||
await act(async () => {
|
||||
stdin.write('\u001B[A');
|
||||
});
|
||||
|
||||
// Wait for it to be suppressed
|
||||
await waitFor(() => {
|
||||
expect(mockedUseCommandCompletion).toHaveBeenLastCalledWith(
|
||||
expect.objectContaining({ active: false }),
|
||||
);
|
||||
});
|
||||
|
||||
// Type a character
|
||||
await act(async () => {
|
||||
stdin.write('a');
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockedUseCommandCompletion).toHaveBeenLastCalledWith(
|
||||
expect.objectContaining({ active: true }),
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('shortcuts help visibility', () => {
|
||||
it.each([
|
||||
{
|
||||
name: 'terminal paste event occurs',
|
||||
input: '\x1b[200~pasted text\x1b[201~',
|
||||
},
|
||||
{
|
||||
name: 'Ctrl+V (PASTE_CLIPBOARD) is pressed',
|
||||
input: '\x16',
|
||||
setupMocks: () => {
|
||||
vi.mocked(clipboardUtils.clipboardHasImage).mockResolvedValue(false);
|
||||
vi.mocked(clipboardy.read).mockResolvedValue('clipboard text');
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'mouse right-click paste occurs',
|
||||
input: '\x1b[<2;1;1m',
|
||||
mouseEventsEnabled: true,
|
||||
setupMocks: () => {
|
||||
vi.mocked(clipboardUtils.clipboardHasImage).mockResolvedValue(false);
|
||||
vi.mocked(clipboardy.read).mockResolvedValue('clipboard text');
|
||||
},
|
||||
},
|
||||
])(
|
||||
'should close shortcuts help when a $name',
|
||||
async ({ input, setupMocks, mouseEventsEnabled }) => {
|
||||
setupMocks?.();
|
||||
const setShortcutsHelpVisible = vi.fn();
|
||||
const { stdin, unmount } = renderWithProviders(
|
||||
<InputPrompt {...props} />,
|
||||
{
|
||||
uiState: { shortcutsHelpVisible: true },
|
||||
uiActions: { setShortcutsHelpVisible },
|
||||
mouseEventsEnabled,
|
||||
},
|
||||
);
|
||||
|
||||
await act(async () => {
|
||||
stdin.write(input);
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(setShortcutsHelpVisible).toHaveBeenCalledWith(false);
|
||||
});
|
||||
unmount();
|
||||
},
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
function clean(str: string | undefined): string {
|
||||
|
||||
@@ -151,7 +151,7 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
|
||||
const { merged: settings } = useSettings();
|
||||
const kittyProtocol = useKittyKeyboardProtocol();
|
||||
const isShellFocused = useShellFocusState();
|
||||
const { setEmbeddedShellFocused, setShortcutsHelpVisible } = useUIActions();
|
||||
const { setEmbeddedShellFocused } = useUIActions();
|
||||
const {
|
||||
terminalWidth,
|
||||
activePtyId,
|
||||
@@ -159,9 +159,8 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
|
||||
terminalBackgroundColor,
|
||||
backgroundShells,
|
||||
backgroundShellHeight,
|
||||
shortcutsHelpVisible,
|
||||
} = useUIState();
|
||||
const [suppressCompletion, setSuppressCompletion] = useState(false);
|
||||
const [justNavigatedHistory, setJustNavigatedHistory] = useState(false);
|
||||
const escPressCount = useRef(0);
|
||||
const [showEscapePrompt, setShowEscapePrompt] = useState(false);
|
||||
const escapeTimerRef = useRef<NodeJS.Timeout | null>(null);
|
||||
@@ -182,16 +181,15 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
|
||||
const shellHistory = useShellHistory(config.getProjectRoot());
|
||||
const shellHistoryData = shellHistory.history;
|
||||
|
||||
const completion = useCommandCompletion({
|
||||
const completion = useCommandCompletion(
|
||||
buffer,
|
||||
cwd: config.getTargetDir(),
|
||||
config.getTargetDir(),
|
||||
slashCommands,
|
||||
commandContext,
|
||||
reverseSearchActive,
|
||||
shellModeActive,
|
||||
config,
|
||||
active: !suppressCompletion,
|
||||
});
|
||||
);
|
||||
|
||||
const reverseSearchCompletion = useReverseSearchCompletion(
|
||||
buffer,
|
||||
@@ -304,11 +302,11 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
|
||||
);
|
||||
|
||||
const customSetTextAndResetCompletionSignal = useCallback(
|
||||
(newText: string, cursorPosition?: 'start' | 'end' | number) => {
|
||||
buffer.setText(newText, cursorPosition);
|
||||
setSuppressCompletion(true);
|
||||
(newText: string) => {
|
||||
buffer.setText(newText);
|
||||
setJustNavigatedHistory(true);
|
||||
},
|
||||
[buffer, setSuppressCompletion],
|
||||
[buffer, setJustNavigatedHistory],
|
||||
);
|
||||
|
||||
const inputHistory = useInputHistory({
|
||||
@@ -318,26 +316,25 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
|
||||
(!completion.showSuggestions || completion.suggestions.length === 1) &&
|
||||
!shellModeActive,
|
||||
currentQuery: buffer.text,
|
||||
currentCursorOffset: buffer.getOffset(),
|
||||
onChange: customSetTextAndResetCompletionSignal,
|
||||
});
|
||||
|
||||
// Effect to reset completion if history navigation just occurred and set the text
|
||||
useEffect(() => {
|
||||
if (suppressCompletion) {
|
||||
if (justNavigatedHistory) {
|
||||
resetCompletionState();
|
||||
resetReverseSearchCompletionState();
|
||||
resetCommandSearchCompletionState();
|
||||
setExpandedSuggestionIndex(-1);
|
||||
setJustNavigatedHistory(false);
|
||||
}
|
||||
}, [
|
||||
suppressCompletion,
|
||||
justNavigatedHistory,
|
||||
buffer.text,
|
||||
resetCompletionState,
|
||||
setSuppressCompletion,
|
||||
setJustNavigatedHistory,
|
||||
resetReverseSearchCompletionState,
|
||||
resetCommandSearchCompletionState,
|
||||
setExpandedSuggestionIndex,
|
||||
]);
|
||||
|
||||
// Helper function to handle loading queued messages into input
|
||||
@@ -359,9 +356,6 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
|
||||
|
||||
// Handle clipboard image pasting with Ctrl+V
|
||||
const handleClipboardPaste = useCallback(async () => {
|
||||
if (shortcutsHelpVisible) {
|
||||
setShortcutsHelpVisible(false);
|
||||
}
|
||||
try {
|
||||
if (await clipboardHasImage()) {
|
||||
const imagePath = await saveClipboardImage(config.getTargetDir());
|
||||
@@ -406,19 +400,11 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
|
||||
} catch (error) {
|
||||
debugLogger.error('Error handling paste:', error);
|
||||
}
|
||||
}, [
|
||||
buffer,
|
||||
config,
|
||||
stdout,
|
||||
settings,
|
||||
shortcutsHelpVisible,
|
||||
setShortcutsHelpVisible,
|
||||
]);
|
||||
}, [buffer, config, stdout, settings]);
|
||||
|
||||
useMouseClick(
|
||||
innerBoxRef,
|
||||
(_event, relX, relY) => {
|
||||
setSuppressCompletion(true);
|
||||
if (isEmbeddedShellFocused) {
|
||||
setEmbeddedShellFocused(false);
|
||||
}
|
||||
@@ -484,7 +470,6 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
|
||||
useMouse(
|
||||
(event: MouseEvent) => {
|
||||
if (event.name === 'right-release') {
|
||||
setSuppressCompletion(false);
|
||||
// eslint-disable-next-line @typescript-eslint/no-floating-promises
|
||||
handleClipboardPaste();
|
||||
}
|
||||
@@ -494,50 +479,6 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
|
||||
|
||||
const handleInput = useCallback(
|
||||
(key: Key) => {
|
||||
// Determine if this keypress is a history navigation command
|
||||
const isHistoryUp =
|
||||
!shellModeActive &&
|
||||
(keyMatchers[Command.HISTORY_UP](key) ||
|
||||
(keyMatchers[Command.NAVIGATION_UP](key) &&
|
||||
(buffer.allVisualLines.length === 1 ||
|
||||
(buffer.visualCursor[0] === 0 && buffer.visualScrollRow === 0))));
|
||||
const isHistoryDown =
|
||||
!shellModeActive &&
|
||||
(keyMatchers[Command.HISTORY_DOWN](key) ||
|
||||
(keyMatchers[Command.NAVIGATION_DOWN](key) &&
|
||||
(buffer.allVisualLines.length === 1 ||
|
||||
buffer.visualCursor[0] === buffer.allVisualLines.length - 1)));
|
||||
|
||||
const isHistoryNav = isHistoryUp || isHistoryDown;
|
||||
const isCursorMovement =
|
||||
keyMatchers[Command.MOVE_LEFT](key) ||
|
||||
keyMatchers[Command.MOVE_RIGHT](key) ||
|
||||
keyMatchers[Command.MOVE_UP](key) ||
|
||||
keyMatchers[Command.MOVE_DOWN](key) ||
|
||||
keyMatchers[Command.MOVE_WORD_LEFT](key) ||
|
||||
keyMatchers[Command.MOVE_WORD_RIGHT](key) ||
|
||||
keyMatchers[Command.HOME](key) ||
|
||||
keyMatchers[Command.END](key);
|
||||
|
||||
const isSuggestionsNav =
|
||||
(completion.showSuggestions ||
|
||||
reverseSearchCompletion.showSuggestions ||
|
||||
commandSearchCompletion.showSuggestions) &&
|
||||
(keyMatchers[Command.COMPLETION_UP](key) ||
|
||||
keyMatchers[Command.COMPLETION_DOWN](key) ||
|
||||
keyMatchers[Command.EXPAND_SUGGESTION](key) ||
|
||||
keyMatchers[Command.COLLAPSE_SUGGESTION](key) ||
|
||||
keyMatchers[Command.ACCEPT_SUGGESTION](key));
|
||||
|
||||
// Reset completion suppression if the user performs any action other than
|
||||
// history navigation or cursor movement.
|
||||
// We explicitly skip this if we are currently navigating suggestions.
|
||||
if (!isSuggestionsNav) {
|
||||
setSuppressCompletion(
|
||||
isHistoryNav || isCursorMovement || keyMatchers[Command.ESCAPE](key),
|
||||
);
|
||||
}
|
||||
|
||||
// TODO(jacobr): this special case is likely not needed anymore.
|
||||
// We should probably stop supporting paste if the InputPrompt is not
|
||||
// focused.
|
||||
@@ -546,14 +487,6 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
|
||||
return false;
|
||||
}
|
||||
|
||||
// Handle escape to close shortcuts panel first, before letting it bubble
|
||||
// up for cancellation. This ensures pressing Escape once closes the panel,
|
||||
// and pressing again cancels the operation.
|
||||
if (shortcutsHelpVisible && key.name === 'escape') {
|
||||
setShortcutsHelpVisible(false);
|
||||
return true;
|
||||
}
|
||||
|
||||
if (
|
||||
key.name === 'escape' &&
|
||||
(streamingState === StreamingState.Responding ||
|
||||
@@ -563,9 +496,6 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
|
||||
}
|
||||
|
||||
if (key.name === 'paste') {
|
||||
if (shortcutsHelpVisible) {
|
||||
setShortcutsHelpVisible(false);
|
||||
}
|
||||
// Record paste time to prevent accidental auto-submission
|
||||
if (!isTerminalPasteTrusted(kittyProtocol.enabled)) {
|
||||
setRecentUnsafePasteTime(Date.now());
|
||||
@@ -594,33 +524,6 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
|
||||
return true;
|
||||
}
|
||||
|
||||
if (shortcutsHelpVisible) {
|
||||
if (key.sequence === '?' && key.insertable) {
|
||||
setShortcutsHelpVisible(false);
|
||||
buffer.handleInput(key);
|
||||
return true;
|
||||
}
|
||||
// Escape is handled earlier to ensure it closes the panel before
|
||||
// potentially cancelling an operation
|
||||
if (key.name === 'backspace' || key.sequence === '\b') {
|
||||
setShortcutsHelpVisible(false);
|
||||
return true;
|
||||
}
|
||||
if (key.insertable) {
|
||||
setShortcutsHelpVisible(false);
|
||||
}
|
||||
}
|
||||
|
||||
if (
|
||||
key.sequence === '?' &&
|
||||
key.insertable &&
|
||||
!shortcutsHelpVisible &&
|
||||
buffer.text.length === 0
|
||||
) {
|
||||
setShortcutsHelpVisible(true);
|
||||
return true;
|
||||
}
|
||||
|
||||
if (vimHandleInput && vimHandleInput(key)) {
|
||||
return true;
|
||||
}
|
||||
@@ -799,7 +702,6 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
|
||||
// We prioritize execution unless the user is explicitly selecting a different suggestion.
|
||||
if (
|
||||
completion.isPerfectMatch &&
|
||||
completion.completionMode !== CompletionMode.AT &&
|
||||
keyMatchers[Command.RETURN](key) &&
|
||||
(!completion.showSuggestions || completion.activeSuggestionIndex <= 0)
|
||||
) {
|
||||
@@ -899,14 +801,7 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
|
||||
return true;
|
||||
}
|
||||
|
||||
if (isHistoryUp) {
|
||||
if (
|
||||
keyMatchers[Command.NAVIGATION_UP](key) &&
|
||||
buffer.visualCursor[1] > 0
|
||||
) {
|
||||
buffer.move('home');
|
||||
return true;
|
||||
}
|
||||
if (keyMatchers[Command.HISTORY_UP](key)) {
|
||||
// Check for queued messages first when input is empty
|
||||
// If no queued messages, inputHistory.navigateUp() is called inside tryLoadQueuedMessages
|
||||
if (tryLoadQueuedMessages()) {
|
||||
@@ -916,43 +811,41 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
|
||||
inputHistory.navigateUp();
|
||||
return true;
|
||||
}
|
||||
if (isHistoryDown) {
|
||||
if (
|
||||
keyMatchers[Command.NAVIGATION_DOWN](key) &&
|
||||
buffer.visualCursor[1] <
|
||||
cpLen(buffer.allVisualLines[buffer.visualCursor[0]] || '')
|
||||
) {
|
||||
buffer.move('end');
|
||||
if (keyMatchers[Command.HISTORY_DOWN](key)) {
|
||||
inputHistory.navigateDown();
|
||||
return true;
|
||||
}
|
||||
// Handle arrow-up/down for history on single-line or at edges
|
||||
if (
|
||||
keyMatchers[Command.NAVIGATION_UP](key) &&
|
||||
(buffer.allVisualLines.length === 1 ||
|
||||
(buffer.visualCursor[0] === 0 && buffer.visualScrollRow === 0))
|
||||
) {
|
||||
// Check for queued messages first when input is empty
|
||||
// If no queued messages, inputHistory.navigateUp() is called inside tryLoadQueuedMessages
|
||||
if (tryLoadQueuedMessages()) {
|
||||
return true;
|
||||
}
|
||||
// Only navigate history if popAllMessages doesn't exist
|
||||
inputHistory.navigateUp();
|
||||
return true;
|
||||
}
|
||||
if (
|
||||
keyMatchers[Command.NAVIGATION_DOWN](key) &&
|
||||
(buffer.allVisualLines.length === 1 ||
|
||||
buffer.visualCursor[0] === buffer.allVisualLines.length - 1)
|
||||
) {
|
||||
inputHistory.navigateDown();
|
||||
return true;
|
||||
}
|
||||
} else {
|
||||
// Shell History Navigation
|
||||
if (keyMatchers[Command.NAVIGATION_UP](key)) {
|
||||
if (
|
||||
(buffer.allVisualLines.length === 1 ||
|
||||
(buffer.visualCursor[0] === 0 && buffer.visualScrollRow === 0)) &&
|
||||
buffer.visualCursor[1] > 0
|
||||
) {
|
||||
buffer.move('home');
|
||||
return true;
|
||||
}
|
||||
const prevCommand = shellHistory.getPreviousCommand();
|
||||
if (prevCommand !== null) buffer.setText(prevCommand);
|
||||
return true;
|
||||
}
|
||||
if (keyMatchers[Command.NAVIGATION_DOWN](key)) {
|
||||
if (
|
||||
(buffer.allVisualLines.length === 1 ||
|
||||
buffer.visualCursor[0] === buffer.allVisualLines.length - 1) &&
|
||||
buffer.visualCursor[1] <
|
||||
cpLen(buffer.allVisualLines[buffer.visualCursor[0]] || '')
|
||||
) {
|
||||
buffer.move('end');
|
||||
return true;
|
||||
}
|
||||
const nextCommand = shellHistory.getNextCommand();
|
||||
if (nextCommand !== null) buffer.setText(nextCommand);
|
||||
return true;
|
||||
@@ -1031,19 +924,15 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
|
||||
return true;
|
||||
}
|
||||
|
||||
if (keyMatchers[Command.TOGGLE_BACKGROUND_SHELL](key)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (keyMatchers[Command.FOCUS_SHELL_INPUT](key)) {
|
||||
// If we got here, Autocomplete didn't handle the key (e.g. no suggestions).
|
||||
if (
|
||||
activePtyId ||
|
||||
(backgroundShells.size > 0 && backgroundShellHeight > 0)
|
||||
) {
|
||||
setEmbeddedShellFocused(true);
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
return true;
|
||||
}
|
||||
|
||||
// Fall back to the text buffer's default input handling for all other keys
|
||||
@@ -1093,8 +982,6 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
|
||||
commandSearchActive,
|
||||
commandSearchCompletion,
|
||||
kittyProtocol.enabled,
|
||||
shortcutsHelpVisible,
|
||||
setShortcutsHelpVisible,
|
||||
tryLoadQueuedMessages,
|
||||
setBannerVisible,
|
||||
onSubmit,
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user