Compare commits

..

36 Commits

Author SHA1 Message Date
Alisa Novikova d952b20532 add package @google/gemini-cli-devtools to devDependencies 2026-02-18 20:31:57 -08:00
Alisa Novikova 5f1a51fd77 add package @google/gemini-cli-devtools 2026-02-18 20:23:12 -08:00
Jacob Richman c43500c50b build: replace deprecated built-in punycode with userland package (#19502) 2026-02-19 02:58:26 +00:00
gemini-cli-robot 42a7d89999 Changelog for v0.30.0-preview.1 (#19496)
Co-authored-by: gemini-cli-robot <224641728+gemini-cli-robot@users.noreply.github.com>
Co-authored-by: Sam Roberts <158088236+g-samroberts@users.noreply.github.com>
2026-02-19 01:56:36 +00:00
Jacob Richman 04f65f3d55 Migrate core render util to use xterm.js as part of the rendering loop. (#19044) 2026-02-19 00:46:50 +00:00
Sam Roberts 04c52513e7 Remove unused files and update index and sidebar. (#19479) 2026-02-19 00:10:49 +00:00
Spencer c62340675a feat(core): centralize compatibility checks and add TrueColor detection (#19478) 2026-02-19 00:01:23 +00:00
Sam Roberts ef65498031 Format strict-development-rules command (#19484) 2026-02-18 23:16:33 +00:00
Shreya Keshive 261788cf91 feat(admin): Admin settings should only apply if adminControlsApplicable = true and fetch errors should be fatal (#19453) 2026-02-18 22:54:07 +00:00
Jasmeet Bhatia 012392ad0a feat(cli): include /dir add directories in @ autocomplete suggestions (#19246) 2026-02-18 22:38:35 +00:00
garrettsparks 037061e2e0 use issuer instead of authorization_endpoint for oauth discovery (#17332)
Co-authored-by: Tommaso Sciortino <sciortino@gmail.com>
2026-02-18 22:38:04 +00:00
Smitty 221ea360b9 fix(core): ripgrep fails when pattern looks like ripgrep flag (#18858)
Co-authored-by: Tommaso Sciortino <sciortino@gmail.com>
2026-02-18 22:11:24 +00:00
N. Taylor Mullen 8910b2720f fix(cli): disable auto-completion on Shift+Tab to preserve mode cycling (#19451) 2026-02-18 22:08:38 +00:00
joshualitt 87f5dd15d6 feat(core): experimental in-progress steering hints (2 of 2) (#19307) 2026-02-18 22:05:50 +00:00
Adib234 81c8893e05 docs(plan): add documentation for plan mode command (#19467)
Co-authored-by: gemini-code-assist[bot] <176961590+gemini-code-assist[bot]@users.noreply.github.com>
2026-02-18 22:03:09 +00:00
skyvanguard 178388d931 fix(cli): treat unknown slash commands as regular input instead of showing error (#17393)
Co-authored-by: Tommaso Sciortino <sciortino@gmail.com>
2026-02-18 21:52:51 +00:00
imadraude e7f97dfa44 fix(ui): move margin from top to bottom in ToolGroupMessage (#17198) 2026-02-18 21:51:03 +00:00
Godwin Iheuwa f961e0d6b1 fix(core): ensure directory exists before writing conversation file (#18429)
Co-authored-by: godwiniheuwa <godwiniheuwa@users.noreply.github.com>
Co-authored-by: ruintheextinct <deepkarma001@gmail.com>
2026-02-18 21:13:54 +00:00
N. Taylor Mullen 14415316c0 feat(core): add support for MCP progress updates (#19046) 2026-02-18 20:46:12 +00:00
gemini-cli-robot 1cf05b0375 Changelog for v0.30.0-preview.0 (#19364)
Co-authored-by: g-samroberts <158088236+g-samroberts@users.noreply.github.com>
Co-authored-by: g-samroberts <samroberts@google.com>
2026-02-18 20:43:39 +00:00
Dev Randalpura 3099df1b7c fix(ui): preventing empty history items from being added (#19014) 2026-02-18 12:53:06 -08:00
gemini-cli-robot 758d419e33 Changelog for v0.29.0 (#19361)
Co-authored-by: g-samroberts <158088236+g-samroberts@users.noreply.github.com>
2026-02-18 20:30:52 +00:00
Dmitry Lyalin 78de533c48 feat(cli): add macOS run-event notifications (interactive only) (#19056)
Co-authored-by: Tommaso Sciortino <sciortino@gmail.com>
2026-02-18 20:28:17 +00:00
Jerop Kipruto 8f6a711a3a fix(core): clarify plan mode constraints and exit mechanism (#19438) 2026-02-18 20:09:59 +00:00
Sandy Tao 65ad78b9c0 feat(devtools): migrate devtools package into monorepo (#18936) 2026-02-18 20:04:02 +00:00
christine betts 858918fe31 Add explicit color-convert dependency (#19460) 2026-02-18 19:55:21 +00:00
Adib234 9255e69abb fix(plan): allow safe fallback when experiment setting for plan is not enabled but approval mode at startup is plan (#19439)
Co-authored-by: Jerop Kipruto <jerop@google.com>
2026-02-18 19:54:04 +00:00
dependabot[bot] e32111e609 chore(deps): bump tar from 7.5.7 to 7.5.8 (#19367)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-02-18 19:49:46 +00:00
Sandy Tao 2c1d6f8029 fix(cli): support legacy onConfirm callback in ToolActionsContext (#19369) 2026-02-18 19:46:09 +00:00
christine betts 8a8826654c Disable failing eval test (#19455) 2026-02-18 19:27:21 +00:00
Tommaso Sciortino 5f6b7c0158 feat(cli): add Alt+D for forward word deletion (#19300) 2026-02-18 09:19:26 -08:00
Mag1ck 65e0043fbf feat(cli): add gemini --resume hint on exit (#16285)
Co-authored-by: gemini-code-assist[bot] <176961590+gemini-code-assist[bot]@users.noreply.github.com>
Co-authored-by: Jack Wotherspoon <jackwoth@google.com>
2026-02-18 15:54:01 +00:00
Jack Wotherspoon 22763c98b0 fix: optimize height calculations for ask_user dialog (#19017) 2026-02-18 15:52:30 +00:00
N. Taylor Mullen 05be2b51fc docs: clarify preflight instructions in GEMINI.md (#19377) 2026-02-18 07:16:55 -08:00
N. Taylor Mullen f1aa38b258 test(evals): add behavioral tests for tool output masking (#19172) 2026-02-18 05:07:25 +00:00
g-samroberts 4e11ddbf4a Release note generator fix (#19363) 2026-02-18 04:53:53 +00:00
370 changed files with 15437 additions and 5397 deletions
+116 -38
View File
@@ -1,64 +1,142 @@
# Gemini CLI Strict Development Rules
These rules apply strictly to all code modifications and additions within the Gemini CLI project.
These rules apply strictly to all code modifications and additions within the
Gemini CLI project.
## Testing Guidelines
* **Async/Await**: Always use `waitFor` from `packages/cli/src/test-utils/async.ts` instead of `vi.waitFor` for all `waitFor` calls within `packages/cli`. NEVER use fixed waits (e.g., `await delay(100)`). Always use `waitFor` with a predicate to ensure tests are stable and fast. Using the wrong `waitFor` can result in flaky tests and `act` warnings.
* **React Testing**: Use `act` to wrap all blocks in tests that change component state. Use `render` or `renderWithProviders` from `packages/cli/src/test-utils/render.tsx` instead of `render` from `ink-testing-library` directly. This prevents spurious `act` warnings. If test cases specify providers directly, consider whether the existing `renderWithProviders` should be modified.
* **Snapshots**: Use `toMatchSnapshot` to verify that rendering works as expected rather than matching against the raw content of the output. When modifying snapshots, verify the changes are intentional and do not hide underlying bugs.
* **Parameterized Tests**: Use parameterized tests where it reduces duplicated lines. Give the parameters explicit types to ensure the tests are type-safe.
* **Mocks Management**:
* Mock critical dependencies (`fs`, `os`, `child_process`) ONLY at the top of the file. Ideally, avoid mocking these dependencies altogether.
* Reuse existing mocks and fakes rather than creating new ones.
* Avoid mocking the file system whenever possible. If using the real file system is too difficult, consider writing an integration test instead.
* Always call `vi.restoreAllMocks()` in `afterEach` to prevent test pollution.
* Use `vi.useFakeTimers()` for tests involving time-based logic to avoid flakiness.
* **Typing in Tests**: Avoid using `any` in tests; prefer proper types or `unknown` with narrowing.
- **Async/Await**: Always use `waitFor` from
`packages/cli/src/test-utils/async.ts` instead of `vi.waitFor` for all
`waitFor` calls within `packages/cli`. NEVER use fixed waits (e.g.,
`await delay(100)`). Always use `waitFor` with a predicate to ensure tests are
stable and fast. Using the wrong `waitFor` can result in flaky tests and `act`
warnings.
- **React Testing**: Use `act` to wrap all blocks in tests that change component
state. Use `render` or `renderWithProviders` from
`packages/cli/src/test-utils/render.tsx` instead of `render` from
`ink-testing-library` directly. This prevents spurious `act` warnings. If test
cases specify providers directly, consider whether the existing
`renderWithProviders` should be modified.
- **Snapshots**: Use `toMatchSnapshot` to verify that rendering works as
expected rather than matching against the raw content of the output. When
modifying snapshots, verify the changes are intentional and do not hide
underlying bugs.
- **Parameterized Tests**: Use parameterized tests where it reduces duplicated
lines. Give the parameters explicit types to ensure the tests are type-safe.
- **Mocks Management**:
- Mock critical dependencies (`fs`, `os`, `child_process`) ONLY at the top of
the file. Ideally, avoid mocking these dependencies altogether.
- Reuse existing mocks and fakes rather than creating new ones.
- Avoid mocking the file system whenever possible. If using the real file
system is too difficult, consider writing an integration test instead.
- Always call `vi.restoreAllMocks()` in `afterEach` to prevent test pollution.
- Use `vi.useFakeTimers()` for tests involving time-based logic to avoid
flakiness.
- **Typing in Tests**: Avoid using `any` in tests; prefer proper types or
`unknown` with narrowing.
## React Guidelines (`packages/cli`)
* **`setState` and Side Effects**: NEVER trigger side effects from within the body of a `setState` callback. Use a reducer or `useRef` if necessary. These cases have historically introduced multiple bugs; typically, they should be resolved using a reducer.
* **Rendering**: Do not introduce infinite rendering loops. Avoid synchronous file I/O in React components as it will hang the UI. Do not implement new logic for custom string measurement or string truncation. Use Ink layout instead, leveraging `ResizeObserver` as needed.
* **Keyboard Handling**: Keyboard handling MUST go through `useKeyPress.ts` from the Gemini CLI package rather than the standard ink library. This library supports reporting multiple keyboard events sequentially in the same React frame (critical for slow terminals). Handling this correctly often requires reducers to ensure multiple state updates are handled gracefully without overriding values. Refer to `text-buffer.ts` for a canonical example.
* **Logging**: Do not leave `console.log`, `console.warn`, or `console.error` in the code.
* **State & Effects**: Ensure state initialization is explicit (e.g., use `undefined` rather than `true` as a default if the state is truly unknown). Carefully manage `useEffect` dependencies. Prefer a reducer whenever practical. NEVER disable `react-hooks/exhaustive-deps`; fix the code to correctly declare dependencies instead.
* **Context & Props**: Avoid excessive property drilling. Leverage existing providers, extend them, or propose a new one if necessary. Only use providers for properties that are consistent across the entire application.
* **Code Structure**: Avoid complex `if` statements where `switch` statements could be used. Keep `AppContainer` minimal; refactor complex logic into React hooks. Evaluate whether business logic should be added to `hookSystem.ts` or integrated into `packages/core` rather than `packages/cli`.
- **`setState` and Side Effects**: NEVER trigger side effects from within the
body of a `setState` callback. Use a reducer or `useRef` if necessary. These
cases have historically introduced multiple bugs; typically, they should be
resolved using a reducer.
- **Rendering**: Do not introduce infinite rendering loops. Avoid synchronous
file I/O in React components as it will hang the UI. Do not implement new
logic for custom string measurement or string truncation. Use Ink layout
instead, leveraging `ResizeObserver` as needed.
- **Keyboard Handling**: Keyboard handling MUST go through `useKeyPress.ts` from
the Gemini CLI package rather than the standard ink library. This library
supports reporting multiple keyboard events sequentially in the same React
frame (critical for slow terminals). Handling this correctly often requires
reducers to ensure multiple state updates are handled gracefully without
overriding values. Refer to `text-buffer.ts` for a canonical example.
- **Logging**: Do not leave `console.log`, `console.warn`, or `console.error` in
the code.
- **State & Effects**: Ensure state initialization is explicit (e.g., use
`undefined` rather than `true` as a default if the state is truly unknown).
Carefully manage `useEffect` dependencies. Prefer a reducer whenever
practical. NEVER disable `react-hooks/exhaustive-deps`; fix the code to
correctly declare dependencies instead.
- **Context & Props**: Avoid excessive property drilling. Leverage existing
providers, extend them, or propose a new one if necessary. Only use providers
for properties that are consistent across the entire application.
- **Code Structure**: Avoid complex `if` statements where `switch` statements
could be used. Keep `AppContainer` minimal; refactor complex logic into React
hooks. Evaluate whether business logic should be added to `hookSystem.ts` or
integrated into `packages/core` rather than `packages/cli`.
## Core Guidelines (`packages/core`)
* **Services**: Implement services as classes with clear lifecycle management (e.g., `initialize()` methods). Services should be stateless where possible, or use the centralized `Storage` service for persistence.
* **Cross-Service Communication**: Prefer using the `coreEvents` bus (from `packages/core/src/utils/events.ts`) for asynchronous communication between services or to notify the UI of state changes. Avoid tight coupling between services.
* **Utilities**: Use `debugLogger` from `packages/core/src/utils/debugLogger.ts` for internal logging instead of `console`. Ensure all shell operations use `spawnAsync` from `packages/core/src/utils/shell-utils.ts` for consistent error handling and promise management. Handle filesystem errors gracefully using `isNodeError` from `packages/core/src/utils/errors.ts`.
* **Exports & Tooling**: Add new tools to `packages/core/src/tools/` and register them in `packages/core/src/tools/tool-registry.ts`. Export all new public services, utilities, and types from `packages/core/src/index.ts`.
- **Services**: Implement services as classes with clear lifecycle management
(e.g., `initialize()` methods). Services should be stateless where possible,
or use the centralized `Storage` service for persistence.
- **Cross-Service Communication**: Prefer using the `coreEvents` bus (from
`packages/core/src/utils/events.ts`) for asynchronous communication between
services or to notify the UI of state changes. Avoid tight coupling between
services.
- **Utilities**: Use `debugLogger` from `packages/core/src/utils/debugLogger.ts`
for internal logging instead of `console`. Ensure all shell operations use
`spawnAsync` from `packages/core/src/utils/shell-utils.ts` for consistent
error handling and promise management. Handle filesystem errors gracefully
using `isNodeError` from `packages/core/src/utils/errors.ts`.
- **Exports & Tooling**: Add new tools to `packages/core/src/tools/` and
register them in `packages/core/src/tools/tool-registry.ts`. Export all new
public services, utilities, and types from `packages/core/src/index.ts`.
## Architectural Audit (Package Boundaries)
* **Logic Placement**: Non-UI logic (e.g., model orchestration, tool implementation, git/filesystem operations) MUST reside in `packages/core`. `packages/cli` should ONLY contain UI/Ink components, command-line argument parsing, and user interaction logic.
* **Environment Isolation**: Core logic must not assume a TUI environment. Use the `ConfirmationBus` or `Output` abstractions for communicating with the user from Core.
* **Decoupling**: Actively look for opportunities to decouple services using `coreEvents`. If a service imports another just to notify it of a change, use an event instead.
- **Logic Placement**: Non-UI logic (e.g., model orchestration, tool
implementation, git/filesystem operations) MUST reside in `packages/core`.
`packages/cli` should ONLY contain UI/Ink components, command-line argument
parsing, and user interaction logic.
- **Environment Isolation**: Core logic must not assume a TUI environment. Use
the `ConfirmationBus` or `Output` abstractions for communicating with the user
from Core.
- **Decoupling**: Actively look for opportunities to decouple services using
`coreEvents`. If a service imports another just to notify it of a change, use
an event instead.
## General Gemini CLI Design Principles
* **Settings**: Use settings for user-configurable options rather than adding new command line arguments. Add new settings to `packages/cli/src/config/settingsSchema.ts`. If a setting has `showInDialog: true`, it MUST be documented in `docs/get-started/configuration.md`. Ensure `requiresRestart` is correctly set.
* **Logging**: Use `debugLogger` for rethrown errors to avoid duplicate logging.
* **Keyboard Shortcuts**: Define all new keyboard shortcuts in `packages/cli/src/config/keyBindings.ts` and document them in `docs/cli/keyboard-shortcuts.md`. Be careful of keybindings that require the `Meta` key, as only certain meta key shortcuts are supported on Mac. Avoid function keys and shortcuts commonly bound in VSCode.
- **Settings**: Use settings for user-configurable options rather than adding
new command line arguments. Add new settings to
`packages/cli/src/config/settingsSchema.ts`. If a setting has
`showInDialog: true`, it MUST be documented in
`docs/get-started/configuration.md`. Ensure `requiresRestart` is correctly
set.
- **Logging**: Use `debugLogger` for rethrown errors to avoid duplicate logging.
- **Keyboard Shortcuts**: Define all new keyboard shortcuts in
`packages/cli/src/config/keyBindings.ts` and document them in
`docs/cli/keyboard-shortcuts.md`. Be careful of keybindings that require the
`Meta` key, as only certain meta key shortcuts are supported on Mac. Avoid
function keys and shortcuts commonly bound in VSCode.
## TypeScript Best Practices
* Use `checkExhaustive` in the `default` clause of `switch` statements to ensure all cases are handled.
* Avoid using the non-null assertion operator (`!`) unless absolutely necessary.
* **STRICT TYPING**: Strictly forbid `any` and `unknown` in both CLI and Core packages. `unknown` is only allowed if it is immediately narrowed using type guards or Zod validation.
* NEVER disable `@typescript-eslint/no-floating-promises`.
* Avoid making types nullable unless strictly necessary, as it hurts readability.
- Use `checkExhaustive` in the `default` clause of `switch` statements to ensure
all cases are handled.
- Avoid using the non-null assertion operator (`!`) unless absolutely necessary.
- **STRICT TYPING**: Strictly forbid `any` and `unknown` in both CLI and Core
packages. `unknown` is only allowed if it is immediately narrowed using type
guards or Zod validation.
- NEVER disable `@typescript-eslint/no-floating-promises`.
- Avoid making types nullable unless strictly necessary, as it hurts
readability.
## TUI Best Practices
* **Terminal Compatibility**: Consider how changes might behave differently across terminals (e.g., VSCode terminal, SSH, Kitty, default Mac terminal, iTerm2, Windows terminal). If modifying keyboard handling, integrate deeply with existing files like `KeypressContext.tsx` and `terminalCapabilityManager.ts`.
* **iTerm**: Be aware that `ITERM_SESSION_ID` may be present when users run VSCode from within iTerm, even if the terminal is not iTerm.
- **Terminal Compatibility**: Consider how changes might behave differently
across terminals (e.g., VSCode terminal, SSH, Kitty, default Mac terminal,
iTerm2, Windows terminal). If modifying keyboard handling, integrate deeply
with existing files like `KeypressContext.tsx` and
`terminalCapabilityManager.ts`.
- **iTerm**: Be aware that `ITERM_SESSION_ID` may be present when users run
VSCode from within iTerm, even if the terminal is not iTerm.
## Code Cleanup
* **Refactoring**: Actively clean up code duplication, technical debt, and boilerplate ("AI Slop") when working in the codebase.
* **Prompts**: Be aware that changes can impact the prompts sent to Gemini CLI and affect overall quality.
- **Refactoring**: Actively clean up code duplication, technical debt, and
boilerplate ("AI Slop") when working in the codebase.
- **Prompts**: Be aware that changes can impact the prompts sent to Gemini CLI
and affect overall quality.
+4
View File
@@ -23,6 +23,8 @@ To standardize the process of updating changelog files (`latest.md`,
## Guidelines for `latest.md` and `preview.md` Highlights
- Aim for **3-5 key highlight points**.
- Each highlight point must start with a bold-typed title that summarizes the
change (e.g., `**New Feature:** A brief description...`).
- **Prioritize** summarizing new features over other changes like bug fixes or
chores.
- **Avoid** mentioning features that are "experimental" or "in preview" in
@@ -65,6 +67,8 @@ detailed **highlights** section for the release-specific page.
1. **Create the Announcement for `index.md`**:
- Generate a concise announcement summarizing the most important changes.
Each announcement entry must start with a bold-typed title that
summarizes the change.
- **Important**: The format for this announcement is unique. You **must**
use the existing announcements in `docs/changelogs/index.md` and the
example within
+3 -2
View File
@@ -32,6 +32,7 @@ jobs:
with:
# The user-level skills need to be available to the workflow
fetch-depth: 0
ref: 'main'
- name: 'Set up Node.js'
uses: 'actions/setup-node@v4'
@@ -42,7 +43,6 @@ jobs:
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"
@@ -50,10 +50,11 @@ jobs:
# Use a heredoc to preserve multiline release body
echo 'RAW_CHANGELOG<<EOF' >> "$GITHUB_OUTPUT"
printf "%s\n" "${BODY}" >> "$GITHUB_OUTPUT"
printf "%s\n" "$BODY" >> "$GITHUB_OUTPUT"
echo 'EOF' >> "$GITHUB_OUTPUT"
env:
GH_TOKEN: '${{ secrets.GEMINI_CLI_ROBOT_GITHUB_PAT }}'
BODY: '${{ github.event.inputs.body || github.event.release.body }}'
- name: 'Generate Changelog with Gemini'
uses: 'google-github-actions/run-gemini-cli@a3bf79042542528e91937b3a3a6fbc4967ee3c31' # ratchet:google-github-actions/run-gemini-cli@v0
+1
View File
@@ -46,6 +46,7 @@ packages/*/coverage/
# Generated files
packages/cli/src/generated/
packages/core/src/generated/
packages/devtools/src/_client-assets.ts
.integration-tests/
packages/vscode-ide-companion/*.vsix
packages/cli/download-ripgrep*/
+1 -1
View File
@@ -546,7 +546,7 @@ Before submitting your documentation pull request, please:
If you have questions about contributing documentation:
- Check our [FAQ](/docs/resources/faq).
- Check our [FAQ](/docs/faq.md).
- Review existing documentation for examples.
- Open [an issue](https://github.com/google-gemini/gemini-cli/issues) to discuss
your proposed changes.
+7 -1
View File
@@ -47,7 +47,13 @@ powerful tool for developers.
be relative to the workspace root, e.g.,
`-w @google/gemini-cli-core -- src/routing/modelRouterService.test.ts`)
- **Full Validation:** `npm run preflight` (Heaviest check; runs clean, install,
build, lint, type check, and tests. Recommended before submitting PRs.)
build, lint, type check, and tests. Recommended before submitting PRs. Due to
its long runtime, only run this at the very end of a code implementation task.
If it fails, use faster, targeted commands (e.g., `npm run test`,
`npm run lint`, or workspace-specific tests) to iterate on fixes before
re-running `preflight`. For simple, non-code changes like documentation or
prompting updates, skip `preflight` at the end of the task and wait for PR
validation.)
- **Individual Checks:** `npm run lint` / `npm run format` / `npm run typecheck`
## Development Conventions
+21 -3
View File
@@ -18,7 +18,26 @@ on GitHub.
| [Preview](preview.md) | Experimental features ready for early feedback. |
| [Stable](latest.md) | Stable, recommended for general use. |
## Announcements: v0.28.0 - 2026-02-03
## Announcements: v0.29.0 - 2026-02-17
- **Plan Mode:** A new comprehensive planning capability with `/plan`,
`enter_plan_mode` tool, and dedicated documentation
([#17698](https://github.com/google-gemini/gemini-cli/pull/17698) by @Adib234,
[#18324](https://github.com/google-gemini/gemini-cli/pull/18324) by @jerop).
- **Gemini 3 Default:** We've removed the preview flag and enabled Gemini 3 by
default for all users
([#18414](https://github.com/google-gemini/gemini-cli/pull/18414) by
@sehoon38).
- **Extension Exploration:** New UI and settings to explore and manage
extensions more easily
([#18686](https://github.com/google-gemini/gemini-cli/pull/18686) by
@sripasg).
- **Admin Control:** Administrators can now allowlist specific MCP server
configurations
([#18311](https://github.com/google-gemini/gemini-cli/pull/18311) by
@skeshive).
## Announcements: v0.28.0 - 2026-02-10
- **Slash Command:** We've added a new `/prompt-suggest` slash command to help
you generate prompt suggestions
@@ -276,8 +295,7 @@ on GitHub.
- **Experimental permission improvements:** We are now experimenting with a new
policy engine in Gemini CLI. This allows users and administrators to create
fine-grained policy for tool calls. Currently behind a flag. See
[policy engine documentation](../reference/policy-engine.md) for more
information.
[policy engine documentation](../core/policy-engine.md) for more information.
- Blog:
[https://allen.hutchison.org/2025/11/26/the-guardrails-of-autonomy/](https://allen.hutchison.org/2025/11/26/the-guardrails-of-autonomy/)
- **Gemini 3 support for paid:** Gemini 3 support has been rolled out to all API
+359 -293
View File
@@ -1,6 +1,6 @@
# Latest stable release: v0.28.0
# Latest stable release: v0.29.0
Released: February 10, 2026
Released: February 17, 2026
For most users, our latest stable release is the recommended release. Install
the latest stable version with:
@@ -11,305 +11,371 @@ npm install -g @google/gemini-cli
## Highlights
- **Commands & UX Enhancements:** Introduced `/prompt-suggest` command,
alongside updated undo/redo keybindings and automatic theme switching.
- **Expanded IDE Support:** Now offering compatibility with Positron IDE,
expanding integration options for developers.
- **Enhanced Security & Authentication:** Implemented interactive and
non-interactive OAuth consent, improving both security and diagnostic
capabilities for bug reports.
- **Advanced Planning & Agent Tools:** Integrated a generic Checklist component
for structured task management and evolved subagent capabilities with dynamic
policy registration.
- **Improved Core Stability & Reliability:** Resolved critical environment
loading, authentication, and session management issues, ensuring a more robust
experience.
- **Background Shell Commands:** Enabled the execution of shell commands in the
background for increased workflow efficiency.
- **Plan Mode:** Introduce a dedicated "Plan Mode" to help you architect complex
changes before implementation. Use `/plan` to get started.
- **Gemini 3 by Default:** Gemini 3 is now the default model family, bringing
improved performance and reasoning capabilities to all users without needing a
feature flag.
- **Extension Discovery:** Easily discover and install extensions with the new
exploration features and registry client.
- **Enhanced Admin Controls:** New administrative capabilities allow for
allowlisting MCP server configurations, giving organizations more control over
available tools.
- **Sub-agent Improvements:** Sub-agents have been transitioned to a new format
with improved definitions and system prompts for better reliability.
## 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
- fix: remove `ask_user` tool from non-interactive modes by @jackwotherspoon in
[#18154](https://github.com/google-gemini/gemini-cli/pull/18154)
- fix(cli): allow restricted .env loading in untrusted sandboxed folders by
@galz10 in [#17806](https://github.com/google-gemini/gemini-cli/pull/17806)
- Encourage agent to utilize ecosystem tools to perform work by @gundermanc in
[#17881](https://github.com/google-gemini/gemini-cli/pull/17881)
- feat(plan): unify workflow location in system prompt to optimize caching by
@jerop in [#18258](https://github.com/google-gemini/gemini-cli/pull/18258)
- feat(core): enable getUserTierName in config by @sehoon38 in
[#18265](https://github.com/google-gemini/gemini-cli/pull/18265)
- feat(core): add default execution limits for subagents by @abhipatel12 in
[#18274](https://github.com/google-gemini/gemini-cli/pull/18274)
- Fix issue where agent gets stuck at interactive commands. by @gundermanc in
[#18272](https://github.com/google-gemini/gemini-cli/pull/18272)
- chore(release): bump version to 0.29.0-nightly.20260203.71f46f116 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](https://github.com/google-gemini/gemini-cli/pull/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
[#18243](https://github.com/google-gemini/gemini-cli/pull/18243)
- feat(core): remove hardcoded policy bypass for local subagents by @abhipatel12
in [#18153](https://github.com/google-gemini/gemini-cli/pull/18153)
- feat(plan): implement `plan` slash command by @Adib234 in
[#17698](https://github.com/google-gemini/gemini-cli/pull/17698)
- feat: increase `ask_user` label limit to 16 characters by @jackwotherspoon in
[#18320](https://github.com/google-gemini/gemini-cli/pull/18320)
- Add information about the agent skills lifecycle and clarify docs-writer skill
metadata. by @g-samroberts in
[#18234](https://github.com/google-gemini/gemini-cli/pull/18234)
- feat(core): add `enter_plan_mode` tool by @jerop in
[#18324](https://github.com/google-gemini/gemini-cli/pull/18324)
- Stop showing an error message in `/plan` by @Adib234 in
[#18333](https://github.com/google-gemini/gemini-cli/pull/18333)
- fix(hooks): remove unnecessary logging for hook registration by @abhipatel12
in [#18332](https://github.com/google-gemini/gemini-cli/pull/18332)
- fix(mcp): ensure MCP transport is closed to prevent memory leaks by
@cbcoutinho in
[#18054](https://github.com/google-gemini/gemini-cli/pull/18054)
- feat(skills): implement linking for agent skills by @MushuEE in
[#18295](https://github.com/google-gemini/gemini-cli/pull/18295)
- Changelogs for 0.27.0 and 0.28.0-preview0 by @g-samroberts in
[#18336](https://github.com/google-gemini/gemini-cli/pull/18336)
- chore: correct docs as skills and hooks are stable by @jackwotherspoon in
[#18358](https://github.com/google-gemini/gemini-cli/pull/18358)
- feat(admin): Implement admin allowlist for MCP server configurations by
@skeshive in [#18311](https://github.com/google-gemini/gemini-cli/pull/18311)
- fix(core): add retry logic for transient SSL/TLS errors (#17318) by @ppgranger
in [#18310](https://github.com/google-gemini/gemini-cli/pull/18310)
- Add support for /extensions config command by @chrstnb in
[#17895](https://github.com/google-gemini/gemini-cli/pull/17895)
- fix(core): handle non-compliant mcpbridge responses from Xcode 26.3 by
@peterfriese in
[#18376](https://github.com/google-gemini/gemini-cli/pull/18376)
- feat(cli): Add W, B, E Vim motions and operator support by @ademuri in
[#16209](https://github.com/google-gemini/gemini-cli/pull/16209)
- fix: Windows Specific Agent Quality & System Prompt by @scidomino in
[#18351](https://github.com/google-gemini/gemini-cli/pull/18351)
- feat(plan): support `replace` tool in plan mode to edit plans by @jerop in
[#18379](https://github.com/google-gemini/gemini-cli/pull/18379)
- Improving memory tool instructions and eval testing by @alisa-alisa in
[#18091](https://github.com/google-gemini/gemini-cli/pull/18091)
- fix(cli): color extension link success message green by @MushuEE in
[#18386](https://github.com/google-gemini/gemini-cli/pull/18386)
- undo by @jacob314 in
[#18147](https://github.com/google-gemini/gemini-cli/pull/18147)
- feat(plan): add guidance on iterating on approved plans vs creating new plans
by @jerop in [#18346](https://github.com/google-gemini/gemini-cli/pull/18346)
- feat(plan): fix invalid tool calls in plan mode by @Adib234 in
[#18352](https://github.com/google-gemini/gemini-cli/pull/18352)
- feat(plan): integrate planning artifacts and tools into primary workflows by
@jerop in [#18375](https://github.com/google-gemini/gemini-cli/pull/18375)
- Fix permission check by @scidomino in
[#18395](https://github.com/google-gemini/gemini-cli/pull/18395)
- ux(polish) autocomplete in the input prompt by @jacob314 in
[#18181](https://github.com/google-gemini/gemini-cli/pull/18181)
- fix: resolve infinite loop when using 'Modify with external editor' by
@ppgranger in [#17453](https://github.com/google-gemini/gemini-cli/pull/17453)
- feat: expand verify-release to macOS and Windows by @yunaseoul in
[#18145](https://github.com/google-gemini/gemini-cli/pull/18145)
- feat(plan): implement support for MCP servers in Plan mode by @Adib234 in
[#18229](https://github.com/google-gemini/gemini-cli/pull/18229)
- chore: update folder trust error messaging by @galz10 in
[#18402](https://github.com/google-gemini/gemini-cli/pull/18402)
- feat(plan): create a metric for execution of plans generated in plan mode by
@Adib234 in [#18236](https://github.com/google-gemini/gemini-cli/pull/18236)
- perf(ui): optimize stripUnsafeCharacters with regex by @gsquared94 in
[#18413](https://github.com/google-gemini/gemini-cli/pull/18413)
- feat(context): implement observation masking for tool outputs by @abhipatel12
in [#18389](https://github.com/google-gemini/gemini-cli/pull/18389)
- feat(core,cli): implement session-linked tool output storage and cleanup 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](https://github.com/google-gemini/gemini-cli/pull/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
[#18416](https://github.com/google-gemini/gemini-cli/pull/18416)
- Shorten temp directory by @joshualitt in
[#17901](https://github.com/google-gemini/gemini-cli/pull/17901)
- feat(plan): add behavioral evals for plan mode by @jerop in
[#18437](https://github.com/google-gemini/gemini-cli/pull/18437)
- Add extension registry client by @chrstnb in
[#18396](https://github.com/google-gemini/gemini-cli/pull/18396)
- Enable extension config by default by @chrstnb in
[#18447](https://github.com/google-gemini/gemini-cli/pull/18447)
- Automatically generate change logs on release by @g-samroberts in
[#18401](https://github.com/google-gemini/gemini-cli/pull/18401)
- Remove previewFeatures and default to Gemini 3 by @sehoon38 in
[#18414](https://github.com/google-gemini/gemini-cli/pull/18414)
- feat(admin): apply MCP allowlist to extensions & gemini mcp list command by
@skeshive in [#18442](https://github.com/google-gemini/gemini-cli/pull/18442)
- fix(cli): improve focus navigation for interactive and background shells by
@galz10 in [#18343](https://github.com/google-gemini/gemini-cli/pull/18343)
- Add shortcuts hint and panel for discoverability by @LyalinDotCom in
[#18035](https://github.com/google-gemini/gemini-cli/pull/18035)
- fix(config): treat system settings as read-only during migration and warn user
by @spencer426 in
[#18277](https://github.com/google-gemini/gemini-cli/pull/18277)
- feat(plan): add positive test case and update eval stability policy by @jerop
in [#18457](https://github.com/google-gemini/gemini-cli/pull/18457)
- fix- windows: add shell: true for spawnSync to fix EINVAL with .cmd editors by
@zackoch in [#18408](https://github.com/google-gemini/gemini-cli/pull/18408)
- bug(core): Fix bug when saving plans. by @joshualitt in
[#18465](https://github.com/google-gemini/gemini-cli/pull/18465)
- Refactor atCommandProcessor by @scidomino in
[#18461](https://github.com/google-gemini/gemini-cli/pull/18461)
- feat(core): implement persistence and resumption for masked tool outputs 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
[#18451](https://github.com/google-gemini/gemini-cli/pull/18451)
- refactor: simplify tool output truncation to single config by @SandyTao520 in
[#18446](https://github.com/google-gemini/gemini-cli/pull/18446)
- bug(core): Ensure storage is initialized early, even if config is not. by
@joshualitt in
[#18471](https://github.com/google-gemini/gemini-cli/pull/18471)
- chore: Update build-and-start script to support argument forwarding 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
[#18241](https://github.com/google-gemini/gemini-cli/pull/18241)
- fix(core): prevent subagent bypass in plan mode by @jerop in
[#18484](https://github.com/google-gemini/gemini-cli/pull/18484)
- feat(cli): add WebSocket-based network logging and streaming chunk support by
@SandyTao520 in
[#18383](https://github.com/google-gemini/gemini-cli/pull/18383)
- feat(cli): update approval modes UI by @jerop in
[#18476](https://github.com/google-gemini/gemini-cli/pull/18476)
- fix(cli): reload skills and agents on extension restart by @NTaylorMullen in
[#18411](https://github.com/google-gemini/gemini-cli/pull/18411)
- fix(core): expand excludeTools with legacy aliases for renamed tools by
@SandyTao520 in
[#18498](https://github.com/google-gemini/gemini-cli/pull/18498)
- feat(core): overhaul system prompt for rigor, integrity, and intent alignment
by @NTaylorMullen in
[#17263](https://github.com/google-gemini/gemini-cli/pull/17263)
- Patch for generate changelog docs yaml file by @g-samroberts in
[#18496](https://github.com/google-gemini/gemini-cli/pull/18496)
- Code review fixes for show question mark pr. by @jacob314 in
[#18480](https://github.com/google-gemini/gemini-cli/pull/18480)
- fix(cli): add SS3 Shift+Tab support for Windows terminals by @ThanhNguyxn in
[#18187](https://github.com/google-gemini/gemini-cli/pull/18187)
- chore: remove redundant planning prompt from final shell by @jerop in
[#18528](https://github.com/google-gemini/gemini-cli/pull/18528)
- docs: require pr-creator skill for PR generation by @NTaylorMullen in
[#18536](https://github.com/google-gemini/gemini-cli/pull/18536)
- chore: update colors for ask_user dialog by @jackwotherspoon in
[#18543](https://github.com/google-gemini/gemini-cli/pull/18543)
- feat(core): exempt high-signal tools from output masking by @abhipatel12 in
[#18545](https://github.com/google-gemini/gemini-cli/pull/18545)
- refactor(core): remove memory tool instructions from Gemini 3 prompt 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
[#18559](https://github.com/google-gemini/gemini-cli/pull/18559)
- chore: remove feedback instruction from system prompt by @NTaylorMullen in
[#18560](https://github.com/google-gemini/gemini-cli/pull/18560)
- feat(context): add remote configuration for tool output masking thresholds by
@abhipatel12 in
[#18553](https://github.com/google-gemini/gemini-cli/pull/18553)
- feat(core): pause agent timeout budget while waiting for tool confirmation by
@abhipatel12 in
[#18415](https://github.com/google-gemini/gemini-cli/pull/18415)
- refactor(config): remove experimental.enableEventDrivenScheduler setting by
@abhipatel12 in
[#17924](https://github.com/google-gemini/gemini-cli/pull/17924)
- feat(cli): truncate shell output in UI history and improve active shell
display by @jwhelangoog in
[#17438](https://github.com/google-gemini/gemini-cli/pull/17438)
- refactor(cli): switch useToolScheduler to event-driven engine by @abhipatel12
in [#18565](https://github.com/google-gemini/gemini-cli/pull/18565)
- fix(core): correct escaped interpolation in system prompt by @NTaylorMullen in
[#18557](https://github.com/google-gemini/gemini-cli/pull/18557)
- propagate abortSignal by @scidomino in
[#18477](https://github.com/google-gemini/gemini-cli/pull/18477)
- feat(core): conditionally include ctrl+f prompt based on interactive shell
setting by @NTaylorMullen in
[#18561](https://github.com/google-gemini/gemini-cli/pull/18561)
- fix(core): ensure `enter_plan_mode` tool registration respects
`experimental.plan` by @jerop in
[#18587](https://github.com/google-gemini/gemini-cli/pull/18587)
- feat(core): transition sub-agents to XML format and improve definitions by
@NTaylorMullen in
[#18555](https://github.com/google-gemini/gemini-cli/pull/18555)
- docs: Add Plan Mode documentation by @jerop in
[#18582](https://github.com/google-gemini/gemini-cli/pull/18582)
- chore: strengthen validation guidance in system prompt by @NTaylorMullen in
[#18544](https://github.com/google-gemini/gemini-cli/pull/18544)
- Fix newline insertion bug in replace tool by @werdnum in
[#18595](https://github.com/google-gemini/gemini-cli/pull/18595)
- fix(evals): update save_memory evals and simplify tool description by
@NTaylorMullen in
[#18610](https://github.com/google-gemini/gemini-cli/pull/18610)
- chore(evals): update validation_fidelity_pre_existing_errors to USUALLY_PASSES
by @NTaylorMullen in
[#18617](https://github.com/google-gemini/gemini-cli/pull/18617)
- fix: shorten tool call IDs and fix duplicate tool name in truncated output
filenames by @SandyTao520 in
[#18600](https://github.com/google-gemini/gemini-cli/pull/18600)
- feat(cli): implement atomic writes and safety checks for trusted folders by
@galz10 in [#18406](https://github.com/google-gemini/gemini-cli/pull/18406)
- Remove relative docs links by @chrstnb in
[#18650](https://github.com/google-gemini/gemini-cli/pull/18650)
- docs: add legacy snippets convention to GEMINI.md by @NTaylorMullen in
[#18597](https://github.com/google-gemini/gemini-cli/pull/18597)
- fix(chore): Support linting for cjs by @aswinashok44 in
[#18639](https://github.com/google-gemini/gemini-cli/pull/18639)
- feat: move shell efficiency guidelines to tool description by @NTaylorMullen
in [#18614](https://github.com/google-gemini/gemini-cli/pull/18614)
- Added "" as default value, since getText() used to expect a string only and
thus crashed when undefined... Fixes #18076 by @019-Abhi in
[#18099](https://github.com/google-gemini/gemini-cli/pull/18099)
- Allow @-includes outside of workspaces (with permission) by @scidomino in
[#18470](https://github.com/google-gemini/gemini-cli/pull/18470)
- chore: make `ask_user` header description more clear by @jackwotherspoon in
[#18657](https://github.com/google-gemini/gemini-cli/pull/18657)
- refactor(core): model-dependent tool definitions by @aishaneeshah in
[#18563](https://github.com/google-gemini/gemini-cli/pull/18563)
- Harded code assist converter. by @jacob314 in
[#18656](https://github.com/google-gemini/gemini-cli/pull/18656)
- bug(core): Fix minor bug in migration logic. by @joshualitt in
[#18661](https://github.com/google-gemini/gemini-cli/pull/18661)
- feat: enable plan mode experiment in settings by @jerop in
[#18636](https://github.com/google-gemini/gemini-cli/pull/18636)
- refactor: push isValidPath() into parsePastedPaths() by @scidomino in
[#18664](https://github.com/google-gemini/gemini-cli/pull/18664)
- fix(cli): correct 'esc to cancel' position and restore duration display by
@NTaylorMullen in
[#18534](https://github.com/google-gemini/gemini-cli/pull/18534)
- feat(cli): add DevTools integration with gemini-cli-devtools by @SandyTao520
in [#18648](https://github.com/google-gemini/gemini-cli/pull/18648)
- chore: remove unused exports and redundant hook files by @SandyTao520 in
[#18681](https://github.com/google-gemini/gemini-cli/pull/18681)
- Fix number of lines being reported in rewind confirmation dialog by @Adib234
in [#18675](https://github.com/google-gemini/gemini-cli/pull/18675)
- feat(cli): disable folder trust in headless mode by @galz10 in
[#18407](https://github.com/google-gemini/gemini-cli/pull/18407)
- Disallow unsafe type assertions by @gundermanc in
[#18688](https://github.com/google-gemini/gemini-cli/pull/18688)
- Change event type for release by @g-samroberts in
[#18693](https://github.com/google-gemini/gemini-cli/pull/18693)
- feat: handle multiple dynamic context filenames in system prompt by
@NTaylorMullen in
[#18598](https://github.com/google-gemini/gemini-cli/pull/18598)
- Properly parse at-commands with narrow non-breaking spaces by @scidomino in
[#18677](https://github.com/google-gemini/gemini-cli/pull/18677)
- refactor(core): centralize core tool definitions and support model-specific
schemas by @aishaneeshah in
[#18662](https://github.com/google-gemini/gemini-cli/pull/18662)
- feat(core): Render memory hierarchically in context. by @joshualitt in
[#18350](https://github.com/google-gemini/gemini-cli/pull/18350)
- feat: Ctrl+O to expand paste placeholder by @jackwotherspoon in
[#18103](https://github.com/google-gemini/gemini-cli/pull/18103)
- fix(cli): Improve header spacing by @NTaylorMullen in
[#18531](https://github.com/google-gemini/gemini-cli/pull/18531)
- Feature/quota visibility 16795 by @spencer426 in
[#18203](https://github.com/google-gemini/gemini-cli/pull/18203)
- Inline thinking bubbles with summary/full modes by @LyalinDotCom in
[#18033](https://github.com/google-gemini/gemini-cli/pull/18033)
- docs: remove TOC marker from Plan Mode header by @jerop in
[#18678](https://github.com/google-gemini/gemini-cli/pull/18678)
- fix(ui): remove redundant newlines in Gemini messages by @NTaylorMullen in
[#18538](https://github.com/google-gemini/gemini-cli/pull/18538)
- test(cli): fix AppContainer act() warnings and improve waitFor resilience by
@NTaylorMullen in
[#18676](https://github.com/google-gemini/gemini-cli/pull/18676)
- refactor(core): refine Security & System Integrity section in system prompt by
@NTaylorMullen in
[#18601](https://github.com/google-gemini/gemini-cli/pull/18601)
- Fix layout rounding. by @gundermanc in
[#18667](https://github.com/google-gemini/gemini-cli/pull/18667)
- docs(skills): enhance pr-creator safety and interactivity by @NTaylorMullen in
[#18616](https://github.com/google-gemini/gemini-cli/pull/18616)
- test(core): remove hardcoded model from TestRig by @NTaylorMullen in
[#18710](https://github.com/google-gemini/gemini-cli/pull/18710)
- feat(core): optimize sub-agents system prompt intro by @NTaylorMullen in
[#18608](https://github.com/google-gemini/gemini-cli/pull/18608)
- feat(cli): update approval mode labels and shortcuts per latest UX spec by
@jerop in [#18698](https://github.com/google-gemini/gemini-cli/pull/18698)
- fix(plan): update persistent approval mode setting by @Adib234 in
[#18638](https://github.com/google-gemini/gemini-cli/pull/18638)
- fix: move toasts location to left side by @jackwotherspoon in
[#18705](https://github.com/google-gemini/gemini-cli/pull/18705)
- feat(routing): restrict numerical routing to Gemini 3 family by @mattKorwel in
[#18478](https://github.com/google-gemini/gemini-cli/pull/18478)
- fix(ide): fix ide nudge setting by @skeshive in
[#18733](https://github.com/google-gemini/gemini-cli/pull/18733)
- fix(core): standardize tool formatting in system prompts by @NTaylorMullen in
[#18615](https://github.com/google-gemini/gemini-cli/pull/18615)
- chore: consolidate to green in ask user dialog by @jackwotherspoon in
[#18734](https://github.com/google-gemini/gemini-cli/pull/18734)
- feat: add `extensionsExplore` setting to enable extensions explore UI. by
@sripasg in [#18686](https://github.com/google-gemini/gemini-cli/pull/18686)
- feat(cli): defer devtools startup and integrate with F12 by @SandyTao520 in
[#18695](https://github.com/google-gemini/gemini-cli/pull/18695)
- ui: update & subdue footer colors and animate progress indicator by
@keithguerin in
[#18570](https://github.com/google-gemini/gemini-cli/pull/18570)
- test: add model-specific snapshots for coreTools by @aishaneeshah in
[#18707](https://github.com/google-gemini/gemini-cli/pull/18707)
- ci: shard windows tests and fix event listener leaks by @NTaylorMullen in
[#18670](https://github.com/google-gemini/gemini-cli/pull/18670)
- fix: allow `ask_user` tool in yolo mode by @jackwotherspoon in
[#18541](https://github.com/google-gemini/gemini-cli/pull/18541)
- feat: redact disabled tools from system prompt (#13597) by @NTaylorMullen in
[#18613](https://github.com/google-gemini/gemini-cli/pull/18613)
- Update Gemini.md to use the curent year on creating new files by @sehoon38 in
[#18460](https://github.com/google-gemini/gemini-cli/pull/18460)
- Code review cleanup for thinking display by @jacob314 in
[#18720](https://github.com/google-gemini/gemini-cli/pull/18720)
- fix(cli): hide scrollbars when in alternate buffer copy mode by @werdnum in
[#18354](https://github.com/google-gemini/gemini-cli/pull/18354)
- Fix issues with rip grep by @gundermanc in
[#18756](https://github.com/google-gemini/gemini-cli/pull/18756)
- fix(cli): fix history navigation regression after prompt autocomplete by
@sehoon38 in [#18752](https://github.com/google-gemini/gemini-cli/pull/18752)
- chore: cleanup unused and add unlisted dependencies in packages/cli 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)
[#18749](https://github.com/google-gemini/gemini-cli/pull/18749)
- Fix issue where Gemini CLI creates tests in a new file by @gundermanc in
[#18409](https://github.com/google-gemini/gemini-cli/pull/18409)
- feat(telemetry): Ensure experiment IDs are included in OpenTelemetry logs by
@kevin-ramdass in
[#18747](https://github.com/google-gemini/gemini-cli/pull/18747)
- fix(patch): cherry-pick e9a9474 to release/v0.29.0-preview.0-pr-18840 to patch
version v0.29.0-preview.0 and create version 0.29.0-preview.1 by
@gemini-cli-robot in
[#18841](https://github.com/google-gemini/gemini-cli/pull/18841)
- fix(patch): cherry-pick 08e8eea to release/v0.29.0-preview.1-pr-18855 to patch
version v0.29.0-preview.1 and create version 0.29.0-preview.2 by
@gemini-cli-robot in
[#18905](https://github.com/google-gemini/gemini-cli/pull/18905)
- fix(patch): cherry-pick d0c6a56 to release/v0.29.0-preview.2-pr-18976 to patch
version v0.29.0-preview.2 and create version 0.29.0-preview.3 by
@gemini-cli-robot in
[#19023](https://github.com/google-gemini/gemini-cli/pull/19023)
- fix(patch): cherry-pick e5ff202 to release/v0.29.0-preview.3-pr-19254 to patch
version v0.29.0-preview.3 and create version 0.29.0-preview.4 by
@gemini-cli-robot in
[#19264](https://github.com/google-gemini/gemini-cli/pull/19264)
- fix(patch): cherry-pick 9590a09 to release/v0.29.0-preview.4-pr-18771 to patch
version v0.29.0-preview.4 and create version 0.29.0-preview.5 by
@gemini-cli-robot in
[#19274](https://github.com/google-gemini/gemini-cli/pull/19274)
**Full Changelog**:
https://github.com/google-gemini/gemini-cli/compare/v0.27.0...v0.28.0
https://github.com/google-gemini/gemini-cli/compare/v0.28.2...v0.29.0
+296 -349
View File
@@ -1,6 +1,6 @@
# Preview release: Release v0.29.0-preview.0
# Preview release: v0.30.0-preview.1
Released: February 10, 2026
Released: February 19, 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,355 +13,302 @@ npm install -g @google/gemini-cli@preview
## Highlights
- **Plan Mode Enhancements**: Significant updates to Plan Mode, including new
commands, support for MCP servers, integration of planning artifacts, and
improved iteration guidance.
- **Core Agent Improvements**: Enhancements to the core agent, including better
system prompt rigor, improved subagent definitions, and enhanced tool
execution limits.
- **CLI UX/UI Updates**: Various UI and UX improvements, such as autocomplete in
the input prompt, updated approval mode labels, DevTools integration, and
improved header spacing.
- **Tooling & Extension Updates**: Improvements to existing tools like
`ask_user` and `grep_search`, and new features for extension management.
- **Bug Fixes**: Numerous bug fixes across the CLI and core, addressing issues
with interactive commands, memory leaks, permission checks, and more.
- **Context and Tool Output Management**: Features for observation masking for
tool outputs, session-linked tool output storage, and persistence for masked
tool outputs.
- **Initial SDK Package:** Introduced the initial SDK package with support for
custom skills and dynamic system instructions.
- **Refined Plan Mode:** Refined Plan Mode with support for enabling skills,
improved agentic execution, and project exploration without planning.
- **Enhanced CLI UI:** Enhanced CLI UI with a new clean UI toggle, minimal-mode
bleed-through, and support for Ctrl-Z suspension.
- **`--policy` flag:** Added the `--policy` flag to support user-defined
policies.
- **New Themes:** Added Solarized Dark and Solarized Light themes.
## What's Changed
- fix: remove ask_user tool from non-interactive modes by jackwotherspoon in
[#18154](https://github.com/google-gemini/gemini-cli/pull/18154)
- fix(cli): allow restricted .env loading in untrusted sandboxed folders by
galz10 in [#17806](https://github.com/google-gemini/gemini-cli/pull/17806)
- Encourage agent to utilize ecosystem tools to perform work by gundermanc in
[#17881](https://github.com/google-gemini/gemini-cli/pull/17881)
- feat(plan): unify workflow location in system prompt to optimize caching by
jerop in [#18258](https://github.com/google-gemini/gemini-cli/pull/18258)
- feat(core): enable getUserTierName in config by sehoon38 in
[#18265](https://github.com/google-gemini/gemini-cli/pull/18265)
- feat(core): add default execution limits for subagents by abhipatel12 in
[#18274](https://github.com/google-gemini/gemini-cli/pull/18274)
- Fix issue where agent gets stuck at interactive commands. by gundermanc in
[#18272](https://github.com/google-gemini/gemini-cli/pull/18272)
- chore(release): bump version to 0.29.0-nightly.20260203.71f46f116 by
gemini-cli-robot in
[#18243](https://github.com/google-gemini/gemini-cli/pull/18243)
- feat(core): remove hardcoded policy bypass for local subagents by abhipatel12
in [#18153](https://github.com/google-gemini/gemini-cli/pull/18153)
- feat(plan): implement plan slash command by Adib234 in
[#17698](https://github.com/google-gemini/gemini-cli/pull/17698)
- feat: increase ask_user label limit to 16 characters by jackwotherspoon in
[#18320](https://github.com/google-gemini/gemini-cli/pull/18320)
- Add information about the agent skills lifecycle and clarify docs-writer skill
metadata. by g-samroberts in
[#18234](https://github.com/google-gemini/gemini-cli/pull/18234)
- feat(core): add enter_plan_mode tool by jerop in
[#18324](https://github.com/google-gemini/gemini-cli/pull/18324)
- Stop showing an error message in /plan by Adib234 in
[#18333](https://github.com/google-gemini/gemini-cli/pull/18333)
- fix(hooks): remove unnecessary logging for hook registration by abhipatel12 in
[#18332](https://github.com/google-gemini/gemini-cli/pull/18332)
- fix(mcp): ensure MCP transport is closed to prevent memory leaks by cbcoutinho
in [#18054](https://github.com/google-gemini/gemini-cli/pull/18054)
- feat(skills): implement linking for agent skills by MushuEE in
[#18295](https://github.com/google-gemini/gemini-cli/pull/18295)
- Changelogs for 0.27.0 and 0.28.0-preview0 by g-samroberts in
[#18336](https://github.com/google-gemini/gemini-cli/pull/18336)
- chore: correct docs as skills and hooks are stable by jackwotherspoon in
[#18358](https://github.com/google-gemini/gemini-cli/pull/18358)
- feat(admin): Implement admin allowlist for MCP server configurations by
skeshive in [#18311](https://github.com/google-gemini/gemini-cli/pull/18311)
- fix(core): add retry logic for transient SSL/TLS errors
([#17318](https://github.com/google-gemini/gemini-cli/pull/17318)) by
ppgranger in [#18310](https://github.com/google-gemini/gemini-cli/pull/18310)
- Add support for /extensions config command by chrstnb in
[#17895](https://github.com/google-gemini/gemini-cli/pull/17895)
- fix(core): handle non-compliant mcpbridge responses from Xcode 26.3 by
peterfriese in
[#18376](https://github.com/google-gemini/gemini-cli/pull/18376)
- feat(cli): Add W, B, E Vim motions and operator support by ademuri in
[#16209](https://github.com/google-gemini/gemini-cli/pull/16209)
- fix: Windows Specific Agent Quality & System Prompt by scidomino in
[#18351](https://github.com/google-gemini/gemini-cli/pull/18351)
- feat(plan): support replace tool in plan mode to edit plans by jerop in
[#18379](https://github.com/google-gemini/gemini-cli/pull/18379)
- Improving memory tool instructions and eval testing by alisa-alisa in
[#18091](https://github.com/google-gemini/gemini-cli/pull/18091)
- fix(cli): color extension link success message green by MushuEE in
[#18386](https://github.com/google-gemini/gemini-cli/pull/18386)
- undo by jacob314 in
[#18147](https://github.com/google-gemini/gemini-cli/pull/18147)
- feat(plan): add guidance on iterating on approved plans vs creating new plans
by jerop in [#18346](https://github.com/google-gemini/gemini-cli/pull/18346)
- feat(plan): fix invalid tool calls in plan mode by Adib234 in
[#18352](https://github.com/google-gemini/gemini-cli/pull/18352)
- feat(plan): integrate planning artifacts and tools into primary workflows by
jerop in [#18375](https://github.com/google-gemini/gemini-cli/pull/18375)
- Fix permission check by scidomino in
[#18395](https://github.com/google-gemini/gemini-cli/pull/18395)
- ux(polish) autocomplete in the input prompt by jacob314 in
[#18181](https://github.com/google-gemini/gemini-cli/pull/18181)
- fix: resolve infinite loop when using 'Modify with external editor' by
ppgranger in [#17453](https://github.com/google-gemini/gemini-cli/pull/17453)
- feat: expand verify-release to macOS and Windows by yunaseoul in
[#18145](https://github.com/google-gemini/gemini-cli/pull/18145)
- feat(plan): implement support for MCP servers in Plan mode by Adib234 in
[#18229](https://github.com/google-gemini/gemini-cli/pull/18229)
- chore: update folder trust error messaging by galz10 in
[#18402](https://github.com/google-gemini/gemini-cli/pull/18402)
- feat(plan): create a metric for execution of plans generated in plan mode by
Adib234 in [#18236](https://github.com/google-gemini/gemini-cli/pull/18236)
- perf(ui): optimize stripUnsafeCharacters with regex by gsquared94 in
[#18413](https://github.com/google-gemini/gemini-cli/pull/18413)
- feat(context): implement observation masking for tool outputs by abhipatel12
in [#18389](https://github.com/google-gemini/gemini-cli/pull/18389)
- feat(core,cli): implement session-linked tool output storage and cleanup by
abhipatel12 in
[#18416](https://github.com/google-gemini/gemini-cli/pull/18416)
- Shorten temp directory by joshualitt in
[#17901](https://github.com/google-gemini/gemini-cli/pull/17901)
- feat(plan): add behavioral evals for plan mode by jerop in
[#18437](https://github.com/google-gemini/gemini-cli/pull/18437)
- Add extension registry client by chrstnb in
[#18396](https://github.com/google-gemini/gemini-cli/pull/18396)
- Enable extension config by default by chrstnb in
[#18447](https://github.com/google-gemini/gemini-cli/pull/18447)
- Automatically generate change logs on release by g-samroberts in
[#18401](https://github.com/google-gemini/gemini-cli/pull/18401)
- Remove previewFeatures and default to Gemini 3 by sehoon38 in
[#18414](https://github.com/google-gemini/gemini-cli/pull/18414)
- feat(admin): apply MCP allowlist to extensions & gemini mcp list command by
skeshive in [#18442](https://github.com/google-gemini/gemini-cli/pull/18442)
- fix(cli): improve focus navigation for interactive and background shells by
galz10 in [#18343](https://github.com/google-gemini/gemini-cli/pull/18343)
- Add shortcuts hint and panel for discoverability by LyalinDotCom in
[#18035](https://github.com/google-gemini/gemini-cli/pull/18035)
- fix(config): treat system settings as read-only during migration and warn user
by spencer426 in
[#18277](https://github.com/google-gemini/gemini-cli/pull/18277)
- feat(plan): add positive test case and update eval stability policy by jerop
in [#18457](https://github.com/google-gemini/gemini-cli/pull/18457)
- fix- windows: add shell: true for spawnSync to fix EINVAL with .cmd editors by
zackoch in [#18408](https://github.com/google-gemini/gemini-cli/pull/18408)
- bug(core): Fix bug when saving plans. by joshualitt in
[#18465](https://github.com/google-gemini/gemini-cli/pull/18465)
- Refactor atCommandProcessor by scidomino in
[#18461](https://github.com/google-gemini/gemini-cli/pull/18461)
- feat(core): implement persistence and resumption for masked tool outputs by
abhipatel12 in
[#18451](https://github.com/google-gemini/gemini-cli/pull/18451)
- refactor: simplify tool output truncation to single config by SandyTao520 in
[#18446](https://github.com/google-gemini/gemini-cli/pull/18446)
- bug(core): Ensure storage is initialized early, even if config is not. by
joshualitt in [#18471](https://github.com/google-gemini/gemini-cli/pull/18471)
- chore: Update build-and-start script to support argument forwarding by
Abhijit-2592 in
[#18241](https://github.com/google-gemini/gemini-cli/pull/18241)
- fix(core): prevent subagent bypass in plan mode by jerop in
[#18484](https://github.com/google-gemini/gemini-cli/pull/18484)
- feat(cli): add WebSocket-based network logging and streaming chunk support by
SandyTao520 in
[#18383](https://github.com/google-gemini/gemini-cli/pull/18383)
- feat(cli): update approval modes UI by jerop in
[#18476](https://github.com/google-gemini/gemini-cli/pull/18476)
- fix(cli): reload skills and agents on extension restart by NTaylorMullen in
[#18411](https://github.com/google-gemini/gemini-cli/pull/18411)
- fix(core): expand excludeTools with legacy aliases for renamed tools by
SandyTao520 in
[#18498](https://github.com/google-gemini/gemini-cli/pull/18498)
- feat(core): overhaul system prompt for rigor, integrity, and intent alignment
by NTaylorMullen in
[#17263](https://github.com/google-gemini/gemini-cli/pull/17263)
- Patch for generate changelog docs yaml file by g-samroberts in
[#18496](https://github.com/google-gemini/gemini-cli/pull/18496)
- Code review fixes for show question mark pr. by jacob314 in
[#18480](https://github.com/google-gemini/gemini-cli/pull/18480)
- fix(cli): add SS3 Shift+Tab support for Windows terminals by ThanhNguyxn in
[#18187](https://github.com/google-gemini/gemini-cli/pull/18187)
- chore: remove redundant planning prompt from final shell by jerop in
[#18528](https://github.com/google-gemini/gemini-cli/pull/18528)
- docs: require pr-creator skill for PR generation by NTaylorMullen in
[#18536](https://github.com/google-gemini/gemini-cli/pull/18536)
- chore: update colors for ask_user dialog by jackwotherspoon in
[#18543](https://github.com/google-gemini/gemini-cli/pull/18543)
- feat(core): exempt high-signal tools from output masking by abhipatel12 in
[#18545](https://github.com/google-gemini/gemini-cli/pull/18545)
- refactor(core): remove memory tool instructions from Gemini 3 prompt by
NTaylorMullen in
[#18559](https://github.com/google-gemini/gemini-cli/pull/18559)
- chore: remove feedback instruction from system prompt by NTaylorMullen in
[#18560](https://github.com/google-gemini/gemini-cli/pull/18560)
- feat(context): add remote configuration for tool output masking thresholds by
abhipatel12 in
[#18553](https://github.com/google-gemini/gemini-cli/pull/18553)
- feat(core): pause agent timeout budget while waiting for tool confirmation by
abhipatel12 in
[#18415](https://github.com/google-gemini/gemini-cli/pull/18415)
- refactor(config): remove experimental.enableEventDrivenScheduler setting by
abhipatel12 in
[#17924](https://github.com/google-gemini/gemini-cli/pull/17924)
- feat(cli): truncate shell output in UI history and improve active shell
display by jwhelangoog in
[#17438](https://github.com/google-gemini/gemini-cli/pull/17438)
- refactor(cli): switch useToolScheduler to event-driven engine by abhipatel12
in [#18565](https://github.com/google-gemini/gemini-cli/pull/18565)
- fix(core): correct escaped interpolation in system prompt by NTaylorMullen in
[#18557](https://github.com/google-gemini/gemini-cli/pull/18557)
- propagate abortSignal by scidomino in
[#18477](https://github.com/google-gemini/gemini-cli/pull/18477)
- feat(core): conditionally include ctrl+f prompt based on interactive shell
setting by NTaylorMullen in
[#18561](https://github.com/google-gemini/gemini-cli/pull/18561)
- fix(core): ensure enter_plan_mode tool registration respects experimental.plan
by jerop in [#18587](https://github.com/google-gemini/gemini-cli/pull/18587)
- feat(core): transition sub-agents to XML format and improve definitions by
NTaylorMullen in
[#18555](https://github.com/google-gemini/gemini-cli/pull/18555)
- docs: Add Plan Mode documentation by jerop in
[#18582](https://github.com/google-gemini/gemini-cli/pull/18582)
- chore: strengthen validation guidance in system prompt by NTaylorMullen in
[#18544](https://github.com/google-gemini/gemini-cli/pull/18544)
- Fix newline insertion bug in replace tool by werdnum in
[#18595](https://github.com/google-gemini/gemini-cli/pull/18595)
- fix(evals): update save_memory evals and simplify tool description by
NTaylorMullen in
[#18610](https://github.com/google-gemini/gemini-cli/pull/18610)
- chore(evals): update validation_fidelity_pre_existing_errors to USUALLY_PASSES
by NTaylorMullen in
[#18617](https://github.com/google-gemini/gemini-cli/pull/18617)
- fix: shorten tool call IDs and fix duplicate tool name in truncated output
filenames by SandyTao520 in
[#18600](https://github.com/google-gemini/gemini-cli/pull/18600)
- feat(cli): implement atomic writes and safety checks for trusted folders by
galz10 in [#18406](https://github.com/google-gemini/gemini-cli/pull/18406)
- Remove relative docs links by chrstnb in
[#18650](https://github.com/google-gemini/gemini-cli/pull/18650)
- docs: add legacy snippets convention to GEMINI.md by NTaylorMullen in
[#18597](https://github.com/google-gemini/gemini-cli/pull/18597)
- fix(chore): Support linting for cjs by aswinashok44 in
[#18639](https://github.com/google-gemini/gemini-cli/pull/18639)
- feat: move shell efficiency guidelines to tool description by NTaylorMullen in
[#18614](https://github.com/google-gemini/gemini-cli/pull/18614)
- Added "" as default value, since getText() used to expect a string only and
thus crashed when undefined... Fixes #18076 by 019-Abhi in
[#18099](https://github.com/google-gemini/gemini-cli/pull/18099)
- Allow @-includes outside of workspaces (with permission) by scidomino in
[#18470](https://github.com/google-gemini/gemini-cli/pull/18470)
- chore: make ask_user header description more clear by jackwotherspoon in
[#18657](https://github.com/google-gemini/gemini-cli/pull/18657)
- refactor(core): model-dependent tool definitions by aishaneeshah in
[#18563](https://github.com/google-gemini/gemini-cli/pull/18563)
- Harded code assist converter. by jacob314 in
[#18656](https://github.com/google-gemini/gemini-cli/pull/18656)
- bug(core): Fix minor bug in migration logic. by joshualitt in
[#18661](https://github.com/google-gemini/gemini-cli/pull/18661)
- feat: enable plan mode experiment in settings by jerop in
[#18636](https://github.com/google-gemini/gemini-cli/pull/18636)
- refactor: push isValidPath() into parsePastedPaths() by scidomino in
[#18664](https://github.com/google-gemini/gemini-cli/pull/18664)
- fix(cli): correct 'esc to cancel' position and restore duration display by
NTaylorMullen in
[#18534](https://github.com/google-gemini/gemini-cli/pull/18534)
- feat(cli): add DevTools integration with gemini-cli-devtools by SandyTao520 in
[#18648](https://github.com/google-gemini/gemini-cli/pull/18648)
- chore: remove unused exports and redundant hook files by SandyTao520 in
[#18681](https://github.com/google-gemini/gemini-cli/pull/18681)
- Fix number of lines being reported in rewind confirmation dialog by Adib234 in
[#18675](https://github.com/google-gemini/gemini-cli/pull/18675)
- feat(cli): disable folder trust in headless mode by galz10 in
[#18407](https://github.com/google-gemini/gemini-cli/pull/18407)
- Disallow unsafe type assertions by gundermanc in
[#18688](https://github.com/google-gemini/gemini-cli/pull/18688)
- Change event type for release by g-samroberts in
[#18693](https://github.com/google-gemini/gemini-cli/pull/18693)
- feat: handle multiple dynamic context filenames in system prompt by
NTaylorMullen in
[#18598](https://github.com/google-gemini/gemini-cli/pull/18598)
- Properly parse at-commands with narrow non-breaking spaces by scidomino in
[#18677](https://github.com/google-gemini/gemini-cli/pull/18677)
- refactor(core): centralize core tool definitions and support model-specific
schemas by aishaneeshah in
[#18662](https://github.com/google-gemini/gemini-cli/pull/18662)
- feat(core): Render memory hierarchically in context. by joshualitt in
[#18350](https://github.com/google-gemini/gemini-cli/pull/18350)
- feat: Ctrl+O to expand paste placeholder by jackwotherspoon in
[#18103](https://github.com/google-gemini/gemini-cli/pull/18103)
- fix(cli): Improve header spacing by NTaylorMullen in
[#18531](https://github.com/google-gemini/gemini-cli/pull/18531)
- Feature/quota visibility 16795 by spencer426 in
[#18203](https://github.com/google-gemini/gemini-cli/pull/18203)
- Inline thinking bubbles with summary/full modes by LyalinDotCom in
[#18033](https://github.com/google-gemini/gemini-cli/pull/18033)
- docs: remove TOC marker from Plan Mode header by jerop in
[#18678](https://github.com/google-gemini/gemini-cli/pull/18678)
- fix(ui): remove redundant newlines in Gemini messages by NTaylorMullen in
[#18538](https://github.com/google-gemini/gemini-cli/pull/18538)
- test(cli): fix AppContainer act() warnings and improve waitFor resilience by
NTaylorMullen in
[#18676](https://github.com/google-gemini/gemini-cli/pull/18676)
- refactor(core): refine Security & System Integrity section in system prompt by
NTaylorMullen in
[#18601](https://github.com/google-gemini/gemini-cli/pull/18601)
- Fix layout rounding. by gundermanc in
[#18667](https://github.com/google-gemini/gemini-cli/pull/18667)
- docs(skills): enhance pr-creator safety and interactivity by NTaylorMullen in
[#18616](https://github.com/google-gemini/gemini-cli/pull/18616)
- test(core): remove hardcoded model from TestRig by NTaylorMullen in
[#18710](https://github.com/google-gemini/gemini-cli/pull/18710)
- feat(core): optimize sub-agents system prompt intro by NTaylorMullen in
[#18608](https://github.com/google-gemini/gemini-cli/pull/18608)
- feat(cli): update approval mode labels and shortcuts per latest UX spec by
jerop in [#18698](https://github.com/google-gemini/gemini-cli/pull/18698)
- fix(plan): update persistent approval mode setting by Adib234 in
[#18638](https://github.com/google-gemini/gemini-cli/pull/18638)
- fix: move toasts location to left side by jackwotherspoon in
[#18705](https://github.com/google-gemini/gemini-cli/pull/18705)
- feat(routing): restrict numerical routing to Gemini 3 family by mattKorwel in
[#18478](https://github.com/google-gemini/gemini-cli/pull/18478)
- fix(ide): fix ide nudge setting by skeshive in
[#18733](https://github.com/google-gemini/gemini-cli/pull/18733)
- fix(core): standardize tool formatting in system prompts by NTaylorMullen in
[#18615](https://github.com/google-gemini/gemini-cli/pull/18615)
- chore: consolidate to green in ask user dialog by jackwotherspoon in
[#18734](https://github.com/google-gemini/gemini-cli/pull/18734)
- feat: add extensionsExplore setting to enable extensions explore UI. by
sripasg in [#18686](https://github.com/google-gemini/gemini-cli/pull/18686)
- feat(cli): defer devtools startup and integrate with F12 by SandyTao520 in
[#18695](https://github.com/google-gemini/gemini-cli/pull/18695)
- ui: update & subdue footer colors and animate progress indicator by
keithguerin in
[#18570](https://github.com/google-gemini/gemini-cli/pull/18570)
- test: add model-specific snapshots for coreTools by aishaneeshah in
[#18707](https://github.com/google-gemini/gemini-cli/pull/18707)
- ci: shard windows tests and fix event listener leaks by NTaylorMullen in
[#18670](https://github.com/google-gemini/gemini-cli/pull/18670)
- fix: allow ask_user tool in yolo mode by jackwotherspoon in
[#18541](https://github.com/google-gemini/gemini-cli/pull/18541)
- feat: redact disabled tools from system prompt
([#13597](https://github.com/google-gemini/gemini-cli/pull/13597)) by
NTaylorMullen in
[#18613](https://github.com/google-gemini/gemini-cli/pull/18613)
- Update Gemini.md to use the curent year on creating new files by sehoon38 in
[#18460](https://github.com/google-gemini/gemini-cli/pull/18460)
- Code review cleanup for thinking display by jacob314 in
[#18720](https://github.com/google-gemini/gemini-cli/pull/18720)
- fix(cli): hide scrollbars when in alternate buffer copy mode by werdnum in
[#18354](https://github.com/google-gemini/gemini-cli/pull/18354)
- Fix issues with rip grep by gundermanc in
[#18756](https://github.com/google-gemini/gemini-cli/pull/18756)
- fix(cli): fix history navigation regression after prompt autocomplete by
sehoon38 in [#18752](https://github.com/google-gemini/gemini-cli/pull/18752)
- chore: cleanup unused and add unlisted dependencies in packages/cli by
adamfweidman in
[#18749](https://github.com/google-gemini/gemini-cli/pull/18749)
- Fix issue where Gemini CLI creates tests in a new file by gundermanc in
[#18409](https://github.com/google-gemini/gemini-cli/pull/18409)
- feat(telemetry): Ensure experiment IDs are included in OpenTelemetry logs by
kevin-ramdass in
[#18747](https://github.com/google-gemini/gemini-cli/pull/18747)
- fix(patch): cherry-pick 261788c to release/v0.30.0-preview.0-pr-19453 to patch
version v0.30.0-preview.0 and create version 0.30.0-preview.1 by
@gemini-cli-robot in
[#19490](https://github.com/google-gemini/gemini-cli/pull/19490)
- feat(ux): added text wrapping capabilities to markdown tables by @devr0306 in
[#18240](https://github.com/google-gemini/gemini-cli/pull/18240)
- Revert "fix(mcp): ensure MCP transport is closed to prevent memory leaks" by
@skeshive in [#18771](https://github.com/google-gemini/gemini-cli/pull/18771)
- chore(release): bump version to 0.30.0-nightly.20260210.a2174751d by
@gemini-cli-robot in
[#18772](https://github.com/google-gemini/gemini-cli/pull/18772)
- chore: cleanup unused and add unlisted dependencies in packages/core by
@adamfweidman in
[#18762](https://github.com/google-gemini/gemini-cli/pull/18762)
- chore(core): update activate_skill prompt verbiage to be more direct by
@NTaylorMullen in
[#18605](https://github.com/google-gemini/gemini-cli/pull/18605)
- Add autoconfigure memory usage setting to the dialog by @jacob314 in
[#18510](https://github.com/google-gemini/gemini-cli/pull/18510)
- fix(core): prevent race condition in policy persistence by @braddux in
[#18506](https://github.com/google-gemini/gemini-cli/pull/18506)
- fix(evals): prevent false positive in hierarchical memory test by
@Abhijit-2592 in
[#18777](https://github.com/google-gemini/gemini-cli/pull/18777)
- test(evals): mark all `save_memory` evals as `USUALLY_PASSES` due to
unreliability by @jerop in
[#18786](https://github.com/google-gemini/gemini-cli/pull/18786)
- feat(cli): add setting to hide shortcuts hint UI by @LyalinDotCom in
[#18562](https://github.com/google-gemini/gemini-cli/pull/18562)
- feat(core): formalize 5-phase sequential planning workflow by @jerop in
[#18759](https://github.com/google-gemini/gemini-cli/pull/18759)
- Introduce limits for search results. by @gundermanc in
[#18767](https://github.com/google-gemini/gemini-cli/pull/18767)
- fix(cli): allow closing debug console after auto-open via flicker by
@SandyTao520 in
[#18795](https://github.com/google-gemini/gemini-cli/pull/18795)
- feat(masking): enable tool output masking by default by @abhipatel12 in
[#18564](https://github.com/google-gemini/gemini-cli/pull/18564)
- perf(ui): optimize table rendering by memoizing styled characters by @devr0306
in [#18770](https://github.com/google-gemini/gemini-cli/pull/18770)
- feat: multi-line text answers in ask-user tool by @jackwotherspoon in
[#18741](https://github.com/google-gemini/gemini-cli/pull/18741)
- perf(cli): truncate large debug logs and limit message history by @mattKorwel
in [#18663](https://github.com/google-gemini/gemini-cli/pull/18663)
- fix(core): complete MCP discovery when configured servers are skipped by
@LyalinDotCom in
[#18586](https://github.com/google-gemini/gemini-cli/pull/18586)
- fix(core): cache CLI version to ensure consistency during sessions by
@sehoon38 in [#18793](https://github.com/google-gemini/gemini-cli/pull/18793)
- fix(cli): resolve double rendering in shpool and address vscode lint warnings
by @braddux in
[#18704](https://github.com/google-gemini/gemini-cli/pull/18704)
- feat(plan): document and validate Plan Mode policy overrides by @jerop in
[#18825](https://github.com/google-gemini/gemini-cli/pull/18825)
- Fix pressing any key to exit select mode. by @jacob314 in
[#18421](https://github.com/google-gemini/gemini-cli/pull/18421)
- fix(cli): update F12 behavior to only open drawer if browser fails by
@SandyTao520 in
[#18829](https://github.com/google-gemini/gemini-cli/pull/18829)
- feat(plan): allow skills to be enabled in plan mode by @Adib234 in
[#18817](https://github.com/google-gemini/gemini-cli/pull/18817)
- docs(plan): add documentation for plan mode tools by @jerop in
[#18827](https://github.com/google-gemini/gemini-cli/pull/18827)
- Remove experimental note in extension settings docs by @chrstnb in
[#18822](https://github.com/google-gemini/gemini-cli/pull/18822)
- Update prompt and grep tool definition to limit context size by @gundermanc in
[#18780](https://github.com/google-gemini/gemini-cli/pull/18780)
- docs(plan): add `ask_user` tool documentation by @jerop in
[#18830](https://github.com/google-gemini/gemini-cli/pull/18830)
- Revert unintended credentials exposure by @Adib234 in
[#18840](https://github.com/google-gemini/gemini-cli/pull/18840)
- feat(core): update internal utility models to Gemini 3 by @SandyTao520 in
[#18773](https://github.com/google-gemini/gemini-cli/pull/18773)
- feat(a2a): add value-resolver for auth credential resolution by @adamfweidman
in [#18653](https://github.com/google-gemini/gemini-cli/pull/18653)
- Removed getPlainTextLength by @devr0306 in
[#18848](https://github.com/google-gemini/gemini-cli/pull/18848)
- More grep prompt tweaks by @gundermanc in
[#18846](https://github.com/google-gemini/gemini-cli/pull/18846)
- refactor(cli): Reactive useSettingsStore hook by @psinha40898 in
[#14915](https://github.com/google-gemini/gemini-cli/pull/14915)
- fix(mcp): Ensure that stdio MCP server execution has the `GEMINI_CLI=1` env
variable populated. by @richieforeman in
[#18832](https://github.com/google-gemini/gemini-cli/pull/18832)
- fix(core): improve headless mode detection for flags and query args by @galz10
in [#18855](https://github.com/google-gemini/gemini-cli/pull/18855)
- refactor(cli): simplify UI and remove legacy inline tool confirmation logic by
@abhipatel12 in
[#18566](https://github.com/google-gemini/gemini-cli/pull/18566)
- feat(cli): deprecate --allowed-tools and excludeTools in favor of policy
engine by @Abhijit-2592 in
[#18508](https://github.com/google-gemini/gemini-cli/pull/18508)
- fix(workflows): improve maintainer detection for automated PR actions by
@bdmorgan in [#18869](https://github.com/google-gemini/gemini-cli/pull/18869)
- refactor(cli): consolidate useToolScheduler and delete legacy implementation
by @abhipatel12 in
[#18567](https://github.com/google-gemini/gemini-cli/pull/18567)
- Update changelog for v0.28.0 and v0.29.0-preview0 by @g-samroberts in
[#18819](https://github.com/google-gemini/gemini-cli/pull/18819)
- fix(core): ensure sub-agents are registered regardless of tools.allowed by
@mattKorwel in
[#18870](https://github.com/google-gemini/gemini-cli/pull/18870)
- Show notification when there's a conflict with an extensions command by
@chrstnb in [#17890](https://github.com/google-gemini/gemini-cli/pull/17890)
- fix(cli): dismiss '?' shortcuts help on hotkeys and active states by
@LyalinDotCom in
[#18583](https://github.com/google-gemini/gemini-cli/pull/18583)
- fix(core): prioritize conditional policy rules and harden Plan Mode by
@Abhijit-2592 in
[#18882](https://github.com/google-gemini/gemini-cli/pull/18882)
- feat(core): refine Plan Mode system prompt for agentic execution by
@NTaylorMullen in
[#18799](https://github.com/google-gemini/gemini-cli/pull/18799)
- feat(plan): create metrics for usage of `AskUser` tool by @Adib234 in
[#18820](https://github.com/google-gemini/gemini-cli/pull/18820)
- feat(cli): support Ctrl-Z suspension by @scidomino in
[#18931](https://github.com/google-gemini/gemini-cli/pull/18931)
- fix(github-actions): use robot PAT for release creation to trigger release
notes by @SandyTao520 in
[#18794](https://github.com/google-gemini/gemini-cli/pull/18794)
- feat: add strict seatbelt profiles and remove unusable closed profiles by
@SandyTao520 in
[#18876](https://github.com/google-gemini/gemini-cli/pull/18876)
- chore: cleanup unused and add unlisted dependencies in packages/a2a-server by
@adamfweidman in
[#18916](https://github.com/google-gemini/gemini-cli/pull/18916)
- fix(plan): isolate plan files per session by @Adib234 in
[#18757](https://github.com/google-gemini/gemini-cli/pull/18757)
- fix: character truncation in raw markdown mode by @jackwotherspoon in
[#18938](https://github.com/google-gemini/gemini-cli/pull/18938)
- feat(cli): prototype clean UI toggle and minimal-mode bleed-through by
@LyalinDotCom in
[#18683](https://github.com/google-gemini/gemini-cli/pull/18683)
- ui(polish) blend background color with theme by @jacob314 in
[#18802](https://github.com/google-gemini/gemini-cli/pull/18802)
- Add generic searchable list to back settings and extensions by @chrstnb in
[#18838](https://github.com/google-gemini/gemini-cli/pull/18838)
- feat(ui): align `AskUser` color scheme with UX spec by @jerop in
[#18943](https://github.com/google-gemini/gemini-cli/pull/18943)
- Hide AskUser tool validation errors from UI (agent self-corrects) by @jerop in
[#18954](https://github.com/google-gemini/gemini-cli/pull/18954)
- bug(cli) fix flicker due to AppContainer continuous initialization by
@jacob314 in [#18958](https://github.com/google-gemini/gemini-cli/pull/18958)
- feat(admin): Add admin controls documentation by @skeshive in
[#18644](https://github.com/google-gemini/gemini-cli/pull/18644)
- feat(cli): disable ctrl-s shortcut outside of alternate buffer mode by
@jacob314 in [#18887](https://github.com/google-gemini/gemini-cli/pull/18887)
- fix(vim): vim support that feels (more) complete by @ppgranger in
[#18755](https://github.com/google-gemini/gemini-cli/pull/18755)
- feat(policy): add --policy flag for user defined policies by @allenhutchison
in [#18500](https://github.com/google-gemini/gemini-cli/pull/18500)
- Update installation guide by @g-samroberts in
[#18823](https://github.com/google-gemini/gemini-cli/pull/18823)
- refactor(core): centralize tool definitions (Group 1: replace, search, grep)
by @aishaneeshah in
[#18944](https://github.com/google-gemini/gemini-cli/pull/18944)
- refactor(cli): finalize event-driven transition and remove interaction bridge
by @abhipatel12 in
[#18569](https://github.com/google-gemini/gemini-cli/pull/18569)
- Fix drag and drop escaping by @scidomino in
[#18965](https://github.com/google-gemini/gemini-cli/pull/18965)
- feat(sdk): initial package bootstrap for SDK by @mbleigh in
[#18861](https://github.com/google-gemini/gemini-cli/pull/18861)
- feat(sdk): implements SessionContext for SDK tool calls by @mbleigh in
[#18862](https://github.com/google-gemini/gemini-cli/pull/18862)
- fix(plan): make question type required in AskUser tool by @Adib234 in
[#18959](https://github.com/google-gemini/gemini-cli/pull/18959)
- fix(core): ensure --yolo does not force headless mode by @NTaylorMullen in
[#18976](https://github.com/google-gemini/gemini-cli/pull/18976)
- refactor(core): adopt `CoreToolCallStatus` enum for type safety by @jerop in
[#18998](https://github.com/google-gemini/gemini-cli/pull/18998)
- Enable in-CLI extension management commands for team by @chrstnb in
[#18957](https://github.com/google-gemini/gemini-cli/pull/18957)
- Adjust lint rules to avoid unnecessary warning. by @scidomino in
[#18970](https://github.com/google-gemini/gemini-cli/pull/18970)
- fix(vscode): resolve unsafe type assertion lint errors by @ehedlund in
[#19006](https://github.com/google-gemini/gemini-cli/pull/19006)
- Remove unnecessary eslint config file by @scidomino in
[#19015](https://github.com/google-gemini/gemini-cli/pull/19015)
- fix(core): Prevent loop detection false positives on lists with long shared
prefixes by @SandyTao520 in
[#18975](https://github.com/google-gemini/gemini-cli/pull/18975)
- feat(core): fallback to chat-base when using unrecognized models for chat by
@SandyTao520 in
[#19016](https://github.com/google-gemini/gemini-cli/pull/19016)
- docs: fix inconsistent commandRegex example in policy engine by @NTaylorMullen
in [#19027](https://github.com/google-gemini/gemini-cli/pull/19027)
- fix(plan): persist the approval mode in UI even when agent is thinking by
@Adib234 in [#18955](https://github.com/google-gemini/gemini-cli/pull/18955)
- feat(sdk): Implement dynamic system instructions by @mbleigh in
[#18863](https://github.com/google-gemini/gemini-cli/pull/18863)
- Docs: Refresh docs to organize and standardize reference materials. by
@jkcinouye in [#18403](https://github.com/google-gemini/gemini-cli/pull/18403)
- fix windows escaping (and broken tests) by @scidomino in
[#19011](https://github.com/google-gemini/gemini-cli/pull/19011)
- refactor: use `CoreToolCallStatus` in the the history data model by @jerop in
[#19033](https://github.com/google-gemini/gemini-cli/pull/19033)
- feat(cleanup): enable 30-day session retention by default by @skeshive in
[#18854](https://github.com/google-gemini/gemini-cli/pull/18854)
- feat(plan): hide plan write and edit operations on plans in Plan Mode by
@jerop in [#19012](https://github.com/google-gemini/gemini-cli/pull/19012)
- bug(ui) fix flicker refreshing background color by @jacob314 in
[#19041](https://github.com/google-gemini/gemini-cli/pull/19041)
- chore: fix dep vulnerabilities by @scidomino in
[#19036](https://github.com/google-gemini/gemini-cli/pull/19036)
- Revamp automated changelog skill by @g-samroberts in
[#18974](https://github.com/google-gemini/gemini-cli/pull/18974)
- feat(sdk): implement support for custom skills by @mbleigh in
[#19031](https://github.com/google-gemini/gemini-cli/pull/19031)
- refactor(core): complete centralization of core tool definitions by
@aishaneeshah in
[#18991](https://github.com/google-gemini/gemini-cli/pull/18991)
- feat: add /commands reload to refresh custom TOML commands by @korade-krushna
in [#19078](https://github.com/google-gemini/gemini-cli/pull/19078)
- fix(cli): wrap terminal capability queries in hidden sequence by @srithreepo
in [#19080](https://github.com/google-gemini/gemini-cli/pull/19080)
- fix(workflows): fix GitHub App token permissions for maintainer detection by
@bdmorgan in [#19139](https://github.com/google-gemini/gemini-cli/pull/19139)
- test: fix hook integration test flakiness on Windows CI by @NTaylorMullen in
[#18665](https://github.com/google-gemini/gemini-cli/pull/18665)
- fix(core): Encourage non-interactive flags for scaffolding commands by
@NTaylorMullen in
[#18804](https://github.com/google-gemini/gemini-cli/pull/18804)
- fix(core): propagate User-Agent header to setup-phase CodeAssist API calls by
@gsquared94 in
[#19182](https://github.com/google-gemini/gemini-cli/pull/19182)
- docs: document .agents/skills alias and discovery precedence by @kevmoo in
[#19166](https://github.com/google-gemini/gemini-cli/pull/19166)
- feat(cli): add loading state to new agents notification by @sehoon38 in
[#19190](https://github.com/google-gemini/gemini-cli/pull/19190)
- Add base branch to workflow. by @g-samroberts in
[#19189](https://github.com/google-gemini/gemini-cli/pull/19189)
- feat(cli): handle invalid model names in useQuotaAndFallback by @sehoon38 in
[#19222](https://github.com/google-gemini/gemini-cli/pull/19222)
- docs: custom themes in extensions by @jackwotherspoon in
[#19219](https://github.com/google-gemini/gemini-cli/pull/19219)
- Disable workspace settings when starting GCLI in the home directory. by
@kevinjwang1 in
[#19034](https://github.com/google-gemini/gemini-cli/pull/19034)
- feat(cli): refactor model command to support set and manage subcommands by
@sehoon38 in [#19221](https://github.com/google-gemini/gemini-cli/pull/19221)
- Add refresh/reload aliases to slash command subcommands by @korade-krushna in
[#19218](https://github.com/google-gemini/gemini-cli/pull/19218)
- refactor: consolidate development rules and add cli guidelines by @jacob314 in
[#19214](https://github.com/google-gemini/gemini-cli/pull/19214)
- chore(ui): remove outdated tip about model routing by @sehoon38 in
[#19226](https://github.com/google-gemini/gemini-cli/pull/19226)
- feat(core): support custom reasoning models by default by @NTaylorMullen in
[#19227](https://github.com/google-gemini/gemini-cli/pull/19227)
- Add Solarized Dark and Solarized Light themes by @rmedranollamas in
[#19064](https://github.com/google-gemini/gemini-cli/pull/19064)
- fix(telemetry): replace JSON.stringify with safeJsonStringify in file
exporters by @gsquared94 in
[#19244](https://github.com/google-gemini/gemini-cli/pull/19244)
- feat(telemetry): add keychain availability and token storage metrics by
@abhipatel12 in
[#18971](https://github.com/google-gemini/gemini-cli/pull/18971)
- feat(cli): update approval mode cycle order by @jerop in
[#19254](https://github.com/google-gemini/gemini-cli/pull/19254)
- refactor(cli): code review cleanup fix for tab+tab by @jacob314 in
[#18967](https://github.com/google-gemini/gemini-cli/pull/18967)
- feat(plan): support project exploration without planning when in plan mode by
@Adib234 in [#18992](https://github.com/google-gemini/gemini-cli/pull/18992)
- feat: add role-specific statistics to telemetry and UI (cont. #15234) by
@yunaseoul in [#18824](https://github.com/google-gemini/gemini-cli/pull/18824)
- feat(cli): remove Plan Mode from rotation when actively working by @jerop in
[#19262](https://github.com/google-gemini/gemini-cli/pull/19262)
- Fix side breakage where anchors don't work in slugs. by @g-samroberts in
[#19261](https://github.com/google-gemini/gemini-cli/pull/19261)
- feat(config): add setting to make directory tree context configurable by
@kevin-ramdass in
[#19053](https://github.com/google-gemini/gemini-cli/pull/19053)
- fix(acp): Wait for mcp initialization in acp (#18893) by @Mervap in
[#18894](https://github.com/google-gemini/gemini-cli/pull/18894)
- docs: format UTC times in releases doc by @pavan-sh in
[#18169](https://github.com/google-gemini/gemini-cli/pull/18169)
- Docs: Clarify extensions documentation. by @jkcinouye in
[#19277](https://github.com/google-gemini/gemini-cli/pull/19277)
- refactor(core): modularize tool definitions by model family by @aishaneeshah
in [#19269](https://github.com/google-gemini/gemini-cli/pull/19269)
- fix(paths): Add cross-platform path normalization by @spencer426 in
[#18939](https://github.com/google-gemini/gemini-cli/pull/18939)
- feat(core): experimental in-progress steering hints (1 of 3) by @joshualitt in
[#19008](https://github.com/google-gemini/gemini-cli/pull/19008)
**Full changelog**:
https://github.com/google-gemini/gemini-cli/compare/v0.28.0-preview.0...v0.29.0-preview.0
https://github.com/google-gemini/gemini-cli/compare/v0.29.0-preview.5...v0.30.0-preview.1
+23 -23
View File
@@ -26,29 +26,29 @@ and parameters.
## CLI Options
| Option | Alias | Type | Default | Description |
| -------------------------------- | ----- | ------- | --------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `--debug` | `-d` | boolean | `false` | Run in debug mode with verbose logging |
| `--version` | `-v` | - | - | Show CLI version number and exit |
| `--help` | `-h` | - | - | Show help information |
| `--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 |
| `--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. |
| `--experimental-acp` | - | boolean | - | Start in ACP (Agent Code Pilot) mode. **Experimental feature.** |
| `--experimental-zed-integration` | - | boolean | - | Run in Zed editor integration mode. **Experimental feature.** |
| `--allowed-mcp-server-names` | - | array | - | Allowed MCP server names (comma-separated or multiple flags) |
| `--allowed-tools` | - | array | - | **Deprecated.** Use the [Policy Engine](../reference/policy-engine.md) instead. Tools that are allowed to run without confirmation (comma-separated or multiple flags) |
| `--extensions` | `-e` | array | - | List of extensions to use. If not provided, all extensions are enabled (comma-separated or multiple flags) |
| `--list-extensions` | `-l` | boolean | - | List all available extensions and exit |
| `--resume` | `-r` | string | - | Resume a previous session. Use `"latest"` for most recent or index number (e.g. `--resume 5`) |
| `--list-sessions` | - | boolean | - | List available sessions for the current project and exit |
| `--delete-session` | - | string | - | Delete a session by index number (use `--list-sessions` to see available sessions) |
| `--include-directories` | - | array | - | Additional directories to include in the workspace (comma-separated or multiple flags) |
| `--screen-reader` | - | boolean | - | Enable screen reader mode for accessibility |
| `--output-format` | `-o` | string | `text` | The format of the CLI output. Choices: `text`, `json`, `stream-json` |
| Option | Alias | Type | Default | Description |
| -------------------------------- | ----- | ------- | --------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `--debug` | `-d` | boolean | `false` | Run in debug mode with verbose logging |
| `--version` | `-v` | - | - | Show CLI version number and exit |
| `--help` | `-h` | - | - | Show help information |
| `--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 |
| `--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. |
| `--experimental-acp` | - | boolean | - | Start in ACP (Agent Code Pilot) mode. **Experimental feature.** |
| `--experimental-zed-integration` | - | boolean | - | Run in Zed editor integration mode. **Experimental feature.** |
| `--allowed-mcp-server-names` | - | array | - | Allowed MCP server names (comma-separated or multiple flags) |
| `--allowed-tools` | - | array | - | **Deprecated.** Use the [Policy Engine](../core/policy-engine.md) instead. Tools that are allowed to run without confirmation (comma-separated or multiple flags) |
| `--extensions` | `-e` | array | - | List of extensions to use. If not provided, all extensions are enabled (comma-separated or multiple flags) |
| `--list-extensions` | `-l` | boolean | - | List all available extensions and exit |
| `--resume` | `-r` | string | - | Resume a previous session. Use `"latest"` for most recent or index number (e.g. `--resume 5`) |
| `--list-sessions` | - | boolean | - | List available sessions for the current project and exit |
| `--delete-session` | - | string | - | Delete a session by index number (use `--list-sessions` to see available sessions) |
| `--include-directories` | - | array | - | Additional directories to include in the workspace (comma-separated or multiple flags) |
| `--screen-reader` | - | boolean | - | Enable screen reader mode for accessibility |
| `--output-format` | `-o` | string | `text` | The format of the CLI output. Choices: `text`, `json`, `stream-json` |
## Model selection
@@ -217,12 +217,19 @@ Slash commands provide meta-level control over the CLI itself.
model.
- **Note:** For more details on how `GEMINI.md` files contribute to
hierarchical memory, see the
[CLI Configuration documentation](../reference/configuration.md).
[CLI Configuration documentation](../get-started/configuration.md).
### `/model`
- **Description:** Opens a dialog to choose your Gemini model.
### `/plan`
- **Description:** Switch to Plan Mode (read-only) and view the current plan if
one has been generated.
- **Note:** This feature requires the `experimental.plan` setting to be
enabled in your configuration.
### `/policies`
- **Description:** Manage policies.
@@ -247,7 +254,7 @@ Slash commands provide meta-level control over the CLI itself.
checkpoints to restore from.
- **Usage:** `/restore [tool_call_id]`
- **Note:** Only available if checkpointing is configured via
[settings](../reference/configuration.md). See
[settings](../get-started/configuration.md). See
[Checkpointing documentation](../cli/checkpointing.md) for more details.
### `/rewind`
@@ -286,8 +293,7 @@ Slash commands provide meta-level control over the CLI itself.
settings that control the behavior and appearance of Gemini CLI. It is
equivalent to manually editing the `.gemini/settings.json` file, but with
validation and guidance to prevent errors. See the
[settings documentation](../cli/settings.md) for a full list of available
settings.
[settings documentation](./settings.md) for a full list of available settings.
- **Usage:** Simply run `/settings` and the editor will open. You can then
browse or search for specific settings, view their current values, and modify
them as desired. Changes to some settings are applied immediately, while
@@ -374,8 +380,7 @@ Slash commands provide meta-level control over the CLI itself.
Custom commands allow you to create personalized shortcuts for your most-used
prompts. For detailed instructions on how to create, manage, and use them,
please see the dedicated
[Custom Commands documentation](../cli/custom-commands.md).
please see the dedicated [Custom Commands documentation](./custom-commands.md).
## Input prompt shortcuts
+7 -7
View File
@@ -20,7 +20,7 @@ The most powerful tools for enterprise administration are the system-wide
settings files. These files allow you to define a baseline configuration
(`system-defaults.json`) and a set of overrides (`settings.json`) that apply to
all users on a machine. For a complete overview of configuration options, see
the [Configuration documentation](../reference/configuration.md).
the [Configuration documentation](../get-started/configuration.md).
Settings are merged from four files. The precedence order for single-value
settings (like `theme`) is:
@@ -224,8 +224,8 @@ gemini
You can significantly enhance security by controlling which tools the Gemini
model can use. This is achieved through the `tools.core` setting and the
[Policy Engine](../reference/policy-engine.md). For a list of available tools,
see the [Tools documentation](../tools/index.md).
[Policy Engine](../core/policy-engine.md). For a list of available tools, see
the [Tools documentation](../tools/index.md).
### Allowlisting with `coreTools`
@@ -245,8 +245,8 @@ on the approved list.
### Blocklisting with `excludeTools` (Deprecated)
> **Deprecated:** Use the [Policy Engine](../reference/policy-engine.md) for
> more robust control.
> **Deprecated:** Use the [Policy Engine](../core/policy-engine.md) for more
> robust control.
Alternatively, you can add specific tools that are considered dangerous in your
environment to a blocklist.
@@ -289,8 +289,8 @@ unintended tool execution.
## Managing custom tools (MCP servers)
If your organization uses custom tools via
[Model-Context Protocol (MCP) servers](../reference/tools-api.md), it is crucial
to understand how server configurations are managed to apply security policies
[Model-Context Protocol (MCP) servers](../core/tools-api.md), it is crucial to
understand how server configurations are managed to apply security policies
effectively.
### How MCP server configurations are merged
+1 -1
View File
@@ -88,7 +88,7 @@ More content here.
@../shared/style-guide.md
```
For more details, see the [Memory Import Processor](../reference/memport.md)
For more details, see the [Memory Import Processor](../core/memport.md)
documentation.
## Customize the context file name
@@ -36,7 +36,7 @@ available combinations.
| Delete from the cursor to the start of the line. | `Ctrl + U` |
| Clear all text in the input field. | `Ctrl + C` |
| Delete the previous word. | `Ctrl + Backspace`<br />`Alt + Backspace`<br />`Ctrl + W` |
| Delete the next word. | `Ctrl + Delete`<br />`Alt + Delete` |
| Delete the next word. | `Ctrl + Delete`<br />`Alt + Delete`<br />`Alt + D` |
| Delete the character to the left. | `Backspace`<br />`Ctrl + H` |
| Delete the character to the right. | `Delete`<br />`Ctrl + D` |
| Undo the most recent text edit. | `Cmd + Z (no Shift)`<br />`Alt + Z (no Shift)` |
@@ -61,7 +61,7 @@ available combinations.
| Show the next entry in history. | `Ctrl + N (no Shift)` |
| Start reverse search through history. | `Ctrl + R` |
| Submit the selected reverse-search match. | `Enter (no Ctrl)` |
| Accept a suggestion while reverse searching. | `Tab` |
| Accept a suggestion while reverse searching. | `Tab (no Shift)` |
| Browse and rewind previous interactions. | `Double Esc` |
#### Navigation
@@ -79,7 +79,7 @@ available combinations.
| Action | Keys |
| --------------------------------------- | -------------------------------------------------- |
| Accept the inline suggestion. | `Tab`<br />`Enter (no Ctrl)` |
| Accept the inline suggestion. | `Tab (no Shift)`<br />`Enter (no Ctrl)` |
| Move to the previous completion option. | `Up Arrow (no Shift)`<br />`Ctrl + P (no Shift)` |
| Move to the next completion option. | `Down Arrow (no Shift)`<br />`Ctrl + N (no Shift)` |
| Expand an inline suggestion. | `Right Arrow` |
+1 -1
View File
@@ -39,7 +39,7 @@ To enable Gemini 3 Pro and Gemini 3 Flash (if available), enable
You can also use the `--model` flag to specify a particular Gemini model on
startup. For more details, refer to the
[configuration documentation](../reference/configuration.md).
[configuration documentation](../get-started/configuration.md).
Changes to these settings will be applied to all subsequent interactions with
Gemini CLI.
+1 -1
View File
@@ -184,7 +184,7 @@ Guide].
[MCP tools]: /docs/tools/mcp-server.md
[`activate_skill`]: /docs/cli/skills.md
[experimental research sub-agents]: /docs/core/subagents.md
[Policy Engine Guide]: /docs/reference/policy-engine.md
[Policy Engine Guide]: /docs/core/policy-engine.md
[`enter_plan_mode`]: /docs/tools/planning.md#1-enter_plan_mode-enterplanmode
[`exit_plan_mode`]: /docs/tools/planning.md#2-exit_plan_mode-exitplanmode
[`ask_user`]: /docs/tools/ask-user.md
+3 -3
View File
@@ -167,6 +167,6 @@ gemini -s -p "run shell command: mount | grep workspace"
## Related documentation
- [Configuration](../reference/configuration.md): Full configuration options.
- [Commands](../reference/commands.md): Available commands.
- [Troubleshooting](../resources/troubleshooting.md): General troubleshooting.
- [Configuration](../get-started/configuration.md): Full configuration options.
- [Commands](./commands.md): Available commands.
- [Troubleshooting](../troubleshooting.md): General troubleshooting.
+3
View File
@@ -27,6 +27,7 @@ they appear in the UI.
| Vim Mode | `general.vimMode` | Enable Vim keybindings | `false` |
| Default Approval Mode | `general.defaultApprovalMode` | The default approval mode for tool execution. 'default' prompts for approval, 'auto_edit' auto-approves edit tools, and 'plan' is read-only mode. 'yolo' is not supported yet. | `"default"` |
| Enable Auto Update | `general.enableAutoUpdate` | Enable automatic updates. | `true` |
| Enable Notifications | `general.enableNotifications` | Enable run-event notifications for action-required prompts and session completion. Currently macOS only. | `false` |
| 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` |
@@ -49,6 +50,7 @@ they appear in the UI.
| Show Thoughts in Title | `ui.showStatusInTitle` | Show Gemini CLI model thoughts in the terminal window title during the working phase | `false` |
| Dynamic Window Title | `ui.dynamicWindowTitle` | Update the terminal window title with current status icons (Ready: ◇, Action Required: ✋, Working: ✦) | `true` |
| Show Home Directory Warning | `ui.showHomeDirectoryWarning` | Show a warning when running Gemini CLI in the home directory. | `true` |
| Show Compatibility Warnings | `ui.showCompatibilityWarnings` | Show warnings about terminal or OS compatibility issues. | `true` |
| Hide Tips | `ui.hideTips` | Hide helpful tips in the UI | `false` |
| Show Shortcuts Hint | `ui.showShortcutsHint` | Show the "? for shortcuts" hint above the input. | `true` |
| Hide Banner | `ui.hideBanner` | Hide the application banner | `false` |
@@ -131,6 +133,7 @@ they appear in the UI.
| Enable Tool Output Masking | `experimental.toolOutputMasking.enabled` | Enables tool output masking to save tokens. | `true` |
| Use OSC 52 Paste | `experimental.useOSC52Paste` | Use OSC 52 sequence for pasting instead of clipboardy (useful for remote sessions). | `false` |
| Plan | `experimental.plan` | Enable planning features (Plan Mode and tools). | `false` |
| Model Steering | `experimental.modelSteering` | Enable model steering (user hints) to guide the model during tool execution. | `false` |
### Skills
+1 -1
View File
@@ -93,7 +93,7 @@ Environment variables can be used to override the settings in the file.
`true` or `1` will enable the feature. Any other value will disable it.
For detailed information about all configuration options, see the
[Configuration guide](../reference/configuration.md).
[Configuration guide](../get-started/configuration.md).
## Google Cloud telemetry
+3 -3
View File
@@ -41,8 +41,8 @@ can change the theme using the `/theme` command.
### Theme persistence
Selected themes are saved in Gemini CLI's
[configuration](../reference/configuration.md) so your preference is remembered
across sessions.
[configuration](../get-started/configuration.md) so your preference is
remembered across sessions.
---
@@ -194,7 +194,7 @@ untrusted sources.
- Or, set it as the default by adding `"theme": "MyCustomTheme"` to the `ui`
object in your `settings.json`.
- Custom themes can be set at the user, project, or system level, and follow the
same [configuration precedence](../reference/configuration.md) as other
same [configuration precedence](../get-started/configuration.md) as other
settings.
### Themes from extensions
+2 -2
View File
@@ -121,6 +121,6 @@ immediately. Force a reload with:
- Learn about [Session management](session-management.md) to see how short-term
history works.
- Explore the [Command reference](../../reference/commands.md) for more
`/memory` options.
- Explore the [Command reference](../../cli/commands.md) for more `/memory`
options.
- Read the technical spec for [Project context](../../cli/gemini-md.md).
+1 -1
View File
@@ -101,5 +101,5 @@ This creates a new branch of history without losing your original work.
- Learn about [Checkpointing](../../cli/checkpointing.md) to understand the
underlying safety mechanism.
- Explore [Task planning](task-planning.md) to keep complex sessions organized.
- See the [Command reference](../../reference/commands.md) for all `/chat` and
- See the [Command reference](../../cli/commands.md) for all `/chat` and
`/resume` options.
+7 -7
View File
@@ -9,11 +9,11 @@ requests sent from `packages/cli`. For a general overview of Gemini CLI, see the
- **[Sub-agents (experimental)](./subagents.md):** Learn how to create and use
specialized sub-agents for complex tasks.
- **[Core tools API](../reference/tools-api.md):** Information on how tools are
defined, registered, and used by the core.
- **[Memory Import Processor](../reference/memport.md):** Documentation for the
modular GEMINI.md import feature using @file.md syntax.
- **[Policy Engine](../reference/policy-engine.md):** Use the Policy Engine for
- **[Core tools API](./tools-api.md):** Information on how tools are defined,
registered, and used by the core.
- **[Memory Import Processor](./memport.md):** Documentation for the modular
GEMINI.md import feature using @file.md syntax.
- **[Policy Engine](./policy-engine.md):** Use the Policy Engine for
fine-grained control over tool execution.
## Role of the core
@@ -92,8 +92,8 @@ This allows you to have global, project-level, and component-level context
files, which are all combined to provide the model with the most relevant
information.
You can use the [`/memory` command](../reference/commands.md) to `show`, `add`,
and `refresh` the content of loaded `GEMINI.md` files.
You can use the [`/memory` command](../cli/commands.md) to `show`, `add`, and
`refresh` the content of loaded `GEMINI.md` files.
## Citations
+39 -39
View File
@@ -1,13 +1,13 @@
# Sub-agents (experimental)
# Subagents (experimental)
Sub-agents are specialized agents that operate within your main Gemini CLI
Subagents are specialized agents that operate within your main Gemini CLI
session. They are designed to handle specific, complex tasks—like deep codebase
analysis, documentation lookup, or domain-specific reasoning—without cluttering
the main agent's context or toolset.
> **Note: Sub-agents are currently an experimental feature.**
> **Note: Subagents are currently an experimental feature.**
>
> To use custom sub-agents, you must explicitly enable them in your
> To use custom subagents, you must explicitly enable them in your
> `settings.json`:
>
> ```json
@@ -16,31 +16,31 @@ the main agent's context or toolset.
> }
> ```
>
> **Warning:** Sub-agents currently operate in
> ["YOLO mode"](../reference/configuration.md#command-line-arguments), meaning
> **Warning:** Subagents currently operate in
> ["YOLO mode"](../get-started/configuration.md#command-line-arguments), meaning
> they may execute tools without individual user confirmation for each step.
> Proceed with caution when defining agents with powerful tools like
> `run_shell_command` or `write_file`.
## What are sub-agents?
## What are subagents?
Sub-agents are "specialists" that the main Gemini agent can hire for a specific
Subagents are "specialists" that the main Gemini agent can hire for a specific
job.
- **Focused context:** Each sub-agent has its own system prompt and persona.
- **Specialized tools:** Sub-agents can have a restricted or specialized set of
- **Focused context:** Each subagent has its own system prompt and persona.
- **Specialized tools:** Subagents can have a restricted or specialized set of
tools.
- **Independent context window:** Interactions with a sub-agent happen in a
- **Independent context window:** Interactions with a subagent happen in a
separate context loop, which saves tokens in your main conversation history.
Sub-agents are exposed to the main agent as a tool of the same name. When the
main agent calls the tool, it delegates the task to the sub-agent. Once the
sub-agent completes its task, it reports back to the main agent with its
Subagents are exposed to the main agent as a tool of the same name. When the
main agent calls the tool, it delegates the task to the subagent. Once the
subagent completes its task, it reports back to the main agent with its
findings.
## Built-in sub-agents
## Built-in subagents
Gemini CLI comes with the following built-in sub-agents:
Gemini CLI comes with the following built-in subagents:
### Codebase Investigator
@@ -75,15 +75,15 @@ Gemini CLI comes with the following built-in sub-agents:
### Generalist Agent
- **Name:** `generalist_agent`
- **Purpose:** Route tasks to the appropriate specialized sub-agent.
- **Purpose:** Route tasks to the appropriate specialized subagent.
- **When to use:** Implicitly used by the main agent for routing. Not directly
invoked by the user.
- **Configuration:** Enabled by default. No specific configuration options.
## Creating custom sub-agents
## Creating custom subagents
You can create your own sub-agents to automate specific workflows or enforce
specific personas. To use custom sub-agents, you must enable them in your
You can create your own subagents to automate specific workflows or enforce
specific personas. To use custom subagents, you must enable them in your
`settings.json`:
```json
@@ -138,20 +138,20 @@ it yourself; just report it.
### Configuration schema
| Field | Type | Required | Description |
| :------------- | :----- | :------- | :------------------------------------------------------------------------------------------------------------------------- |
| `name` | string | Yes | Unique identifier (slug) used as the tool name for the agent. Only lowercase letters, numbers, hyphens, and underscores. |
| `description` | string | Yes | Short description of what the agent does. This is visible to the main agent to help it decide when to call this sub-agent. |
| `kind` | string | No | `local` (default) or `remote`. |
| `tools` | array | No | List of tool names this agent can use. If omitted, it may have access to a default set. |
| `model` | string | No | Specific model to use (e.g., `gemini-2.5-pro`). Defaults to `inherit` (uses the main session model). |
| `temperature` | number | No | Model temperature (0.0 - 2.0). |
| `max_turns` | number | No | Maximum number of conversation turns allowed for this agent before it must return. Defaults to `15`. |
| `timeout_mins` | number | No | Maximum execution time in minutes. Defaults to `5`. |
| Field | Type | Required | Description |
| :------------- | :----- | :------- | :------------------------------------------------------------------------------------------------------------------------ |
| `name` | string | Yes | Unique identifier (slug) used as the tool name for the agent. Only lowercase letters, numbers, hyphens, and underscores. |
| `description` | string | Yes | Short description of what the agent does. This is visible to the main agent to help it decide when to call this subagent. |
| `kind` | string | No | `local` (default) or `remote`. |
| `tools` | array | No | List of tool names this agent can use. If omitted, it may have access to a default set. |
| `model` | string | No | Specific model to use (e.g., `gemini-2.5-pro`). Defaults to `inherit` (uses the main session model). |
| `temperature` | number | No | Model temperature (0.0 - 2.0). |
| `max_turns` | number | No | Maximum number of conversation turns allowed for this agent before it must return. Defaults to `15`. |
| `timeout_mins` | number | No | Maximum execution time in minutes. Defaults to `5`. |
### Optimizing your sub-agent
### Optimizing your subagent
The main agent's system prompt encourages it to use an expert sub-agent when one
The main agent's system prompt encourages it to use an expert subagent when one
is available. It decides whether an agent is a relevant expert based on the
agent's description. You can improve the reliability with which an agent is used
by updating the description to more clearly indicate:
@@ -160,7 +160,7 @@ by updating the description to more clearly indicate:
- When it should be used.
- Some example scenarios.
For example, the following sub-agent description should be called fairly
For example, the following subagent description should be called fairly
consistently for Git operations.
> Git expert agent which should be used for all local and remote git operations.
@@ -170,13 +170,13 @@ consistently for Git operations.
> - Searching for regressions with bisect
> - Interacting with source control and issues providers such as GitHub.
If you need to further tune your sub-agent, you can do so by selecting the model
If you need to further tune your subagent, you can do so by selecting the model
to optimize for with `/model` and then asking the model why it does not think
that your sub-agent was called with a specific prompt and the given description.
that your subagent was called with a specific prompt and the given description.
## Remote subagents (Agent2Agent) (experimental)
Gemini CLI can also delegate tasks to remote sub-agents using the Agent-to-Agent
Gemini CLI can also delegate tasks to remote subagents using the Agent-to-Agent
(A2A) protocol.
> **Note: Remote subagents are currently an experimental feature.**
@@ -184,8 +184,8 @@ Gemini CLI can also delegate tasks to remote sub-agents using the Agent-to-Agent
See the [Remote Subagents documentation](/docs/core/remote-agents) for detailed
configuration and usage instructions.
## Extension sub-agents
## Extension subagents
Extensions can bundle and distribute sub-agents. See the
[Extensions documentation](../extensions/index.md#sub-agents) for details on how
Extensions can bundle and distribute subagents. See the
[Extensions documentation](../extensions/index.md#subagents) for details on how
to package agents within an extension.
+1 -1
View File
@@ -130,7 +130,7 @@ The manifest file defines the extension's behavior and configuration.
- `description`: A short summary shown in the extension gallery.
- <a id="mcp-servers"></a>`mcpServers`: A map of Model Context Protocol (MCP)
servers. Extension servers follow the same format as standard
[CLI configuration](../reference/configuration.md).
[CLI configuration](../get-started/configuration.md).
- `contextFileName`: The name of the context file (defaults to `GEMINI.md`). Can
also be an array of strings to load multiple context files.
- `excludeTools`: An array of tools to block from the model. You can restrict
+1 -1
View File
@@ -104,7 +104,7 @@ The Gemini CLI configuration is stored in two `settings.json` files:
1. In your home directory: `~/.gemini/settings.json`.
2. In your project's root directory: `./.gemini/settings.json`.
Refer to [Gemini CLI Configuration](../reference/configuration.md) for more
Refer to [Gemini CLI Configuration](./get-started/configuration.md) for more
details.
## Google AI Pro/Ultra and subscription FAQs
+4 -4
View File
@@ -22,8 +22,8 @@ Select the authentication method that matches your situation in the table below:
### What is my Google account type?
- **Individual Google accounts:** Includes all
[free tier accounts](../resources/quota-and-pricing.md#free-usage) such as
Gemini Code Assist for individuals, as well as paid subscriptions for
[free tier accounts](../quota-and-pricing/#free-usage) such as Gemini Code
Assist for individuals, as well as paid subscriptions for
[Google AI Pro and Ultra](https://gemini.google/subscriptions/).
- **Organization accounts:** Accounts using paid licenses through an
@@ -317,5 +317,5 @@ configure authentication using environment variables:
Your authentication method affects your quotas, pricing, Terms of Service, and
privacy notices. Review the following pages to learn more:
- [Gemini CLI: Quotas and Pricing](../resources/quota-and-pricing.md).
- [Gemini CLI: Terms of Service and Privacy Notice](../resources/tos-privacy.md).
- [Gemini CLI: Quotas and Pricing](../quota-and-pricing.md).
- [Gemini CLI: Terms of Service and Privacy Notice](../tos-privacy.md).
@@ -121,6 +121,11 @@ their corresponding top-level category object in your `settings.json` file.
- **Description:** Enable update notification prompts.
- **Default:** `true`
- **`general.enableNotifications`** (boolean):
- **Description:** Enable run-event notifications for action-required prompts
and session completion. Currently macOS only.
- **Default:** `false`
- **`general.checkpointing.enabled`** (boolean):
- **Description:** Enable session checkpointing for recovery
- **Default:** `false`
@@ -217,6 +222,11 @@ their corresponding top-level category object in your `settings.json` file.
- **Default:** `true`
- **Requires restart:** Yes
- **`ui.showCompatibilityWarnings`** (boolean):
- **Description:** Show warnings about terminal or OS compatibility issues.
- **Default:** `true`
- **Requires restart:** Yes
- **`ui.hideTips`** (boolean):
- **Description:** Hide helpful tips in the UI
- **Default:** `false`
@@ -940,6 +950,11 @@ their corresponding top-level category object in your `settings.json` file.
- **Default:** `false`
- **Requires restart:** Yes
- **`experimental.modelSteering`** (boolean):
- **Description:** Enable model steering (user hints) to guide the model
during tool execution.
- **Default:** `false`
#### `skills`
- **`skills.enabled`** (boolean):
@@ -1199,8 +1214,8 @@ within your user's home folder.
Environment variables are a common way to configure applications, especially for
sensitive information like API keys or for settings that might change between
environments. For authentication setup, see the
[Authentication documentation](../get-started/authentication.md) which covers
all available authentication methods.
[Authentication documentation](./authentication.md) which covers all available
authentication methods.
The CLI automatically loads environment variables from an `.env` file. The
loading order is:
@@ -1219,8 +1234,7 @@ the `advanced.excludedEnvVars` setting in your `settings.json` file.
- **`GEMINI_API_KEY`**:
- Your API key for the Gemini API.
- One of several available
[authentication methods](../get-started/authentication.md).
- One of several available [authentication methods](./authentication.md).
- Set this in your shell profile (e.g., `~/.bashrc`, `~/.zshrc`) or an `.env`
file.
- **`GEMINI_MODEL`**:
@@ -1566,16 +1580,15 @@ conventions and context.
about the active instructional context.
- **Importing content:** You can modularize your context files by importing
other Markdown files using the `@path/to/file.md` syntax. For more details,
see the [Memory Import Processor documentation](../reference/memport.md).
see the [Memory Import Processor documentation](../core/memport.md).
- **Commands for memory management:**
- Use `/memory refresh` to force a re-scan and reload of all context files
from all configured locations. This updates the AI's instructional context.
- Use `/memory show` to display the combined instructional context currently
loaded, allowing you to verify the hierarchy and content being used by the
AI.
- See the [Commands documentation](../reference/commands.md#memory) for full
details on the `/memory` command and its sub-commands (`show` and
`refresh`).
- See the [Commands documentation](../cli/commands.md#memory) for full details
on the `/memory` command and its sub-commands (`show` and `refresh`).
By understanding and utilizing these configuration layers and the hierarchical
nature of context files, you can effectively manage the AI's memory and tailor
+1 -1
View File
@@ -54,7 +54,7 @@ Gemini CLI offers several ways to configure its behavior, including environment
variables, command-line arguments, and settings files.
To explore your configuration options, see
[Gemini CLI Configuration](../reference/configuration.md).
[Gemini CLI Configuration](./configuration.md).
## Use
+1 -1
View File
@@ -420,7 +420,7 @@ When you open a project with hooks defined in `.gemini/settings.json`:
Hooks inherit the environment of the Gemini CLI process, which may include
sensitive API keys. Gemini CLI provides a
[redaction system](/docs/reference/configuration#environment-variable-redaction)
[redaction system](/docs/get-started/configuration#environment-variable-redaction)
that automatically filters variables matching sensitive patterns (e.g., `KEY`,
`TOKEN`).
+115 -105
View File
@@ -12,7 +12,8 @@ npm install -g @google/gemini-cli
## Get started
- **[Overview](./index.md):** An overview of Gemini CLI and its features.
Jump in to Gemini CLI.
- **[Quickstart](./get-started/index.md):** Your first session with Gemini CLI.
- **[Installation](./get-started/installation.md):** How to install Gemini CLI
on your system.
@@ -20,15 +21,17 @@ npm install -g @google/gemini-cli
personal and enterprise accounts.
- **[Examples](./get-started/examples.md):** Practical examples of Gemini CLI in
action.
- **[CLI cheatsheet](./cli/cli-reference.md):** A quick reference for common
- **[Cheatsheet](./cli/cli-reference.md):** A quick reference for common
commands and options.
- **[Gemini 3 on Gemini CLI](./get-started/gemini-3.md):** Learn how to use
Gemini 3 with Gemini CLI.
## Use Gemini CLI
User-focused guides and tutorials for daily development workflows.
- **[File management](./cli/tutorials/file-management.md):** How to work with
local files and directories.
- **[Get started with Agent skills](./cli/tutorials/skills-getting-started.md):**
Getting started with specialized expertise.
- **[Manage context and memory](./cli/tutorials/memory-management.md):**
Managing persistent instructions and facts.
- **[Execute shell commands](./cli/tutorials/shell-commands.md):** Executing
@@ -39,121 +42,128 @@ npm install -g @google/gemini-cli
complex workflows.
- **[Web search and fetch](./cli/tutorials/web-tools.md):** Searching and
fetching content from the web.
- **[Get started with skills](./cli/tutorials/skills-getting-started.md):**
Getting started with specialized expertise.
- **[Set up an MCP server](./cli/tutorials/mcp-setup.md):** Learn how to set up
a Model-Context Protocol server.
- **[Automate tasks](./cli/tutorials/automation.md):** Automate common
development tasks.
- **[Set up an MCP server](./cli/tutorials/mcp-setup.md):** Set up an MCP
server.
- **[Automate tasks](./cli/tutorials/automation.md):** Automate tasks.
## Features
- **[Agent Skills](./cli/skills.md):** Extend the capabilities of Gemini CLI
with custom skills.
- **[Authentication](./get-started/authentication.md):** Learn about the
different authentication methods.
- **[Checkpointing](./cli/checkpointing.md):** Automatically save and restore
your sessions.
- **[Extensions](./extensions/index.md):** Extend the functionality of Gemini
CLI with extensions.
- **[Headless mode](./cli/headless.md):** Run Gemini CLI in a non-interactive
mode.
- **[Hooks](./hooks/index.md):** Customize the behavior of Gemini CLI with
hooks.
- **[IDE integration](./ide-integration/index.md):** Integrate Gemini CLI with
your favorite IDE.
- **[MCP servers](./tools/mcp-server.md):** Connect to Model-Context Protocol
servers.
- **[Model routing](./cli/model-routing.md):** Automatically route requests to
the best model.
- **[Model selection](./cli/model.md):** Learn how to select the right model for
your task.
- **[Plan mode (experimental)](./cli/plan-mode.md):** Plan and review changes
before they are executed.
- **[Rewind](./cli/rewind.md):** Rewind the conversation to a previous point.
- **[Sandboxing](./cli/sandbox.md):** Isolate tool execution in a sandboxed
environment.
- **[Settings](./cli/settings.md):** Customize the behavior of Gemini CLI.
- **[Telemetry](./cli/telemetry.md):** Understand the usage data that Gemini CLI
collects.
- **[Token caching](./cli/token-caching.md):** Cache tokens to improve
performance.
Technical reference documentation for each capability of Gemini CLI.
- **[/about](./cli/commands.md#about):** About Gemini CLI.
- **[/auth](./get-started/authentication.md):** Authentication.
- **[/bug](./cli/commands.md#bug):** Report a bug.
- **[/chat](./cli/commands.md#chat):** Chat history.
- **[/clear](./cli/commands.md#clear):** Clear screen.
- **[/compress](./cli/commands.md#compress):** Compress context.
- **[/copy](./cli/commands.md#copy):** Copy output.
- **[/directory](./cli/commands.md#directory-or-dir):** Manage workspace.
- **[/docs](./cli/commands.md#docs):** Open documentation.
- **[/editor](./cli/commands.md#editor):** Select editor.
- **[/extensions](./extensions/index.md):** Manage extensions.
- **[/help](./cli/commands.md#help-or):** Show help.
- **[/hooks](./hooks/index.md):** Hooks.
- **[/ide](./ide-integration/index.md):** IDE integration.
- **[/init](./cli/commands.md#init):** Initialize context.
- **[/mcp](./tools/mcp-server.md):** MCP servers.
- **[/memory](./cli/commands.md#memory):** Manage memory.
- **[/model](./cli/model.md):** Model selection.
- **[/policies](./cli/commands.md#policies):** Manage policies.
- **[/privacy](./cli/commands.md#privacy):** Privacy notice.
- **[/quit](./cli/commands.md#quit-or-exit):** Exit CLI.
- **[/restore](./cli/checkpointing.md):** Restore files.
- **[/resume](./cli/commands.md#resume):** Resume session.
- **[/rewind](./cli/rewind.md):** Rewind.
- **[/settings](./cli/settings.md):** Settings.
- **[/setup-github](./cli/commands.md#setup-github):** GitHub setup.
- **[/shells](./cli/commands.md#shells-or-bashes):** Manage processes.
- **[/skills](./cli/skills.md):** Agent skills.
- **[/stats](./cli/commands.md#stats):** Session statistics.
- **[/terminal-setup](./cli/commands.md#terminal-setup):** Terminal keybindings.
- **[/theme](./cli/themes.md):** Themes.
- **[/tools](./cli/commands.md#tools):** List tools.
- **[/vim](./cli/commands.md#vim):** Vim mode.
- **[Activate skill (tool)](./tools/activate-skill.md):** Internal mechanism for
loading expert procedures.
- **[Ask user (tool)](./tools/ask-user.md):** Internal dialog system for
clarification.
- **[Checkpointing](./cli/checkpointing.md):** Automatic session snapshots.
- **[File system (tool)](./tools/file-system.md):** Technical details for local
file operations.
- **[Headless mode](./cli/headless.md):** Programmatic and scripting interface.
- **[Internal documentation (tool)](./tools/internal-docs.md):** Technical
lookup for CLI features.
- **[Memory (tool)](./tools/memory.md):** Storage details for persistent facts.
- **[Model routing](./cli/model-routing.md):** Automatic fallback resilience.
- **[Plan mode 🧪](./cli/plan-mode.md):** Use a safe, read-only mode for
planning complex changes.
- **[Remote subagents 🧪](./core/remote-agents.md):** Connecting to and using
remote agents.
- **[Sandboxing](./cli/sandbox.md):** Isolate tool execution.
- **[Shell (tool)](./tools/shell.md):** Detailed system execution parameters.
- **[Subagents 🧪](./core/subagents.md):** Using specialized agents for specific
tasks.
- **[Telemetry](./cli/telemetry.md):** Usage and performance metric details.
- **[Todo (tool)](./tools/todos.md):** Progress tracking specification.
- **[Token caching](./cli/token-caching.md):** Performance optimization.
- **[Web fetch (tool)](./tools/web-fetch.md):** URL retrieval and extraction
details.
- **[Web search (tool)](./tools/web-search.md):** Google Search integration
technicals.
## Configuration
- **[Custom commands](./cli/custom-commands.md):** Create your own custom
commands.
- **[Enterprise configuration](./cli/enterprise.md):** Configure Gemini CLI for
your enterprise.
- **[Ignore files (.geminiignore)](./cli/gemini-ignore.md):** Ignore files and
directories.
- **[Model configuration](./cli/generation-settings.md):** Configure the
generation settings for your models.
- **[Project context (GEMINI.md)](./cli/gemini-md.md):** Provide
project-specific context to the model.
- **[Settings](./cli/settings.md):** A full reference of all the available
settings.
- **[System prompt override](./cli/system-prompt.md):** Override the default
system prompt.
- **[Themes](./cli/themes.md):** Customize the look and feel of Gemini CLI.
- **[Trusted folders](./cli/trusted-folders.md):** Configure trusted folders to
bypass security warnings.
Settings and customization options for Gemini CLI.
## Extensions
- **[Overview](./extensions/index.md):** An overview of the extension system.
- **[User guide: Install and manage](./extensions/index.md#manage-extensions):**
Learn how to install and manage extensions.
- **[Developer guide: Build extensions](./extensions/writing-extensions.md):**
Learn how to build your own extensions.
- **[Developer guide: Best practices](./extensions/best-practices.md):** Best
practices for writing extensions.
- **[Developer guide: Releasing](./extensions/releasing.md):** Learn how to
release your extensions.
- **[Developer guide: Reference](./extensions/reference.md):** A full reference
of the extension API.
## Development
- **[Contribution guide](/docs/contributing):** Learn how to contribute to
Gemini CLI.
- **[Integration testing](./integration-tests.md):** Learn how to run the
integration tests.
- **[Issue and PR automation](./issue-and-pr-automation.md):** Learn about the
issue and PR automation.
- **[Local development](./local-development.md):** Learn how to set up your
local development environment.
- **[NPM package structure](./npm.md):** Learn about the structure of the NPM
packages.
- **[Custom commands](./cli/custom-commands.md):** Personalized shortcuts.
- **[Enterprise configuration](./cli/enterprise.md):** Professional environment
controls.
- **[Ignore files (.geminiignore)](./cli/gemini-ignore.md):** Exclusion pattern
reference.
- **[Model configuration](./cli/generation-settings.md):** Fine-tune generation
parameters like temperature and thinking budget.
- **[Project context (GEMINI.md)](./cli/gemini-md.md):** Technical hierarchy of
context files.
- **[Settings](./cli/settings.md):** Full configuration reference.
- **[System prompt override](./cli/system-prompt.md):** Instruction replacement
logic.
- **[Themes](./cli/themes.md):** UI personalization technical guide.
- **[Trusted folders](./cli/trusted-folders.md):** Security permission logic.
## Reference
- **[Command reference](./reference/commands.md):** A full reference of all the
available commands.
- **[Configuration reference](./reference/configuration.md):** A full reference
of all the available configuration options.
- **[Keyboard shortcuts](./reference/keyboard-shortcuts.md):** A list of all the
available keyboard shortcuts.
- **[Memory import processor](./reference/memport.md):** Learn how to use the
memory import processor.
- **[Policy engine](./reference/policy-engine.md):** Learn how to use the policy
engine.
- **[Tools API](./reference/tools-api.md):** Learn how to use the tools API.
Deep technical documentation and API specifications.
- **[Command reference](./cli/commands.md):** Detailed slash command guide.
- **[Configuration reference](./get-started/configuration.md):** Settings and
environment variables.
- **[Keyboard shortcuts](./cli/keyboard-shortcuts.md):** Productivity tips.
- **[Memory import processor](./core/memport.md):** How Gemini CLI processes
memory from various sources.
- **[Policy engine](./core/policy-engine.md):** Fine-grained execution control.
- **[Tools API](./core/tools-api.md):** The API for defining and using tools.
## Resources
- **[Quota and pricing](./resources/quota-and-pricing.md):** Information about
quota and pricing.
- **[Terms and privacy](./resources/tos-privacy.md):** The terms of service and
privacy policy.
- **[FAQ](./resources/faq.md):** Frequently asked questions.
- **[Troubleshooting](./resources/troubleshooting.md):** Troubleshooting common
issues.
- **[Uninstall](./resources/uninstall.md):** How to uninstall Gemini CLI.
Support, release history, and legal information.
## Changelog
- **[FAQ](./faq.md):** Answers to frequently asked questions.
- **[Changelogs](./changelogs/index.md):** Highlights and notable changes.
- **[Quota and pricing](./quota-and-pricing.md):** Limits and billing details.
- **[Terms and privacy](./tos-privacy.md):** Official notices and terms.
- **[Release notes](./changelogs/index.md):** The release notes for all
versions.
## Development
- **[Contribution guide](/docs/contributing):** How to contribute to Gemini CLI.
- **[Integration testing](./integration-tests.md):** Running integration tests.
- **[Issue and PR automation](./issue-and-pr-automation.md):** Automation for
issues and pull requests.
- **[Local development](./local-development.md):** Setting up a local
development environment.
- **[NPM package structure](./npm.md):** The structure of the NPM packages.
## Releases
- **[Release notes](./changelogs/index.md):** Release notes for all versions.
- **[Stable release](./changelogs/latest.md):** The latest stable release.
- **[Preview release](./changelogs/preview.md):** The latest preview release.
+160 -190
View File
@@ -1,223 +1,193 @@
[
{
"label": "docs_tab",
"label": "Get started",
"items": [
{ "label": "Overview", "slug": "docs" },
{ "label": "Quickstart", "slug": "docs/get-started" },
{ "label": "Installation", "slug": "docs/get-started/installation" },
{ "label": "Authentication", "slug": "docs/get-started/authentication" },
{ "label": "Examples", "slug": "docs/get-started/examples" },
{ "label": "CLI cheatsheet", "slug": "docs/cli/cli-reference" },
{ "label": "Gemini 3 on Gemini CLI", "slug": "docs/get-started/gemini-3" }
]
},
{
"label": "Use Gemini CLI",
"items": [
{
"label": "Get started",
"items": [
{ "label": "Overview", "slug": "docs" },
{ "label": "Quickstart", "slug": "docs/get-started" },
{ "label": "Installation", "slug": "docs/get-started/installation" },
{
"label": "Authentication",
"slug": "docs/get-started/authentication"
},
{ "label": "Examples", "slug": "docs/get-started/examples" },
{ "label": "CLI cheatsheet", "slug": "docs/cli/cli-reference" },
{
"label": "Gemini 3 on Gemini CLI",
"slug": "docs/get-started/gemini-3"
}
]
"label": "File management",
"slug": "docs/cli/tutorials/file-management"
},
{
"label": "Use Gemini CLI",
"items": [
{
"label": "File management",
"slug": "docs/cli/tutorials/file-management"
},
{
"label": "Manage context and memory",
"slug": "docs/cli/tutorials/memory-management"
},
{
"label": "Execute shell commands",
"slug": "docs/cli/tutorials/shell-commands"
},
{
"label": "Manage sessions and history",
"slug": "docs/cli/tutorials/session-management"
},
{
"label": "Plan tasks with todos",
"slug": "docs/cli/tutorials/task-planning"
},
{
"label": "Web search and fetch",
"slug": "docs/cli/tutorials/web-tools"
},
{
"label": "Get started with skills",
"slug": "docs/cli/tutorials/skills-getting-started"
},
{
"label": "Set up an MCP server",
"slug": "docs/cli/tutorials/mcp-setup"
},
{ "label": "Automate tasks", "slug": "docs/cli/tutorials/automation" }
]
"label": "Get started with Agent skills",
"slug": "docs/cli/tutorials/skills-getting-started"
},
{
"label": "Features",
"items": [
{ "label": "Agent Skills", "slug": "docs/cli/skills" },
{
"label": "Authentication",
"slug": "docs/get-started/authentication"
},
{ "label": "Checkpointing", "slug": "docs/cli/checkpointing" },
{
"label": "Extensions",
"slug": "docs/extensions/index"
},
{ "label": "Headless mode", "slug": "docs/cli/headless" },
{ "label": "Hooks", "slug": "docs/hooks" },
{ "label": "IDE integration", "slug": "docs/ide-integration" },
{ "label": "MCP servers", "slug": "docs/tools/mcp-server" },
{ "label": "Model routing", "slug": "docs/cli/model-routing" },
{ "label": "Model selection", "slug": "docs/cli/model" },
{ "label": "Plan mode (experimental)", "slug": "docs/cli/plan-mode" },
{ "label": "Rewind", "slug": "docs/cli/rewind" },
{ "label": "Sandboxing", "slug": "docs/cli/sandbox" },
{ "label": "Settings", "slug": "docs/cli/settings" },
{ "label": "Telemetry", "slug": "docs/cli/telemetry" },
{ "label": "Token caching", "slug": "docs/cli/token-caching" }
]
"label": "Manage context and memory",
"slug": "docs/cli/tutorials/memory-management"
},
{
"label": "Configuration",
"items": [
{ "label": "Custom commands", "slug": "docs/cli/custom-commands" },
{
"label": "Enterprise configuration",
"slug": "docs/cli/enterprise"
},
{
"label": "Ignore files (.geminiignore)",
"slug": "docs/cli/gemini-ignore"
},
{
"label": "Model configuration",
"slug": "docs/cli/generation-settings"
},
{
"label": "Project context (GEMINI.md)",
"slug": "docs/cli/gemini-md"
},
{ "label": "Settings", "slug": "docs/cli/settings" },
{
"label": "System prompt override",
"slug": "docs/cli/system-prompt"
},
{ "label": "Themes", "slug": "docs/cli/themes" },
{ "label": "Trusted folders", "slug": "docs/cli/trusted-folders" }
]
"label": "Execute shell commands",
"slug": "docs/cli/tutorials/shell-commands"
},
{
"label": "Manage sessions and history",
"slug": "docs/cli/tutorials/session-management"
},
{
"label": "Plan tasks with todos",
"slug": "docs/cli/tutorials/task-planning"
},
{
"label": "Web search and fetch",
"slug": "docs/cli/tutorials/web-tools"
},
{
"label": "Set up an MCP server",
"slug": "docs/cli/tutorials/mcp-setup"
},
{ "label": "Automate tasks", "slug": "docs/cli/tutorials/automation" }
]
},
{
"label": "Features",
"items": [
{ "label": "Agent Skills", "slug": "docs/cli/skills" },
{
"label": "Authentication",
"slug": "docs/get-started/authentication"
},
{ "label": "Checkpointing", "slug": "docs/cli/checkpointing" },
{
"label": "Extensions",
"items": [
{
"label": "Overview",
"slug": "docs/extensions"
},
{
"label": "User guide: Install and manage",
"link": "/docs/extensions/#manage-extensions"
},
{
"label": "Developer guide: Build extensions",
"slug": "docs/extensions/writing-extensions"
},
{
"label": "Developer guide: Best practices",
"slug": "docs/extensions/best-practices"
},
{
"label": "Developer guide: Releasing",
"slug": "docs/extensions/releasing"
},
{
"label": "Developer guide: Reference",
"slug": "docs/extensions/reference"
}
]
"slug": "docs/extensions/index"
},
{ "label": "Headless mode", "slug": "docs/cli/headless" },
{ "label": "Help", "link": "/docs/cli/commands/#help-or" },
{ "label": "Hooks", "slug": "docs/hooks" },
{ "label": "IDE integration", "slug": "docs/ide-integration" },
{ "label": "MCP servers", "slug": "docs/tools/mcp-server" },
{
"label": "Memory management",
"link": "/docs/cli/commands/#memory"
},
{ "label": "Model routing", "slug": "docs/cli/model-routing" },
{ "label": "Model selection", "slug": "docs/cli/model" },
{ "label": "Plan mode", "badge": "🧪", "slug": "docs/cli/plan-mode" },
{ "label": "Subagents", "badge": "🧪", "slug": "docs/core/subagents" },
{
"label": "Remote subagents",
"badge": "🧪",
"slug": "docs/core/remote-agents"
},
{ "label": "Rewind", "slug": "docs/cli/rewind" },
{ "label": "Sandboxing", "slug": "docs/cli/sandbox" },
{ "label": "Settings", "slug": "docs/cli/settings" },
{
"label": "Shell",
"link": "/docs/cli/commands/#shells-or-bashes"
},
{
"label": "Development",
"items": [
{ "label": "Contribution guide", "slug": "docs/contributing" },
{ "label": "Integration testing", "slug": "docs/integration-tests" },
{
"label": "Issue and PR automation",
"slug": "docs/issue-and-pr-automation"
},
{ "label": "Local development", "slug": "docs/local-development" },
{ "label": "NPM package structure", "slug": "docs/npm" }
]
"label": "Stats",
"link": "/docs/cli/commands/#stats"
},
{ "label": "Telemetry", "slug": "docs/cli/telemetry" },
{ "label": "Token caching", "slug": "docs/cli/token-caching" },
{ "label": "Tools", "link": "/docs/cli/commands/#tools" }
]
},
{
"label": "Configuration",
"items": [
{ "label": "Custom commands", "slug": "docs/cli/custom-commands" },
{ "label": "Enterprise configuration", "slug": "docs/cli/enterprise" },
{
"label": "Ignore files (.geminiignore)",
"slug": "docs/cli/gemini-ignore"
},
{
"label": "Model configuration",
"slug": "docs/cli/generation-settings"
},
{ "label": "Project context (GEMINI.md)", "slug": "docs/cli/gemini-md" },
{ "label": "Settings", "slug": "docs/cli/settings" },
{ "label": "System prompt override", "slug": "docs/cli/system-prompt" },
{ "label": "Themes", "slug": "docs/cli/themes" },
{ "label": "Trusted folders", "slug": "docs/cli/trusted-folders" }
]
},
{
"label": "Extensions",
"items": [
{
"label": "Overview",
"slug": "docs/extensions"
},
{
"label": "User guide: Install and manage",
"link": "/docs/extensions/#manage-extensions"
},
{
"label": "Developer guide: Build extensions",
"slug": "docs/extensions/writing-extensions"
},
{
"label": "Developer guide: Best practices",
"slug": "docs/extensions/best-practices"
},
{
"label": "Developer guide: Releasing",
"slug": "docs/extensions/releasing"
},
{
"label": "Developer guide: Reference",
"slug": "docs/extensions/reference"
}
]
},
{
"label": "reference_tab",
"label": "Reference",
"items": [
{ "label": "Command reference", "slug": "docs/cli/commands" },
{
"label": "Reference",
"items": [
{ "label": "Command reference", "slug": "docs/reference/commands" },
{
"label": "Configuration reference",
"slug": "docs/reference/configuration"
},
{
"label": "Keyboard shortcuts",
"slug": "docs/reference/keyboard-shortcuts"
},
{
"label": "Memory import processor",
"slug": "docs/reference/memport"
},
{ "label": "Policy engine", "slug": "docs/reference/policy-engine" },
{ "label": "Tools API", "slug": "docs/reference/tools-api" }
]
}
"label": "Configuration reference",
"slug": "docs/get-started/configuration"
},
{ "label": "Keyboard shortcuts", "slug": "docs/cli/keyboard-shortcuts" },
{ "label": "Memory import processor", "slug": "docs/core/memport" },
{ "label": "Policy engine", "slug": "docs/core/policy-engine" },
{ "label": "Tools API", "slug": "docs/core/tools-api" }
]
},
{
"label": "resources_tab",
"label": "Resources",
"items": [
{
"label": "Resources",
"items": [
{
"label": "Quota and pricing",
"slug": "docs/resources/quota-and-pricing"
},
{
"label": "Terms and privacy",
"slug": "docs/resources/tos-privacy"
},
{ "label": "FAQ", "slug": "docs/resources/faq" },
{
"label": "Troubleshooting",
"slug": "docs/resources/troubleshooting"
},
{ "label": "Uninstall", "slug": "docs/resources/uninstall" }
]
}
{ "label": "FAQ", "slug": "docs/faq" },
{ "label": "Quota and pricing", "slug": "docs/quota-and-pricing" },
{ "label": "Terms and privacy", "slug": "docs/tos-privacy" },
{ "label": "Troubleshooting", "slug": "docs/troubleshooting" },
{ "label": "Uninstall", "slug": "docs/cli/uninstall" }
]
},
{
"label": "changelog_tab",
"label": "Development",
"items": [
{ "label": "Contribution guide", "slug": "docs/contributing" },
{ "label": "Integration testing", "slug": "docs/integration-tests" },
{
"label": "Changelog",
"items": [
{ "label": "Release notes", "slug": "docs/changelogs/" },
{ "label": "Stable release", "slug": "docs/changelogs/latest" },
{ "label": "Preview release", "slug": "docs/changelogs/preview" }
]
}
"label": "Issue and PR automation",
"slug": "docs/issue-and-pr-automation"
},
{ "label": "Local development", "slug": "docs/local-development" },
{ "label": "NPM package structure", "slug": "docs/npm" }
]
},
{
"label": "Releases",
"items": [
{ "label": "Release notes", "slug": "docs/changelogs/" },
{ "label": "Stable release", "slug": "docs/changelogs/latest" },
{ "label": "Preview release", "slug": "docs/changelogs/preview" }
]
}
]
+2 -2
View File
@@ -98,5 +98,5 @@ Always review confirmation prompts carefully before allowing a tool to execute.
## Next steps
- Learn how to [Provide context](../cli/gemini-md.md) to guide tool use.
- Explore the [Command reference](../reference/commands.md) for tool-related
slash commands.
- Explore the [Command reference](../cli/commands.md) for tool-related slash
commands.
+3 -3
View File
@@ -40,7 +40,7 @@ Gemini CLI uses this tool to ensure technical accuracy:
## Next steps
- Explore the [Command reference](../reference/commands.md) for a detailed guide
to slash commands.
- See the [Configuration guide](../reference/configuration.md) for settings
- Explore the [Command reference](../cli/commands.md) for a detailed guide to
slash commands.
- See the [Configuration guide](../get-started/configuration.md) for settings
reference.
+3 -3
View File
@@ -131,9 +131,9 @@ configuration file.
commands. Including the generic `run_shell_command` acts as a wildcard,
allowing any command not explicitly blocked.
- `tools.exclude` [DEPRECATED]: To block specific commands, use the
[Policy Engine](../reference/policy-engine.md). Historically, this setting
allowed adding entries to the `exclude` list under the `tools` category in the
format `run_shell_command(<command>)`. For example,
[Policy Engine](../core/policy-engine.md). Historically, this setting allowed
adding entries to the `exclude` list under the `tools` category in the format
`run_shell_command(<command>)`. For example,
`"tools": {"exclude": ["run_shell_command(rm)"]}` will block `rm` commands.
The validation logic is designed to be secure and flexible:
@@ -10,8 +10,8 @@ and Privacy Notices applicable to those services apply to such access and use.
Your Gemini CLI Usage Statistics are handled in accordance with Google's Privacy
Policy.
**Note:** See [quotas and pricing](/docs/resources/quota-and-pricing.md) for the
quota and pricing details that apply to your usage of the Gemini CLI.
**Note:** See [quotas and pricing](/docs/quota-and-pricing.md) for the quota and
pricing details that apply to your usage of the Gemini CLI.
## Supported authentication methods
@@ -93,4 +93,4 @@ backend, these Terms of Service and Privacy Notice documents apply:
You may opt-out from sending Gemini CLI Usage Statistics to Google by following
the instructions available here:
[Usage Statistics Configuration](https://github.com/google-gemini/gemini-cli/blob/main/docs/reference/configuration.md#usage-statistics).
[Usage Statistics Configuration](https://github.com/google-gemini/gemini-cli/blob/main/docs/get-started/configuration.md#usage-statistics).
@@ -93,7 +93,7 @@ topics on:
- **Cause:** When sandboxing is enabled, Gemini CLI may attempt operations
that are restricted by your sandbox configuration, such as writing outside
the project directory or system temp directory.
- **Solution:** Refer to the [Configuration: Sandboxing](../cli/sandbox.md)
- **Solution:** Refer to the [Configuration: Sandboxing](./cli/sandbox.md)
documentation for more information, including how to customize your sandbox
configuration.
+7 -1
View File
@@ -63,7 +63,7 @@ const external = [
'@lydell/node-pty-win32-arm64',
'@lydell/node-pty-win32-x64',
'keytar',
'gemini-cli-devtools',
'@google/gemini-cli-devtools',
];
const baseConfig = {
@@ -75,6 +75,10 @@ const baseConfig = {
write: true,
};
const commonAliases = {
punycode: 'punycode/',
};
const cliConfig = {
...baseConfig,
banner: {
@@ -88,6 +92,7 @@ const cliConfig = {
plugins: createWasmPlugins(),
alias: {
'is-in-ci': path.resolve(__dirname, 'packages/cli/src/patches/is-in-ci.ts'),
...commonAliases,
},
metafile: true,
};
@@ -103,6 +108,7 @@ const a2aServerConfig = {
'process.env.CLI_VERSION': JSON.stringify(pkg.version),
},
plugins: createWasmPlugins(),
alias: commonAliases,
};
Promise.allSettled([
+4 -9
View File
@@ -6,17 +6,12 @@
import { describe, expect } from 'vitest';
import { evalTest } from './test-helper.js';
import {
assertModelHasOutput,
checkModelOutputContent,
} from '../integration-tests/test-helper.js';
import { assertModelHasOutput } from '../integration-tests/test-helper.js';
describe('Hierarchical Memory', () => {
const TEST_PREFIX = 'Hierarchical memory test: ';
const conflictResolutionTest =
'Agent follows hierarchy for contradictory instructions';
evalTest('ALWAYS_PASSES', {
evalTest('USUALLY_PASSES', {
name: conflictResolutionTest,
params: {
settings: {
@@ -52,7 +47,7 @@ What is my favorite fruit? Tell me just the name of the fruit.`,
});
const provenanceAwarenessTest = 'Agent is aware of memory provenance';
evalTest('ALWAYS_PASSES', {
evalTest('USUALLY_PASSES', {
name: provenanceAwarenessTest,
params: {
settings: {
@@ -91,7 +86,7 @@ Provide the answer as an XML block like this:
});
const extensionVsGlobalTest = 'Extension memory wins over Global memory';
evalTest('ALWAYS_PASSES', {
evalTest('USUALLY_PASSES', {
name: extensionVsGlobalTest,
params: {
settings: {
+89
View File
@@ -0,0 +1,89 @@
/**
* @license
* Copyright 2026 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import { describe, expect } from 'vitest';
import { act } from 'react';
import path from 'node:path';
import fs from 'node:fs';
import { appEvalTest } from './app-test-helper.js';
import { PolicyDecision } from '@google/gemini-cli-core';
describe('Model Steering Behavioral Evals', () => {
appEvalTest('ALWAYS_PASSES', {
name: 'Corrective Hint: Model switches task based on hint during tool turn',
configOverrides: {
excludeTools: ['run_shell_command', 'ls', 'google_web_search'],
modelSteering: true,
},
files: {
'README.md':
'# Gemini CLI\nThis is a tool for developers.\nLicense: Apache-2.0\nLine 4\nLine 5\nLine 6',
},
prompt: 'Find the first 5 lines of README.md',
setup: async (rig) => {
// Pause on any relevant tool to inject a corrective hint
rig.setBreakpoint(['read_file', 'list_directory', 'glob']);
},
assert: async (rig) => {
// Wait for the model to pause on any tool call
await rig.waitForPendingConfirmation(
/read_file|list_directory|glob/i,
30000,
);
// Interrupt with a corrective hint
await rig.addUserHint(
'Actually, stop what you are doing. Just tell me a short knock-knock joke about a robot instead.',
);
// Resolve the tool to let the turn finish and the model see the hint
await rig.resolveAwaitedTool();
// Verify the model pivots to the new task
await rig.waitForOutput(/Knock,? knock/i, 40000);
await rig.waitForIdle(30000);
const output = rig.getStaticOutput();
expect(output).toMatch(/Knock,? knock/i);
expect(output).not.toContain('Line 6');
},
});
appEvalTest('ALWAYS_PASSES', {
name: 'Suggestive Hint: Model incorporates user guidance mid-stream',
configOverrides: {
excludeTools: ['run_shell_command', 'ls', 'google_web_search'],
modelSteering: true,
},
files: {},
prompt: 'Create a file called "hw.js" with a JS hello world.',
setup: async (rig) => {
// Pause on write_file to inject a suggestive hint
rig.setBreakpoint(['write_file']);
},
assert: async (rig) => {
// Wait for the model to start creating the first file
await rig.waitForPendingConfirmation('write_file', 30000);
await rig.addUserHint(
'Next, create a file called "hw.py" with a python hello world.',
);
// Resolve and wait for the model to complete both tasks
await rig.resolveAwaitedTool();
await rig.waitForPendingConfirmation('write_file', 30000);
await rig.resolveAwaitedTool();
await rig.waitForIdle(60000);
const testDir = rig.getTestDir();
const hwJs = path.join(testDir, 'hw.js');
const hwPy = path.join(testDir, 'hw.py');
expect(fs.existsSync(hwJs), 'hw.js should exist').toBe(true);
expect(fs.existsSync(hwPy), 'hw.py should exist').toBe(true);
},
});
});
+42 -1
View File
@@ -57,6 +57,47 @@ describe('plan_mode', () => {
},
});
evalTest('USUALLY_PASSES', {
name: 'should refuse saving new documentation to the repo when in plan mode',
approvalMode: ApprovalMode.PLAN,
params: {
settings,
},
prompt:
'This architecture overview is great. Please save it as architecture-new.md in the docs/ folder of the repo so we have it for later.',
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;
}
});
// It should NOT write to the docs folder or any other repo path
const hasRepoWrite = writeTargets.some(
(path) => path && !path.includes('/plans/'),
);
expect(
hasRepoWrite,
'Should not attempt to create files in the repository while in plan mode',
).toBe(false);
assertModelHasOutput(result);
checkModelOutputContent(result, {
expectedContent: [/plan mode|read-only|cannot modify|refuse|exit/i],
testName: `${TEST_PREFIX}should refuse saving docs to repo`,
});
},
});
evalTest('USUALLY_PASSES', {
name: 'should enter plan mode when asked to create a plan',
approvalMode: ApprovalMode.DEFAULT,
@@ -85,7 +126,7 @@ describe('plan_mode', () => {
'# 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.',
'The plan in plans/my-plan.md looks solid. Start 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(
+297
View File
@@ -0,0 +1,297 @@
/**
* @license
* Copyright 2026 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import { describe, expect } from 'vitest';
import { evalTest } from './test-helper.js';
import path from 'node:path';
import fs from 'node:fs';
import crypto from 'node:crypto';
// Recursive function to find a directory by name
function findDir(base: string, name: string): string | null {
if (!fs.existsSync(base)) return null;
const files = fs.readdirSync(base);
for (const file of files) {
const fullPath = path.join(base, file);
if (fs.statSync(fullPath).isDirectory()) {
if (file === name) return fullPath;
const found = findDir(fullPath, name);
if (found) return found;
}
}
return null;
}
describe('Tool Output Masking Behavioral Evals', () => {
/**
* Scenario: The agent needs information that was masked in a previous turn.
* It should recognize the <tool_output_masked> tag and use a tool to read the file.
*/
evalTest('USUALLY_PASSES', {
name: 'should attempt to read the redirected full output file when information is masked',
params: {
security: {
folderTrust: {
enabled: true,
},
},
},
prompt: '/help',
assert: async (rig) => {
// 1. Initialize project directories
await rig.run({ args: '/help' });
// 2. Discover the project temp dir
const chatsDir = findDir(path.join(rig.homeDir!, '.gemini'), 'chats');
if (!chatsDir) throw new Error('Could not find chats directory');
const projectTempDir = path.dirname(chatsDir);
const sessionId = crypto.randomUUID();
const toolOutputsDir = path.join(
projectTempDir,
'tool-outputs',
`session-${sessionId}`,
);
fs.mkdirSync(toolOutputsDir, { recursive: true });
const secretValue = 'THE_RECOVERED_SECRET_99';
const outputFileName = `masked_output_${crypto.randomUUID()}.txt`;
const outputFilePath = path.join(toolOutputsDir, outputFileName);
fs.writeFileSync(
outputFilePath,
`Some padding...\nThe secret key is: ${secretValue}\nMore padding...`,
);
const maskedSnippet = `<tool_output_masked>
Output: [PREVIEW]
Output too large. Full output available at: ${outputFilePath}
</tool_output_masked>`;
// 3. Inject manual session file
const conversation = {
sessionId: sessionId,
projectHash: path.basename(projectTempDir),
startTime: new Date().toISOString(),
lastUpdated: new Date().toISOString(),
messages: [
{
id: 'msg_1',
timestamp: new Date().toISOString(),
type: 'user',
content: [{ text: 'Get secret.' }],
},
{
id: 'msg_2',
timestamp: new Date().toISOString(),
type: 'gemini',
model: 'gemini-3-flash-preview',
toolCalls: [
{
id: 'call_1',
name: 'run_shell_command',
args: { command: 'get_secret' },
status: 'success',
timestamp: new Date().toISOString(),
result: [
{
functionResponse: {
id: 'call_1',
name: 'run_shell_command',
response: { output: maskedSnippet },
},
},
],
},
],
content: [{ text: 'I found a masked output.' }],
},
],
};
const futureDate = new Date();
futureDate.setFullYear(futureDate.getFullYear() + 1);
conversation.startTime = futureDate.toISOString();
conversation.lastUpdated = futureDate.toISOString();
const timestamp = futureDate
.toISOString()
.slice(0, 16)
.replace(/:/g, '-');
const sessionFile = path.join(
chatsDir,
`session-${timestamp}-${sessionId.slice(0, 8)}.json`,
);
fs.writeFileSync(sessionFile, JSON.stringify(conversation, null, 2));
// 4. Trust folder
const settingsDir = path.join(rig.homeDir!, '.gemini');
fs.writeFileSync(
path.join(settingsDir, 'trustedFolders.json'),
JSON.stringify(
{
[path.resolve(rig.homeDir!)]: 'TRUST_FOLDER',
},
null,
2,
),
);
// 5. Run agent with --resume
const result = await rig.run({
args: [
'--resume',
'latest',
'What was the secret key in that last masked shell output?',
],
approvalMode: 'yolo',
timeout: 120000,
});
// ASSERTION: Verify agent accessed the redirected file
const logs = rig.readToolLogs();
const accessedFile = logs.some((log) =>
log.toolRequest.args.includes(outputFileName),
);
expect(
accessedFile,
`Agent should have attempted to access the masked output file: ${outputFileName}`,
).toBe(true);
expect(result.toLowerCase()).toContain(secretValue.toLowerCase());
},
});
/**
* Scenario: Information is in the preview.
*/
evalTest('USUALLY_PASSES', {
name: 'should NOT read the full output file when the information is already in the preview',
params: {
security: {
folderTrust: {
enabled: true,
},
},
},
prompt: '/help',
assert: async (rig) => {
await rig.run({ args: '/help' });
const chatsDir = findDir(path.join(rig.homeDir!, '.gemini'), 'chats');
if (!chatsDir) throw new Error('Could not find chats directory');
const projectTempDir = path.dirname(chatsDir);
const sessionId = crypto.randomUUID();
const toolOutputsDir = path.join(
projectTempDir,
'tool-outputs',
`session-${sessionId}`,
);
fs.mkdirSync(toolOutputsDir, { recursive: true });
const secretValue = 'PREVIEW_SECRET_123';
const outputFileName = `masked_output_${crypto.randomUUID()}.txt`;
const outputFilePath = path.join(toolOutputsDir, outputFileName);
fs.writeFileSync(
outputFilePath,
`Full content containing ${secretValue}`,
);
const maskedSnippet = `<tool_output_masked>
Output: The secret key is: ${secretValue}
... lines omitted ...
Output too large. Full output available at: ${outputFilePath}
</tool_output_masked>`;
const conversation = {
sessionId: sessionId,
projectHash: path.basename(projectTempDir),
startTime: new Date().toISOString(),
lastUpdated: new Date().toISOString(),
messages: [
{
id: 'msg_1',
timestamp: new Date().toISOString(),
type: 'user',
content: [{ text: 'Find secret.' }],
},
{
id: 'msg_2',
timestamp: new Date().toISOString(),
type: 'gemini',
model: 'gemini-3-flash-preview',
toolCalls: [
{
id: 'call_1',
name: 'run_shell_command',
args: { command: 'get_secret' },
status: 'success',
timestamp: new Date().toISOString(),
result: [
{
functionResponse: {
id: 'call_1',
name: 'run_shell_command',
response: { output: maskedSnippet },
},
},
],
},
],
content: [{ text: 'Masked output found.' }],
},
],
};
const futureDate = new Date();
futureDate.setFullYear(futureDate.getFullYear() + 1);
conversation.startTime = futureDate.toISOString();
conversation.lastUpdated = futureDate.toISOString();
const timestamp = futureDate
.toISOString()
.slice(0, 16)
.replace(/:/g, '-');
const sessionFile = path.join(
chatsDir,
`session-${timestamp}-${sessionId.slice(0, 8)}.json`,
);
fs.writeFileSync(sessionFile, JSON.stringify(conversation, null, 2));
const settingsDir = path.join(rig.homeDir!, '.gemini');
fs.writeFileSync(
path.join(settingsDir, 'trustedFolders.json'),
JSON.stringify(
{
[path.resolve(rig.homeDir!)]: 'TRUST_FOLDER',
},
null,
2,
),
);
const result = await rig.run({
args: [
'--resume',
'latest',
'What was the secret key mentioned in the previous output?',
],
approvalMode: 'yolo',
timeout: 120000,
});
const logs = rig.readToolLogs();
const accessedFile = logs.some((log) =>
log.toolRequest.args.includes(outputFileName),
);
expect(
accessedFile,
'Agent should NOT have accessed the masked output file',
).toBe(false);
expect(result.toLowerCase()).toContain(secretValue.toLowerCase());
},
});
});
+57 -47
View File
@@ -11,9 +11,10 @@
"packages/*"
],
"dependencies": {
"ink": "npm:@jrichman/ink@6.4.10",
"ink": "npm:@jrichman/ink@6.4.11",
"latest-version": "^9.0.0",
"proper-lockfile": "^4.1.2",
"punycode": "^2.3.1",
"simple-git": "^3.28.0"
},
"bin": {
@@ -48,7 +49,6 @@
"globals": "^16.0.0",
"google-artifactregistry-auth": "^3.4.0",
"husky": "^9.1.7",
"ink-testing-library": "^4.0.0",
"json": "^11.0.0",
"lint-staged": "^16.1.6",
"memfs": "^4.42.0",
@@ -58,6 +58,7 @@
"npm-run-all": "^4.1.5",
"prettier": "^3.5.3",
"react-devtools-core": "^6.1.2",
"react-dom": "^19.2.0",
"semver": "^7.7.2",
"strip-ansi": "^7.1.2",
"ts-prune": "^0.10.3",
@@ -76,7 +77,6 @@
"@lydell/node-pty-linux-x64": "1.1.0",
"@lydell/node-pty-win32-arm64": "1.1.0",
"@lydell/node-pty-win32-x64": "1.1.0",
"gemini-cli-devtools": "^0.2.1",
"keytar": "^7.9.0",
"node-pty": "^1.0.0"
}
@@ -1389,6 +1389,10 @@
"resolved": "packages/core",
"link": true
},
"node_modules/@google/gemini-cli-devtools": {
"resolved": "packages/devtools",
"link": true
},
"node_modules/@google/gemini-cli-sdk": {
"resolved": "packages/sdk",
"link": true
@@ -9056,18 +9060,6 @@
"node": ">=14"
}
},
"node_modules/gemini-cli-devtools": {
"version": "0.2.1",
"resolved": "https://registry.npmjs.org/gemini-cli-devtools/-/gemini-cli-devtools-0.2.1.tgz",
"integrity": "sha512-PcqPL9ZZjgjsp3oYhcXnUc6yNeLvdZuU/UQp0aT+DA8pt3BZzPzXthlOmIrRRqHBdLjMLPwN5GD29zR5bASXtQ==",
"optional": true,
"dependencies": {
"ws": "^8.16.0"
},
"engines": {
"node": ">=20"
}
},
"node_modules/gemini-cli-vscode-ide-companion": {
"resolved": "packages/vscode-ide-companion",
"link": true
@@ -10029,9 +10021,9 @@
},
"node_modules/ink": {
"name": "@jrichman/ink",
"version": "6.4.10",
"resolved": "https://registry.npmjs.org/@jrichman/ink/-/ink-6.4.10.tgz",
"integrity": "sha512-kjJqZFkGVm0QyJmga/L02rsFJroF1aP2bhXEGkpuuT7clB6/W+gxAbLNw7ZaJrG6T30DgqOT92Pu6C9mK1FWyg==",
"version": "6.4.11",
"resolved": "https://registry.npmjs.org/@jrichman/ink/-/ink-6.4.11.tgz",
"integrity": "sha512-93LQlzT7vvZ1XJcmOMwN4s+6W334QegendeHOMnEJBlhnpIzr8bws6/aOEHG8ZCuVD/vNeeea5m1msHIdAY6ig==",
"license": "MIT",
"dependencies": {
"@alcalzone/ansi-tokenize": "^0.2.1",
@@ -10112,24 +10104,6 @@
"react": ">=18.0.0"
}
},
"node_modules/ink-testing-library": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/ink-testing-library/-/ink-testing-library-4.0.0.tgz",
"integrity": "sha512-yF92kj3pmBvk7oKbSq5vEALO//o7Z9Ck/OaLNlkzXNeYdwfpxMQkSowGTFUCS5MSu9bWfSZMewGpp7bFc66D7Q==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=18"
},
"peerDependencies": {
"@types/react": ">=18.0.0"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
}
}
},
"node_modules/ink/node_modules/ansi-styles": {
"version": "6.2.1",
"resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-6.2.1.tgz",
@@ -13547,7 +13521,6 @@
"version": "2.3.1",
"resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz",
"integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==",
"devOptional": true,
"license": "MIT",
"engines": {
"node": ">=6"
@@ -13709,9 +13682,9 @@
}
},
"node_modules/react": {
"version": "19.2.0",
"resolved": "https://registry.npmjs.org/react/-/react-19.2.0.tgz",
"integrity": "sha512-tmbWg6W31tQLeB5cdIBOicJDJRR2KzXsV7uSK9iNfLWQ5bIZfxuPEHp7M8wiHyHnn0DD1i7w3Zmin0FtkrwoCQ==",
"version": "19.2.4",
"resolved": "https://registry.npmjs.org/react/-/react-19.2.4.tgz",
"integrity": "sha512-9nfp2hYpCwOjAN+8TZFGhtWEwgvWHXqESH8qT89AT/lWklpLON22Lc8pEtnpsZz7VmawabSU0gCjnj8aC0euHQ==",
"license": "MIT",
"engines": {
"node": ">=0.10.0"
@@ -13750,6 +13723,26 @@
}
}
},
"node_modules/react-dom": {
"version": "19.2.4",
"resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.4.tgz",
"integrity": "sha512-AXJdLo8kgMbimY95O2aKQqsz2iWi9jMgKJhRBAxECE4IFxfcazB2LmzloIoibJI3C12IlY20+KFaLv+71bUJeQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"scheduler": "^0.27.0"
},
"peerDependencies": {
"react": "^19.2.4"
}
},
"node_modules/react-dom/node_modules/scheduler": {
"version": "0.27.0",
"resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.27.0.tgz",
"integrity": "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==",
"dev": true,
"license": "MIT"
},
"node_modules/react-is": {
"version": "16.13.1",
"resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz",
@@ -15564,9 +15557,9 @@
}
},
"node_modules/tar": {
"version": "7.5.7",
"resolved": "https://registry.npmjs.org/tar/-/tar-7.5.7.tgz",
"integrity": "sha512-fov56fJiRuThVFXD6o6/Q354S7pnWMJIVlDBYijsTNx6jKSE4pvrDTs6lUnmGvNyfJwFQQwWy3owKz1ucIhveQ==",
"version": "7.5.8",
"resolved": "https://registry.npmjs.org/tar/-/tar-7.5.8.tgz",
"integrity": "sha512-SYkBtK99u0yXa+IWL0JRzzcl7RxNpvX/U08Z+8DKnysfno7M+uExnTZH8K+VGgShf2qFPKtbNr9QBl8n7WBP6Q==",
"license": "BlueOak-1.0.0",
"dependencies": {
"@isaacs/fs-minipass": "^4.0.0",
@@ -17262,7 +17255,7 @@
"express": "^5.1.0",
"fs-extra": "^11.3.0",
"strip-json-comments": "^3.1.1",
"tar": "^7.5.2",
"tar": "^7.5.8",
"uuid": "^13.0.0",
"winston": "^3.17.0"
},
@@ -17325,6 +17318,7 @@
"chalk": "^4.1.2",
"cli-spinners": "^2.9.2",
"clipboardy": "^5.0.0",
"color-convert": "^2.0.1",
"command-exists": "^1.2.9",
"comment-json": "^4.2.5",
"diff": "^8.0.3",
@@ -17333,7 +17327,7 @@
"fzf": "^0.5.2",
"glob": "^12.0.0",
"highlight.js": "^11.11.1",
"ink": "npm:@jrichman/ink@6.4.10",
"ink": "npm:@jrichman/ink@6.4.11",
"ink-gradient": "^3.0.0",
"ink-spinner": "^5.0.0",
"latest-version": "^9.0.0",
@@ -17348,7 +17342,7 @@
"string-width": "^8.1.0",
"strip-ansi": "^7.1.0",
"strip-json-comments": "^3.1.1",
"tar": "^7.5.2",
"tar": "^7.5.8",
"tinygradient": "^1.1.5",
"undici": "^7.10.0",
"ws": "^8.16.0",
@@ -17359,6 +17353,7 @@
"gemini": "dist/index.js"
},
"devDependencies": {
"@google/gemini-cli-devtools": "file:../devtools",
"@google/gemini-cli-test-utils": "file:../test-utils",
"@types/command-exists": "^1.2.3",
"@types/hast": "^3.0.4",
@@ -17368,7 +17363,7 @@
"@types/shell-quote": "^1.7.5",
"@types/ws": "^8.5.10",
"@types/yargs": "^17.0.32",
"ink-testing-library": "^4.0.0",
"@xterm/headless": "^5.5.0",
"typescript": "^5.3.3",
"vitest": "^3.1.1"
},
@@ -17573,6 +17568,21 @@
"uuid": "dist-node/bin/uuid"
}
},
"packages/devtools": {
"name": "@google/gemini-cli-devtools",
"version": "0.30.0-nightly.20260210.a2174751d",
"license": "Apache-2.0",
"dependencies": {
"ws": "^8.16.0"
},
"devDependencies": {
"react": "^19.2.0",
"react-dom": "^19.2.0"
},
"engines": {
"node": ">=20"
}
},
"packages/sdk": {
"name": "@google/gemini-cli-sdk",
"version": "0.29.0-nightly.20260203.71f46f116",
+5 -5
View File
@@ -37,7 +37,7 @@
"build:all": "npm run build && npm run build:sandbox && npm run build:vscode",
"build:packages": "npm run build --workspaces",
"build:sandbox": "node scripts/build_sandbox.js",
"bundle": "npm run generate && node esbuild.config.js && node scripts/copy_bundle_assets.js",
"bundle": "npm run generate && npm run build --workspace=@google/gemini-cli-devtools && node esbuild.config.js && node scripts/copy_bundle_assets.js",
"test": "npm run test --workspaces --if-present",
"test:ci": "npm run test:ci --workspaces --if-present && npm run test:scripts",
"test:scripts": "vitest run --config ./scripts/tests/vitest.config.ts",
@@ -64,7 +64,7 @@
"pre-commit": "node scripts/pre-commit.js"
},
"overrides": {
"ink": "npm:@jrichman/ink@6.4.10",
"ink": "npm:@jrichman/ink@6.4.11",
"wrap-ansi": "9.0.2",
"cliui": {
"wrap-ansi": "7.0.0"
@@ -107,7 +107,6 @@
"globals": "^16.0.0",
"google-artifactregistry-auth": "^3.4.0",
"husky": "^9.1.7",
"ink-testing-library": "^4.0.0",
"json": "^11.0.0",
"lint-staged": "^16.1.6",
"memfs": "^4.42.0",
@@ -117,6 +116,7 @@
"npm-run-all": "^4.1.5",
"prettier": "^3.5.3",
"react-devtools-core": "^6.1.2",
"react-dom": "^19.2.0",
"semver": "^7.7.2",
"strip-ansi": "^7.1.2",
"ts-prune": "^0.10.3",
@@ -126,9 +126,10 @@
"yargs": "^17.7.2"
},
"dependencies": {
"ink": "npm:@jrichman/ink@6.4.10",
"ink": "npm:@jrichman/ink@6.4.11",
"latest-version": "^9.0.0",
"proper-lockfile": "^4.1.2",
"punycode": "^2.3.1",
"simple-git": "^3.28.0"
},
"optionalDependencies": {
@@ -138,7 +139,6 @@
"@lydell/node-pty-linux-x64": "1.1.0",
"@lydell/node-pty-win32-arm64": "1.1.0",
"@lydell/node-pty-win32-x64": "1.1.0",
"gemini-cli-devtools": "^0.2.1",
"keytar": "^7.9.0",
"node-pty": "^1.0.0"
},
+1 -1
View File
@@ -31,7 +31,7 @@
"express": "^5.1.0",
"fs-extra": "^11.3.0",
"strip-json-comments": "^3.1.1",
"tar": "^7.5.2",
"tar": "^7.5.8",
"uuid": "^13.0.0",
"winston": "^3.17.0"
},
+5 -3
View File
@@ -39,6 +39,7 @@
"chalk": "^4.1.2",
"cli-spinners": "^2.9.2",
"clipboardy": "^5.0.0",
"color-convert": "^2.0.1",
"command-exists": "^1.2.9",
"comment-json": "^4.2.5",
"diff": "^8.0.3",
@@ -47,7 +48,7 @@
"fzf": "^0.5.2",
"glob": "^12.0.0",
"highlight.js": "^11.11.1",
"ink": "npm:@jrichman/ink@6.4.10",
"ink": "npm:@jrichman/ink@6.4.11",
"ink-gradient": "^3.0.0",
"ink-spinner": "^5.0.0",
"latest-version": "^9.0.0",
@@ -62,7 +63,7 @@
"string-width": "^8.1.0",
"strip-ansi": "^7.1.0",
"strip-json-comments": "^3.1.1",
"tar": "^7.5.2",
"tar": "^7.5.8",
"tinygradient": "^1.1.5",
"undici": "^7.10.0",
"ws": "^8.16.0",
@@ -70,6 +71,7 @@
"zod": "^3.23.8"
},
"devDependencies": {
"@google/gemini-cli-devtools": "file:../devtools",
"@google/gemini-cli-test-utils": "file:../test-utils",
"@types/command-exists": "^1.2.3",
"@types/hast": "^3.0.4",
@@ -79,7 +81,7 @@
"@types/shell-quote": "^1.7.5",
"@types/ws": "^8.5.10",
"@types/yargs": "^17.0.32",
"ink-testing-library": "^4.0.0",
"@xterm/headless": "^5.5.0",
"typescript": "^5.3.3",
"vitest": "^3.1.1"
},
+36 -11
View File
@@ -1344,6 +1344,36 @@ describe('Approval mode tool exclusion logic', () => {
'Invalid approval mode: invalid_mode. Valid values are: yolo, auto_edit, plan, default',
);
});
it('should fall back to default approval mode if plan mode is requested but not enabled', async () => {
process.argv = ['node', 'script.js'];
const settings = createTestMergedSettings({
general: {
defaultApprovalMode: 'plan',
},
experimental: {
plan: false,
},
});
const argv = await parseArguments(settings);
const config = await loadCliConfig(settings, 'test-session', argv);
expect(config.getApprovalMode()).toBe(ApprovalMode.DEFAULT);
});
it('should allow plan approval mode if experimental plan is enabled', async () => {
process.argv = ['node', 'script.js'];
const settings = createTestMergedSettings({
general: {
defaultApprovalMode: 'plan',
},
experimental: {
plan: true,
},
});
const argv = await parseArguments(settings);
const config = await loadCliConfig(settings, 'test-session', argv);
expect(config.getApprovalMode()).toBe(ApprovalMode.PLAN);
});
});
describe('loadCliConfig with allowed-mcp-server-names', () => {
@@ -2556,9 +2586,8 @@ describe('loadCliConfig approval mode', () => {
},
});
await expect(loadCliConfig(settings, 'test-session', argv)).rejects.toThrow(
'Approval mode "plan" is only available when experimental.plan is enabled.',
);
const config = await loadCliConfig(settings, 'test-session', argv);
expect(config.getApprovalMode()).toBe(ApprovalMode.DEFAULT);
});
it('should throw error when --approval-mode=plan is used but experimental.plan setting is missing', async () => {
@@ -2566,9 +2595,8 @@ describe('loadCliConfig approval mode', () => {
const argv = await parseArguments(createTestMergedSettings());
const settings = createTestMergedSettings({});
await expect(loadCliConfig(settings, 'test-session', argv)).rejects.toThrow(
'Approval mode "plan" is only available when experimental.plan is enabled.',
);
const config = await loadCliConfig(settings, 'test-session', argv);
expect(config.getApprovalMode()).toBe(ApprovalMode.DEFAULT);
});
// --- Untrusted Folder Scenarios ---
@@ -2678,11 +2706,8 @@ describe('loadCliConfig approval mode', () => {
experimental: { plan: false },
});
const argv = await parseArguments(settings);
await expect(
loadCliConfig(settings, 'test-session', argv),
).rejects.toThrow(
'Approval mode "plan" is only available when experimental.plan is enabled.',
);
const config = await loadCliConfig(settings, 'test-session', argv);
expect(config.getApprovalMode()).toBe(ApprovalMode.DEFAULT);
});
});
});
+6 -3
View File
@@ -555,11 +555,13 @@ export async function loadCliConfig(
break;
case 'plan':
if (!(settings.experimental?.plan ?? false)) {
throw new Error(
'Approval mode "plan" is only available when experimental.plan is enabled.',
debugLogger.warn(
'Approval mode "plan" is only available when experimental.plan is enabled. Falling back to "default".',
);
approvalMode = ApprovalMode.DEFAULT;
} else {
approvalMode = ApprovalMode.PLAN;
}
approvalMode = ApprovalMode.PLAN;
break;
case 'default':
approvalMode = ApprovalMode.DEFAULT;
@@ -816,6 +818,7 @@ export async function loadCliConfig(
skillsSupport: settings.skills?.enabled ?? true,
disabledSkills: settings.skills?.disabled,
experimentalJitContext: settings.experimental?.jitContext,
modelSteering: settings.experimental?.modelSteering,
toolOutputMasking: settings.experimental?.toolOutputMasking,
noBrowser: !!process.env['NO_BROWSER'],
summarizeToolOutput: settings.model?.summarizeToolOutput,
@@ -269,7 +269,7 @@ describe('github.ts', () => {
it('should return NOT_UPDATABLE if local extension config cannot be loaded', async () => {
vi.mocked(mockExtensionManager.loadExtensionConfig).mockImplementation(
() => {
async () => {
throw new Error('Config not found');
},
);
+6 -2
View File
@@ -178,6 +178,7 @@ export const defaultKeyBindings: KeyBindingConfig = {
[Command.DELETE_WORD_FORWARD]: [
{ key: 'delete', ctrl: true },
{ key: 'delete', alt: true },
{ key: 'd', alt: true },
],
[Command.DELETE_CHAR_LEFT]: [{ key: 'backspace' }, { key: 'h', ctrl: true }],
[Command.DELETE_CHAR_RIGHT]: [{ key: 'delete' }, { key: 'd', ctrl: true }],
@@ -211,7 +212,7 @@ export const defaultKeyBindings: KeyBindingConfig = {
[Command.REVERSE_SEARCH]: [{ key: 'r', ctrl: true }],
[Command.REWIND]: [{ key: 'double escape' }],
[Command.SUBMIT_REVERSE_SEARCH]: [{ key: 'return', ctrl: false }],
[Command.ACCEPT_SUGGESTION_REVERSE_SEARCH]: [{ key: 'tab' }],
[Command.ACCEPT_SUGGESTION_REVERSE_SEARCH]: [{ key: 'tab', shift: false }],
// Navigation
[Command.NAVIGATION_UP]: [{ key: 'up', shift: false }],
@@ -230,7 +231,10 @@ export const defaultKeyBindings: KeyBindingConfig = {
[Command.DIALOG_PREV]: [{ key: 'tab', shift: true }],
// Suggestions & Completions
[Command.ACCEPT_SUGGESTION]: [{ key: 'tab' }, { key: 'return', ctrl: false }],
[Command.ACCEPT_SUGGESTION]: [
{ key: 'tab', shift: false },
{ key: 'return', ctrl: false },
],
[Command.COMPLETION_UP]: [
{ key: 'up', shift: false },
{ key: 'p', shift: false, ctrl: true },
@@ -353,6 +353,17 @@ describe('SettingsSchema', () => {
).toBe('Show the "? for shortcuts" hint above the input.');
});
it('should have enableNotifications setting in schema', () => {
const setting =
getSettingsSchema().general.properties.enableNotifications;
expect(setting).toBeDefined();
expect(setting.type).toBe('boolean');
expect(setting.category).toBe('General');
expect(setting.default).toBe(false);
expect(setting.requiresRestart).toBe(false);
expect(setting.showInDialog).toBe(true);
});
it('should have enableAgents setting in schema', () => {
const setting = getSettingsSchema().experimental.properties.enableAgents;
expect(setting).toBeDefined();
+29
View File
@@ -236,6 +236,16 @@ const SETTINGS_SCHEMA = {
description: 'Enable update notification prompts.',
showInDialog: false,
},
enableNotifications: {
type: 'boolean',
label: 'Enable Notifications',
category: 'General',
requiresRestart: false,
default: false,
description:
'Enable run-event notifications for action-required prompts and session completion. Currently macOS only.',
showInDialog: true,
},
checkpointing: {
type: 'object',
label: 'Checkpointing',
@@ -475,6 +485,15 @@ const SETTINGS_SCHEMA = {
'Show a warning when running Gemini CLI in the home directory.',
showInDialog: true,
},
showCompatibilityWarnings: {
type: 'boolean',
label: 'Show Compatibility Warnings',
category: 'UI',
requiresRestart: true,
default: true,
description: 'Show warnings about terminal or OS compatibility issues.',
showInDialog: true,
},
hideTips: {
type: 'boolean',
label: 'Hide Tips',
@@ -1625,6 +1644,16 @@ const SETTINGS_SCHEMA = {
description: 'Enable planning features (Plan Mode and tools).',
showInDialog: true,
},
modelSteering: {
type: 'boolean',
label: 'Model Steering',
category: 'Experimental',
requiresRestart: false,
default: false,
description:
'Enable model steering (user hints) to guide the model during tool execution.',
showInDialog: true,
},
},
},
+20 -12
View File
@@ -491,25 +491,33 @@ describe('Trusted Folders', () => {
});
});
const itif = (condition: boolean) => (condition ? it : it.skip);
describe('Symlinks Support', () => {
const mockSettings: Settings = {
security: { folderTrust: { enabled: true } },
};
it('should trust a folder if the rule matches the realpath', () => {
// Create a real directory and a symlink
const realDir = path.join(tempDir, 'real');
const symlinkDir = path.join(tempDir, 'symlink');
fs.mkdirSync(realDir);
fs.symlinkSync(realDir, symlinkDir);
// TODO: issue 19387 - Enable symlink tests on Windows
itif(process.platform !== 'win32')(
'should trust a folder if the rule matches the realpath',
() => {
// Create a real directory and a symlink
const realDir = path.join(tempDir, 'real');
const symlinkDir = path.join(tempDir, 'symlink');
fs.mkdirSync(realDir);
fs.symlinkSync(realDir, symlinkDir);
// Rule uses realpath
const config = { [realDir]: TrustLevel.TRUST_FOLDER };
fs.writeFileSync(trustedFoldersPath, JSON.stringify(config), 'utf-8');
// Rule uses realpath
const config = { [realDir]: TrustLevel.TRUST_FOLDER };
fs.writeFileSync(trustedFoldersPath, JSON.stringify(config), 'utf-8');
// Check against symlink path
expect(isWorkspaceTrusted(mockSettings, symlinkDir).isTrusted).toBe(true);
});
// Check against symlink path
expect(isWorkspaceTrusted(mockSettings, symlinkDir).isTrusted).toBe(
true,
);
},
);
});
describe('Verification: Auth and Trust Interaction', () => {
+18
View File
@@ -56,6 +56,20 @@ vi.mock('./nonInteractiveCli.js', () => ({
runNonInteractive: runNonInteractiveSpy,
}));
const terminalNotificationMocks = vi.hoisted(() => ({
notifyViaTerminal: vi.fn().mockResolvedValue(true),
buildRunEventNotificationContent: vi.fn(() => ({
title: 'Session complete',
body: 'done',
subtitle: 'Run finished',
})),
}));
vi.mock('./utils/terminalNotifications.js', () => ({
notifyViaTerminal: terminalNotificationMocks.notifyViaTerminal,
buildRunEventNotificationContent:
terminalNotificationMocks.buildRunEventNotificationContent,
}));
vi.mock('@google/gemini-cli-core', async (importOriginal) => {
const actual =
await importOriginal<typeof import('@google/gemini-cli-core')>();
@@ -837,6 +851,10 @@ describe('gemini.tsx main function kitty protocol', () => {
expect(runNonInteractive).toHaveBeenCalled();
const callArgs = vi.mocked(runNonInteractive).mock.calls[0][0];
expect(callArgs.input).toBe('stdin-data\n\ntest-question');
expect(
terminalNotificationMocks.buildRunEventNotificationContent,
).not.toHaveBeenCalled();
expect(terminalNotificationMocks.notifyViaTerminal).not.toHaveBeenCalled();
expect(processExitSpy).toHaveBeenCalledWith(0);
processExitSpy.mockRestore();
});
+31 -22
View File
@@ -9,14 +9,6 @@ import { main } from './gemini.js';
import { debugLogger } from '@google/gemini-cli-core';
import { type Config } from '@google/gemini-cli-core';
// Custom error to identify mock process.exit calls
class MockProcessExitError extends Error {
constructor(readonly code?: string | number | null | undefined) {
super('PROCESS_EXIT_MOCKED');
this.name = 'MockProcessExitError';
}
}
vi.mock('@google/gemini-cli-core', async (importOriginal) => {
const actual =
await importOriginal<typeof import('@google/gemini-cli-core')>();
@@ -124,10 +116,39 @@ vi.mock('./validateNonInterActiveAuth.js', () => ({
validateNonInteractiveAuth: vi.fn().mockResolvedValue({}),
}));
vi.mock('./core/initializer.js', () => ({
initializeApp: vi.fn().mockResolvedValue({
authError: null,
themeError: null,
shouldOpenAuthDialog: false,
geminiMdFileCount: 0,
}),
}));
vi.mock('./nonInteractiveCli.js', () => ({
runNonInteractive: vi.fn().mockResolvedValue(undefined),
}));
vi.mock('./utils/cleanup.js', async (importOriginal) => {
const actual = await importOriginal<typeof import('./utils/cleanup.js')>();
return {
...actual,
cleanupCheckpoints: vi.fn().mockResolvedValue(undefined),
registerCleanup: vi.fn(),
registerSyncCleanup: vi.fn(),
registerTelemetryConfig: vi.fn(),
runExitCleanup: vi.fn().mockResolvedValue(undefined),
};
});
vi.mock('./zed-integration/zedIntegration.js', () => ({
runZedIntegration: vi.fn().mockResolvedValue(undefined),
}));
vi.mock('./utils/readStdin.js', () => ({
readStdin: vi.fn().mockResolvedValue(''),
}));
const { cleanupMockState } = vi.hoisted(() => ({
cleanupMockState: { shouldThrow: false, called: false },
}));
@@ -169,12 +190,6 @@ describe('gemini.tsx main function cleanup', () => {
const debugLoggerErrorSpy = vi
.spyOn(debugLogger, 'error')
.mockImplementation(() => {});
const processExitSpy = vi
.spyOn(process, 'exit')
.mockImplementation((code) => {
throw new MockProcessExitError(code);
});
vi.mocked(loadSettings).mockReturnValue({
merged: { advanced: {}, security: { auth: {} }, ui: {} },
workspace: { settings: {} },
@@ -201,7 +216,7 @@ describe('gemini.tsx main function cleanup', () => {
getMcpServers: () => ({}),
getMcpClientManager: vi.fn(),
getIdeMode: vi.fn(() => false),
getExperimentalZedIntegration: vi.fn(() => false),
getExperimentalZedIntegration: vi.fn(() => true),
getScreenReader: vi.fn(() => false),
getGeminiMdFileCount: vi.fn(() => 0),
getProjectRoot: vi.fn(() => '/'),
@@ -224,18 +239,12 @@ describe('gemini.tsx main function cleanup', () => {
getRemoteAdminSettings: vi.fn(() => undefined),
} as any); // eslint-disable-line @typescript-eslint/no-explicit-any
try {
await main();
} catch (e) {
if (!(e instanceof MockProcessExitError)) throw e;
}
await main();
expect(cleanupMockState.called).toBe(true);
expect(debugLoggerErrorSpy).toHaveBeenCalledWith(
'Failed to cleanup expired sessions:',
expect.objectContaining({ message: 'Cleanup failed' }),
);
expect(processExitSpy).toHaveBeenCalledWith(0); // Should not exit on cleanup failure
processExitSpy.mockRestore();
});
});
@@ -0,0 +1,84 @@
/**
* @license
* Copyright 2026 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import { describe, it, afterEach } from 'vitest';
import { AppRig } from '../test-utils/AppRig.js';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
import { PolicyDecision } from '@google/gemini-cli-core';
const __dirname = path.dirname(fileURLToPath(import.meta.url));
describe('Model Steering Integration', () => {
let rig: AppRig | undefined;
afterEach(async () => {
await rig?.unmount();
});
it('should steer the model using a hint during a tool turn', async () => {
const fakeResponsesPath = path.join(
__dirname,
'../test-utils/fixtures/steering.responses',
);
rig = new AppRig({
fakeResponsesPath,
configOverrides: { modelSteering: true },
});
await rig.initialize();
rig.render();
await rig.waitForIdle();
rig.setToolPolicy('list_directory', PolicyDecision.ASK_USER);
rig.setToolPolicy('read_file', PolicyDecision.ASK_USER);
rig.setMockCommands([
{
command: /list_directory/,
result: {
output: 'file1.txt\nfile2.js\nfile3.md',
exitCode: 0,
},
},
{
command: /read_file file1.txt/,
result: {
output: 'This is file1.txt content.',
exitCode: 0,
},
},
]);
// Start a long task
await rig.type('Start long task');
await rig.pressEnter();
// Wait for the model to call 'list_directory' (Confirming state)
await rig.waitForOutput('ReadFolder');
// Injected a hint while the model is in a tool turn
await rig.addUserHint('focus on .txt');
// Resolve list_directory (Proceed)
await rig.resolveTool('ReadFolder');
// Wait for the model to process the hint and output the next action
// Based on steering.responses, it should first acknowledge the hint
await rig.waitForOutput('ACK: I will focus on .txt files now.');
// Then it should proceed with the next action
await rig.waitForOutput(
/Since you want me to focus on .txt files,[\s\S]*I will read file1.txt/,
);
await rig.waitForOutput('ReadFile');
// Resolve read_file (Proceed)
await rig.resolveTool('ReadFile');
// Wait for final completion
await rig.waitForOutput('Task complete.');
});
});
+49 -2
View File
@@ -4,10 +4,12 @@
* SPDX-License-Identifier: Apache-2.0
*/
import { describe, it, afterEach } from 'vitest';
import { describe, it, afterEach, expect } from 'vitest';
import { act } from 'react';
import { AppRig } from './AppRig.js';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
import { debugLogger } from '@google/gemini-cli-core';
const __dirname = path.dirname(fileURLToPath(import.meta.url));
@@ -18,6 +20,47 @@ describe('AppRig', () => {
await rig?.unmount();
});
it('should handle deterministic tool turns with breakpoints', async () => {
const fakeResponsesPath = path.join(
__dirname,
'fixtures',
'steering.responses',
);
rig = new AppRig({
fakeResponsesPath,
configOverrides: { modelSteering: true },
});
await rig.initialize();
rig.render();
await rig.waitForIdle();
// Set breakpoints on the canonical tool names
rig.setBreakpoint('list_directory');
rig.setBreakpoint('read_file');
// Start a task
debugLogger.log('[Test] Sending message: Start long task');
await rig.sendMessage('Start long task');
// Wait for the first breakpoint (list_directory)
const pending1 = await rig.waitForPendingConfirmation('list_directory');
expect(pending1.toolName).toBe('list_directory');
// Injected a hint
await rig.addUserHint('focus on .txt');
// Resolve and wait for the NEXT breakpoint (read_file)
// resolveTool will automatically remove the breakpoint policy for list_directory
await rig.resolveTool('list_directory');
const pending2 = await rig.waitForPendingConfirmation('read_file');
expect(pending2.toolName).toBe('read_file');
// Resolve and finish. Also removes read_file breakpoint.
await rig.resolveTool('read_file');
await rig.waitForOutput('Task complete.', 100000);
});
it('should render the app and handle a simple message', async () => {
const fakeResponsesPath = path.join(
__dirname,
@@ -26,7 +69,11 @@ describe('AppRig', () => {
);
rig = new AppRig({ fakeResponsesPath });
await rig.initialize();
rig.render();
await act(async () => {
rig!.render();
// Allow async initializations (like banners) to settle within the act boundary
await new Promise((resolve) => setTimeout(resolve, 0));
});
// Wait for initial render
await rig.waitForIdle();
+19 -6
View File
@@ -74,6 +74,20 @@ class MockExtensionManager extends ExtensionLoader {
setRequestSetting = vi.fn();
}
// Mock GeminiRespondingSpinner to disable animations (avoiding 'act()' warnings) without triggering screen reader mode.
vi.mock('../ui/components/GeminiRespondingSpinner.js', async () => {
const React = await import('react');
const { Text } = await import('ink');
return {
GeminiSpinner: () => React.createElement(Text, null, '...'),
GeminiRespondingSpinner: ({
nonRespondingDisplay,
}: {
nonRespondingDisplay: string;
}) => React.createElement(Text, null, nonRespondingDisplay || '...'),
};
});
export interface AppRigOptions {
fakeResponsesPath?: string;
terminalWidth?: number;
@@ -449,12 +463,11 @@ export class AppRig {
this.lastAwaitedConfirmation = undefined;
}
async addUserHint(_hint: string) {
async addUserHint(hint: string) {
if (!this.config) throw new Error('AppRig not initialized');
// TODO(joshualitt): Land hints.
// await act(async () => {
// this.config!.addUserHint(hint);
// });
await act(async () => {
this.config!.userHintService.addUserHint(hint);
});
}
getConfig(): Config {
@@ -488,7 +501,7 @@ export class AppRig {
get lastFrame() {
if (!this.renderResult) return '';
return stripAnsi(this.renderResult.lastFrame() || '');
return stripAnsi(this.renderResult.lastFrame({ allowEmpty: true }) || '');
}
getStaticOutput() {
+2 -2
View File
@@ -13,14 +13,14 @@ import { vi } from 'vitest';
// The version of waitFor from vitest is still fine to use if you aren't waiting
// for React state updates.
export async function waitFor(
assertion: () => void,
assertion: () => void | Promise<void>,
{ timeout = 2000, interval = 50 } = {},
): Promise<void> {
const startTime = Date.now();
while (true) {
try {
assertion();
await assertion();
return;
} catch (error) {
if (Date.now() - startTime > timeout) {
@@ -0,0 +1,4 @@
{"method":"generateContentStream","response":[{"candidates":[{"content":{"role":"model","parts":[{"text":"Starting a long task. First, I'll list the files."},{"functionCall":{"name":"list_directory","args":{"dir_path":"."}}}]},"finishReason":"STOP"}]}]}
{"method":"generateContent","response":{"candidates":[{"content":{"role":"model","parts":[{"text":"ACK: I will focus on .txt files now."}]},"finishReason":"STOP"}]}}
{"method":"generateContentStream","response":[{"candidates":[{"content":{"role":"model","parts":[{"text":"I see the files. Since you want me to focus on .txt files, I will read file1.txt."},{"functionCall":{"name":"read_file","args":{"file_path":"file1.txt"}}}]},"finishReason":"STOP"}]}]}
{"method":"generateContentStream","response":[{"candidates":[{"content":{"role":"model","parts":[{"text":"I have read file1.txt. Task complete."}]},"finishReason":"STOP"}]}]}
+64 -27
View File
@@ -5,36 +5,48 @@
*/
import { describe, it, expect, vi } from 'vitest';
import { useState, useEffect } from 'react';
import { useState, useEffect, act } from 'react';
import { Text } from 'ink';
import { renderHook, render } from './render.js';
import { waitFor } from './async.js';
describe('render', () => {
it('should render a component', () => {
const { lastFrame } = render(<Text>Hello World</Text>);
expect(lastFrame()).toBe('Hello World');
it('should render a component', async () => {
const { lastFrame, waitUntilReady, unmount } = render(
<Text>Hello World</Text>,
);
await waitUntilReady();
expect(lastFrame()).toBe('Hello World\n');
unmount();
});
it('should support rerender', () => {
const { lastFrame, rerender } = render(<Text>Hello</Text>);
expect(lastFrame()).toBe('Hello');
it('should support rerender', async () => {
const { lastFrame, rerender, waitUntilReady, unmount } = render(
<Text>Hello</Text>,
);
await waitUntilReady();
expect(lastFrame()).toBe('Hello\n');
rerender(<Text>World</Text>);
expect(lastFrame()).toBe('World');
await act(async () => {
rerender(<Text>World</Text>);
});
await waitUntilReady();
expect(lastFrame()).toBe('World\n');
unmount();
});
it('should support unmount', () => {
const cleanup = vi.fn();
it('should support unmount', async () => {
const cleanupMock = vi.fn();
function TestComponent() {
useEffect(() => cleanup, []);
useEffect(() => cleanupMock, []);
return <Text>Hello</Text>;
}
const { unmount } = render(<TestComponent />);
const { unmount, waitUntilReady } = render(<TestComponent />);
await waitUntilReady();
unmount();
expect(cleanup).toHaveBeenCalled();
expect(cleanupMock).toHaveBeenCalled();
});
});
@@ -48,49 +60,74 @@ describe('renderHook', () => {
return { count, value };
};
const { result, rerender } = renderHook(useTestHook, {
initialProps: { value: 1 },
});
const { result, rerender, waitUntilReady, unmount } = renderHook(
useTestHook,
{
initialProps: { value: 1 },
},
);
await waitUntilReady();
expect(result.current.value).toBe(1);
await waitFor(() => expect(result.current.count).toBe(1));
// Rerender with new props
rerender({ value: 2 });
await act(async () => {
rerender({ value: 2 });
});
await waitUntilReady();
expect(result.current.value).toBe(2);
await waitFor(() => expect(result.current.count).toBe(2));
// Rerender without arguments should use previous props (value: 2)
// This would previously crash or pass undefined if not fixed
rerender();
await act(async () => {
rerender();
});
await waitUntilReady();
expect(result.current.value).toBe(2);
// Count should not increase because value didn't change
await waitFor(() => expect(result.current.count).toBe(2));
unmount();
});
it('should handle initial render without props', () => {
it('should handle initial render without props', async () => {
const useTestHook = () => {
const [count, setCount] = useState(0);
return { count, increment: () => setCount((c) => c + 1) };
};
const { result, rerender } = renderHook(useTestHook);
const { result, rerender, waitUntilReady, unmount } =
renderHook(useTestHook);
await waitUntilReady();
expect(result.current.count).toBe(0);
rerender();
await act(async () => {
rerender();
});
await waitUntilReady();
expect(result.current.count).toBe(0);
unmount();
});
it('should update props if undefined is passed explicitly', () => {
it('should update props if undefined is passed explicitly', async () => {
const useTestHook = (val: string | undefined) => val;
const { result, rerender } = renderHook(useTestHook, {
initialProps: 'initial',
});
const { result, rerender, waitUntilReady, unmount } = renderHook(
useTestHook,
{
initialProps: 'initial' as string | undefined,
},
);
await waitUntilReady();
expect(result.current).toBe('initial');
rerender(undefined);
await act(async () => {
rerender(undefined);
});
await waitUntilReady();
expect(result.current).toBeUndefined();
unmount();
});
});
+370 -32
View File
@@ -4,10 +4,13 @@
* SPDX-License-Identifier: Apache-2.0
*/
import { render as inkRender } from 'ink-testing-library';
import { render as inkRenderDirect, type Instance as InkInstance } from 'ink';
import { EventEmitter } from 'node:events';
import { Box } from 'ink';
import type React from 'react';
import { Terminal } from '@xterm/headless';
import { vi } from 'vitest';
import stripAnsi from 'strip-ansi';
import { act, useState } from 'react';
import os from 'node:os';
import { LoadedSettings } from '../config/settings.js';
@@ -40,6 +43,15 @@ import { pickDefaultThemeName } from '../ui/themes/theme.js';
export const persistentStateMock = new FakePersistentState();
if (process.env['NODE_ENV'] === 'test') {
// We mock NODE_ENV to development during tests that use render.tsx
// so that animations (which check process.env.NODE_ENV !== 'test')
// are actually tested. We mutate process.env directly here because
// vi.stubEnv() is cleared by vi.unstubAllEnvs() in test-setup.ts
// after each test.
process.env['NODE_ENV'] = 'development';
}
vi.mock('../utils/persistentState.js', () => ({
persistentState: persistentStateMock,
}));
@@ -50,51 +62,356 @@ vi.mock('../ui/utils/terminalUtils.js', () => ({
isITerm2: vi.fn(() => false),
}));
// Wrapper around ink-testing-library's render that ensures act() is called
type TerminalState = {
terminal: Terminal;
cols: number;
rows: number;
};
class XtermStdout extends EventEmitter {
private state: TerminalState;
private pendingWrites = 0;
private renderCount = 0;
private queue: { promise: Promise<void> };
isTTY = true;
private lastRenderOutput: string | undefined = undefined;
private lastRenderStaticContent: string | undefined = undefined;
constructor(state: TerminalState, queue: { promise: Promise<void> }) {
super();
this.state = state;
this.queue = queue;
}
get columns() {
return this.state.terminal.cols;
}
get rows() {
return this.state.terminal.rows;
}
get frames(): string[] {
return [];
}
write = (data: string) => {
this.pendingWrites++;
this.queue.promise = this.queue.promise.then(async () => {
await new Promise<void>((resolve) =>
this.state.terminal.write(data, resolve),
);
this.pendingWrites--;
});
};
clear = () => {
this.state.terminal.reset();
this.lastRenderOutput = undefined;
this.lastRenderStaticContent = undefined;
};
dispose = () => {
this.state.terminal.dispose();
};
onRender = (staticContent: string, output: string) => {
this.renderCount++;
this.lastRenderStaticContent = staticContent;
this.lastRenderOutput = output;
this.emit('render');
};
lastFrame = (options: { allowEmpty?: boolean } = {}) => {
let result: string;
// On Windows, xterm.js headless can sometimes have timing or rendering issues
// that lead to duplicated content or incorrect buffer state in tests.
// As a fallback, we can trust the raw output Ink provided during onRender.
if (os.platform() === 'win32') {
result =
(this.lastRenderStaticContent ?? '') + (this.lastRenderOutput ?? '');
} else {
const buffer = this.state.terminal.buffer.active;
const allLines: string[] = [];
for (let i = 0; i < buffer.length; i++) {
allLines.push(buffer.getLine(i)?.translateToString(true) ?? '');
}
const trimmed = [...allLines];
while (trimmed.length > 0 && trimmed[trimmed.length - 1] === '') {
trimmed.pop();
}
result = trimmed.join('\n');
}
// Normalize for cross-platform snapshot stability:
// Normalize any \r\n to \n
const normalized = result.replace(/\r\n/g, '\n');
if (normalized === '' && !options.allowEmpty) {
throw new Error(
'lastFrame() returned an empty string. If this is intentional, use lastFrame({ allowEmpty: true }). ' +
'Otherwise, ensure you are calling await waitUntilReady() and that the component is rendering correctly.',
);
}
return normalized === '' ? normalized : normalized + '\n';
};
async waitUntilReady() {
const startRenderCount = this.renderCount;
if (!vi.isFakeTimers()) {
// Give Ink a chance to start its rendering loop
await new Promise((resolve) => setImmediate(resolve));
}
await act(async () => {
if (vi.isFakeTimers()) {
await vi.advanceTimersByTimeAsync(50);
} else {
// Wait for at least one render to be called if we haven't rendered yet or since start of this call,
// but don't wait forever as some renders might be synchronous or skipped.
if (this.renderCount === startRenderCount) {
const renderPromise = new Promise((resolve) =>
this.once('render', resolve),
);
const timeoutPromise = new Promise((resolve) =>
setTimeout(resolve, 50),
);
await Promise.race([renderPromise, timeoutPromise]);
}
}
});
let attempts = 0;
const maxAttempts = 50;
let lastCurrent = '';
let lastExpected = '';
while (attempts < maxAttempts) {
// Ensure all pending writes to the terminal are processed.
await this.queue.promise;
const currentFrame = stripAnsi(
this.lastFrame({ allowEmpty: true }),
).trim();
const expectedFrame = stripAnsi(
(this.lastRenderStaticContent ?? '') + (this.lastRenderOutput ?? ''),
)
.trim()
.replace(/\r\n/g, '\n');
lastCurrent = currentFrame;
lastExpected = expectedFrame;
const isMatch = () => {
if (expectedFrame === '...') {
return currentFrame !== '';
}
// If both are empty, it's a match.
// We consider undefined lastRenderOutput as effectively empty for this check
// to support hook testing where Ink may skip rendering completely.
if (
(this.lastRenderOutput === undefined || expectedFrame === '') &&
currentFrame === ''
) {
return true;
}
if (this.lastRenderOutput === undefined) {
return false;
}
// If Ink expects nothing but terminal has content, or vice-versa, it's NOT a match.
if (expectedFrame === '' || currentFrame === '') {
return false;
}
// Check if the current frame contains the expected content.
// We use includes because xterm might have some formatting or
// extra whitespace that Ink doesn't account for in its raw output metrics.
return currentFrame.includes(expectedFrame);
};
if (this.pendingWrites === 0 && isMatch()) {
return;
}
attempts++;
await act(async () => {
if (vi.isFakeTimers()) {
await vi.advanceTimersByTimeAsync(10);
} else {
await new Promise((resolve) => setTimeout(resolve, 10));
}
});
}
throw new Error(
`waitUntilReady() timed out after ${maxAttempts} attempts.\n` +
`Expected content (stripped ANSI):\n"${lastExpected}"\n` +
`Actual content (stripped ANSI):\n"${lastCurrent}"\n` +
`Pending writes: ${this.pendingWrites}\n` +
`Render count: ${this.renderCount}`,
);
}
}
class XtermStderr extends EventEmitter {
private state: TerminalState;
private pendingWrites = 0;
private queue: { promise: Promise<void> };
isTTY = true;
constructor(state: TerminalState, queue: { promise: Promise<void> }) {
super();
this.state = state;
this.queue = queue;
}
write = (data: string) => {
this.pendingWrites++;
this.queue.promise = this.queue.promise.then(async () => {
await new Promise<void>((resolve) =>
this.state.terminal.write(data, resolve),
);
this.pendingWrites--;
});
};
dispose = () => {
this.state.terminal.dispose();
};
lastFrame = () => '';
}
class XtermStdin extends EventEmitter {
isTTY = true;
data: string | null = null;
constructor(options: { isTTY?: boolean } = {}) {
super();
this.isTTY = options.isTTY ?? true;
}
write = (data: string) => {
this.data = data;
this.emit('readable');
this.emit('data', data);
};
setEncoding() {}
setRawMode() {}
resume() {}
pause() {}
ref() {}
unref() {}
read = () => {
const { data } = this;
this.data = null;
return data;
};
}
export type RenderInstance = {
rerender: (tree: React.ReactElement) => void;
unmount: () => void;
cleanup: () => void;
stdout: XtermStdout;
stderr: XtermStderr;
stdin: XtermStdin;
frames: string[];
lastFrame: (options?: { allowEmpty?: boolean }) => string;
terminal: Terminal;
waitUntilReady: () => Promise<void>;
};
const instances: InkInstance[] = [];
// Wrapper around ink's render that ensures act() is called and uses Xterm for output
export const render = (
tree: React.ReactElement,
terminalWidth?: number,
): ReturnType<typeof inkRender> => {
let renderResult: ReturnType<typeof inkRender> =
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
undefined as unknown as ReturnType<typeof inkRender>;
act(() => {
renderResult = inkRender(tree);
): RenderInstance => {
const cols = terminalWidth ?? 100;
const rows = 40;
const terminal = new Terminal({
cols,
rows,
allowProposedApi: true,
convertEol: true,
});
if (terminalWidth !== undefined && renderResult?.stdout) {
// Override the columns getter on the stdout instance provided by ink-testing-library
Object.defineProperty(renderResult.stdout, 'columns', {
get: () => terminalWidth,
configurable: true,
});
const state: TerminalState = {
terminal,
cols,
rows,
};
const writeQueue = { promise: Promise.resolve() };
const stdout = new XtermStdout(state, writeQueue);
const stderr = new XtermStderr(state, writeQueue);
const stdin = new XtermStdin();
// Trigger a rerender so Ink can pick up the new terminal width
act(() => {
renderResult.rerender(tree);
let instance!: InkInstance;
stdout.clear();
act(() => {
instance = inkRenderDirect(tree, {
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
stdout: stdout as unknown as NodeJS.WriteStream,
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
stderr: stderr as unknown as NodeJS.WriteStream,
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
stdin: stdin as unknown as NodeJS.ReadStream,
debug: false,
exitOnCtrlC: false,
patchConsole: false,
onRender: (metrics: { output: string; staticOutput?: string }) => {
stdout.onRender(metrics.staticOutput ?? '', metrics.output);
},
});
}
});
const originalUnmount = renderResult.unmount;
const originalRerender = renderResult.rerender;
instances.push(instance);
return {
...renderResult,
unmount: () => {
act(() => {
originalUnmount();
});
},
rerender: (newTree: React.ReactElement) => {
act(() => {
originalRerender(newTree);
stdout.clear();
instance.rerender(newTree);
});
},
unmount: () => {
act(() => {
instance.unmount();
});
stdout.dispose();
stderr.dispose();
},
cleanup: instance.cleanup,
stdout,
stderr,
stdin,
frames: stdout.frames,
lastFrame: stdout.lastFrame,
terminal: state.terminal,
waitUntilReady: () => stdout.waitUntilReady(),
};
};
export const cleanup = () => {
for (const instance of instances) {
act(() => {
instance.unmount();
});
instance.cleanup();
}
instances.length = 0;
};
export const simulateClick = async (
stdin: ReturnType<typeof inkRender>['stdin'],
stdin: XtermStdin,
col: number,
row: number,
button: 0 | 1 | 2 = 0, // 0 for left, 1 for middle, 2 for right
@@ -151,7 +468,7 @@ export const mockSettings = new LoadedSettings(
const baseMockUiState = {
renderMarkdown: true,
streamingState: StreamingState.Idle,
terminalWidth: 120,
terminalWidth: 100,
terminalHeight: 40,
currentModel: 'gemini-pro',
terminalBackgroundColor: 'black',
@@ -166,6 +483,8 @@ const baseMockUiState = {
proQuotaRequest: null,
validationRequest: null,
},
hintMode: false,
hintBuffer: '',
};
export const mockAppState: AppState = {
@@ -219,6 +538,10 @@ const mockUIActions: UIActions = {
setActiveBackgroundShellPid: vi.fn(),
setIsBackgroundShellListOpen: vi.fn(),
setAuthContext: vi.fn(),
onHintInput: vi.fn(),
onHintBackspace: vi.fn(),
onHintClear: vi.fn(),
onHintSubmit: vi.fn(),
handleRestart: vi.fn(),
handleNewAgentsSelect: vi.fn(),
};
@@ -252,7 +575,13 @@ export const renderWithProviders = (
};
appState?: AppState;
} = {},
): ReturnType<typeof render> & { simulateClick: typeof simulateClick } => {
): RenderInstance & {
simulateClick: (
col: number,
row: number,
button?: 0 | 1 | 2,
) => Promise<void>;
} => {
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
const baseState: UIState = new Proxy(
{ ...baseMockUiState, ...providedUiState },
@@ -371,7 +700,11 @@ export const renderWithProviders = (
terminalWidth,
);
return { ...renderResult, simulateClick };
return {
...renderResult,
simulateClick: (col: number, row: number, button?: 0 | 1 | 2) =>
simulateClick(renderResult.stdin, col, row, button),
};
};
export function renderHook<Result, Props>(
@@ -384,6 +717,7 @@ export function renderHook<Result, Props>(
result: { current: Result };
rerender: (props?: Props) => void;
unmount: () => void;
waitUntilReady: () => Promise<void>;
} {
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
const result = { current: undefined as unknown as Result };
@@ -405,6 +739,7 @@ export function renderHook<Result, Props>(
let inkRerender: (tree: React.ReactElement) => void = () => {};
let unmount: () => void = () => {};
let waitUntilReady: () => Promise<void> = async () => {};
act(() => {
const renderResult = render(
@@ -414,6 +749,7 @@ export function renderHook<Result, Props>(
);
inkRerender = renderResult.rerender;
unmount = renderResult.unmount;
waitUntilReady = renderResult.waitUntilReady;
});
function rerender(props?: Props) {
@@ -430,7 +766,7 @@ export function renderHook<Result, Props>(
});
}
return { result, rerender, unmount };
return { result, rerender, unmount, waitUntilReady };
}
export function renderHookWithProviders<Result, Props>(
@@ -451,6 +787,7 @@ export function renderHookWithProviders<Result, Props>(
result: { current: Result };
rerender: (props?: Props) => void;
unmount: () => void;
waitUntilReady: () => Promise<void>;
} {
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
const result = { current: undefined as unknown as Result };
@@ -500,5 +837,6 @@ export function renderHookWithProviders<Result, Props>(
renderResult.unmount();
});
},
waitUntilReady: () => renderResult.waitUntilReady(),
};
}
+103 -48
View File
@@ -92,32 +92,42 @@ describe('App', () => {
backgroundShells: new Map(),
};
it('should render main content and composer when not quitting', () => {
const { lastFrame } = renderWithProviders(<App />, {
uiState: mockUIState,
useAlternateBuffer: false,
});
it('should render main content and composer when not quitting', async () => {
const { lastFrame, waitUntilReady, unmount } = renderWithProviders(
<App />,
{
uiState: mockUIState,
useAlternateBuffer: false,
},
);
await waitUntilReady();
expect(lastFrame()).toContain('Tips for getting started');
expect(lastFrame()).toContain('Notifications');
expect(lastFrame()).toContain('Composer');
unmount();
});
it('should render quitting display when quittingMessages is set', () => {
it('should render quitting display when quittingMessages is set', async () => {
const quittingUIState = {
...mockUIState,
quittingMessages: [{ id: 1, type: 'user', text: 'test' }],
} as UIState;
const { lastFrame } = renderWithProviders(<App />, {
uiState: quittingUIState,
useAlternateBuffer: false,
});
const { lastFrame, waitUntilReady, unmount } = renderWithProviders(
<App />,
{
uiState: quittingUIState,
useAlternateBuffer: false,
},
);
await waitUntilReady();
expect(lastFrame()).toContain('Quitting...');
unmount();
});
it('should render full history in alternate buffer mode when quittingMessages is set', () => {
it('should render full history in alternate buffer mode when quittingMessages is set', async () => {
const quittingUIState = {
...mockUIState,
quittingMessages: [{ id: 1, type: 'user', text: 'test' }],
@@ -125,28 +135,38 @@ describe('App', () => {
pendingHistoryItems: [{ type: 'user', text: 'pending item' }],
} as UIState;
const { lastFrame } = renderWithProviders(<App />, {
uiState: quittingUIState,
useAlternateBuffer: true,
});
const { lastFrame, waitUntilReady, unmount } = renderWithProviders(
<App />,
{
uiState: quittingUIState,
useAlternateBuffer: true,
},
);
await waitUntilReady();
expect(lastFrame()).toContain('HistoryItemDisplay');
expect(lastFrame()).toContain('Quitting...');
unmount();
});
it('should render dialog manager when dialogs are visible', () => {
it('should render dialog manager when dialogs are visible', async () => {
const dialogUIState = {
...mockUIState,
dialogsVisible: true,
} as UIState;
const { lastFrame } = renderWithProviders(<App />, {
uiState: dialogUIState,
});
const { lastFrame, waitUntilReady, unmount } = renderWithProviders(
<App />,
{
uiState: dialogUIState,
},
);
await waitUntilReady();
expect(lastFrame()).toContain('Tips for getting started');
expect(lastFrame()).toContain('Notifications');
expect(lastFrame()).toContain('DialogManager');
unmount();
});
it.each([
@@ -154,47 +174,62 @@ describe('App', () => {
{ key: 'D', stateKey: 'ctrlDPressedOnce' },
])(
'should show Ctrl+$key exit prompt when dialogs are visible and $stateKey is true',
({ key, stateKey }) => {
async ({ key, stateKey }) => {
const uiState = {
...mockUIState,
dialogsVisible: true,
[stateKey]: true,
} as UIState;
const { lastFrame } = renderWithProviders(<App />, {
uiState,
});
const { lastFrame, waitUntilReady, unmount } = renderWithProviders(
<App />,
{
uiState,
},
);
await waitUntilReady();
expect(lastFrame()).toContain(`Press Ctrl+${key} again to exit.`);
unmount();
},
);
it('should render ScreenReaderAppLayout when screen reader is enabled', () => {
it('should render ScreenReaderAppLayout when screen reader is enabled', async () => {
(useIsScreenReaderEnabled as Mock).mockReturnValue(true);
const { lastFrame } = renderWithProviders(<App />, {
uiState: mockUIState,
});
const { lastFrame, waitUntilReady, unmount } = renderWithProviders(
<App />,
{
uiState: mockUIState,
},
);
await waitUntilReady();
expect(lastFrame()).toContain('Notifications');
expect(lastFrame()).toContain('Footer');
expect(lastFrame()).toContain('Tips for getting started');
expect(lastFrame()).toContain('Composer');
unmount();
});
it('should render DefaultAppLayout when screen reader is not enabled', () => {
it('should render DefaultAppLayout when screen reader is not enabled', async () => {
(useIsScreenReaderEnabled as Mock).mockReturnValue(false);
const { lastFrame } = renderWithProviders(<App />, {
uiState: mockUIState,
});
const { lastFrame, waitUntilReady, unmount } = renderWithProviders(
<App />,
{
uiState: mockUIState,
},
);
await waitUntilReady();
expect(lastFrame()).toContain('Tips for getting started');
expect(lastFrame()).toContain('Notifications');
expect(lastFrame()).toContain('Composer');
unmount();
});
it('should render ToolConfirmationQueue along with Composer when tool is confirming and experiment is on', () => {
it('should render ToolConfirmationQueue along with Composer when tool is confirming and experiment is on', async () => {
(useIsScreenReaderEnabled as Mock).mockReturnValue(false);
const toolCalls = [
@@ -234,44 +269,64 @@ describe('App', () => {
vi.spyOn(configWithExperiment, 'isTrustedFolder').mockReturnValue(true);
vi.spyOn(configWithExperiment, 'getIdeMode').mockReturnValue(false);
const { lastFrame } = renderWithProviders(<App />, {
uiState: stateWithConfirmingTool,
config: configWithExperiment,
});
const { lastFrame, waitUntilReady, unmount } = renderWithProviders(
<App />,
{
uiState: stateWithConfirmingTool,
config: configWithExperiment,
},
);
await waitUntilReady();
expect(lastFrame()).toContain('Tips for getting started');
expect(lastFrame()).toContain('Notifications');
expect(lastFrame()).toContain('Action Required'); // From ToolConfirmationQueue
expect(lastFrame()).toContain('Composer');
expect(lastFrame()).toMatchSnapshot();
unmount();
});
describe('Snapshots', () => {
it('renders default layout correctly', () => {
it('renders default layout correctly', async () => {
(useIsScreenReaderEnabled as Mock).mockReturnValue(false);
const { lastFrame } = renderWithProviders(<App />, {
uiState: mockUIState,
});
const { lastFrame, waitUntilReady, unmount } = renderWithProviders(
<App />,
{
uiState: mockUIState,
},
);
await waitUntilReady();
expect(lastFrame()).toMatchSnapshot();
unmount();
});
it('renders screen reader layout correctly', () => {
it('renders screen reader layout correctly', async () => {
(useIsScreenReaderEnabled as Mock).mockReturnValue(true);
const { lastFrame } = renderWithProviders(<App />, {
uiState: mockUIState,
});
const { lastFrame, waitUntilReady, unmount } = renderWithProviders(
<App />,
{
uiState: mockUIState,
},
);
await waitUntilReady();
expect(lastFrame()).toMatchSnapshot();
unmount();
});
it('renders with dialogs visible', () => {
it('renders with dialogs visible', async () => {
const dialogUIState = {
...mockUIState,
dialogsVisible: true,
} as UIState;
const { lastFrame } = renderWithProviders(<App />, {
uiState: dialogUIState,
});
const { lastFrame, waitUntilReady, unmount } = renderWithProviders(
<App />,
{
uiState: dialogUIState,
},
);
await waitUntilReady();
expect(lastFrame()).toMatchSnapshot();
unmount();
});
});
});
+374 -2
View File
@@ -14,9 +14,8 @@ import {
type Mock,
type MockedObject,
} from 'vitest';
import { render, persistentStateMock } from '../test-utils/render.js';
import { render, cleanup, persistentStateMock } from '../test-utils/render.js';
import { waitFor } from '../test-utils/async.js';
import { cleanup } from 'ink-testing-library';
import { act, useContext, type ReactElement } from 'react';
import { AppContainer } from './AppContainer.js';
import { SettingsContext } from './contexts/SettingsContext.js';
@@ -49,6 +48,15 @@ const mockIdeClient = vi.hoisted(() => ({
const mocks = vi.hoisted(() => ({
mockStdout: { write: vi.fn() },
}));
const terminalNotificationsMocks = vi.hoisted(() => ({
notifyViaTerminal: vi.fn().mockResolvedValue(true),
isNotificationsEnabled: vi.fn(() => true),
buildRunEventNotificationContent: vi.fn((event) => ({
title: 'Mock Notification',
subtitle: 'Mock Subtitle',
body: JSON.stringify(event),
})),
}));
vi.mock('@google/gemini-cli-core', async (importOriginal) => {
const actual =
@@ -165,6 +173,12 @@ vi.mock('./hooks/useShellInactivityStatus.js', () => ({
inactivityStatus: 'none',
})),
}));
vi.mock('../utils/terminalNotifications.js', () => ({
notifyViaTerminal: terminalNotificationsMocks.notifyViaTerminal,
isNotificationsEnabled: terminalNotificationsMocks.isNotificationsEnabled,
buildRunEventNotificationContent:
terminalNotificationsMocks.buildRunEventNotificationContent,
}));
vi.mock('./hooks/useTerminalTheme.js', () => ({
useTerminalTheme: vi.fn(),
}));
@@ -172,6 +186,7 @@ vi.mock('./hooks/useTerminalTheme.js', () => ({
import { useHookDisplayState } from './hooks/useHookDisplayState.js';
import { useTerminalTheme } from './hooks/useTerminalTheme.js';
import { useShellInactivityStatus } from './hooks/useShellInactivityStatus.js';
import { useFocus } from './hooks/useFocus.js';
// Mock external utilities
vi.mock('../utils/events.js');
@@ -280,6 +295,7 @@ describe('AppContainer State Management', () => {
const mockedUseHookDisplayState = useHookDisplayState as Mock;
const mockedUseTerminalTheme = useTerminalTheme as Mock;
const mockedUseShellInactivityStatus = useShellInactivityStatus as Mock;
const mockedUseFocusState = useFocus as Mock;
const DEFAULT_GEMINI_STREAM_MOCK = {
streamingState: 'idle',
@@ -417,6 +433,10 @@ describe('AppContainer State Management', () => {
shouldShowFocusHint: false,
inactivityStatus: 'none',
});
mockedUseFocusState.mockReturnValue({
isFocused: true,
hasReceivedFocusEvent: true,
});
// Mock Config
mockConfig = makeFakeConfig();
@@ -525,6 +545,358 @@ describe('AppContainer State Management', () => {
});
describe('State Initialization', () => {
it('sends a macOS notification when confirmation is pending and terminal is unfocused', async () => {
mockedUseFocusState.mockReturnValue({
isFocused: false,
hasReceivedFocusEvent: true,
});
mockedUseGeminiStream.mockReturnValue({
...DEFAULT_GEMINI_STREAM_MOCK,
pendingHistoryItems: [
{
type: 'tool_group',
tools: [
{
callId: 'call-1',
name: 'run_shell_command',
description: 'Run command',
resultDisplay: undefined,
status: CoreToolCallStatus.AwaitingApproval,
confirmationDetails: {
type: 'exec',
title: 'Run shell command',
command: 'ls',
rootCommand: 'ls',
rootCommands: ['ls'],
},
},
],
},
],
});
let unmount: (() => void) | undefined;
await act(async () => {
const rendered = renderAppContainer();
unmount = rendered.unmount;
});
await waitFor(() =>
expect(terminalNotificationsMocks.notifyViaTerminal).toHaveBeenCalled(),
);
expect(
terminalNotificationsMocks.buildRunEventNotificationContent,
).toHaveBeenCalledWith(
expect.objectContaining({
type: 'attention',
}),
);
await act(async () => {
unmount?.();
});
});
it('does not send attention notification when terminal is focused', async () => {
mockedUseFocusState.mockReturnValue({
isFocused: true,
hasReceivedFocusEvent: true,
});
mockedUseGeminiStream.mockReturnValue({
...DEFAULT_GEMINI_STREAM_MOCK,
pendingHistoryItems: [
{
type: 'tool_group',
tools: [
{
callId: 'call-2',
name: 'run_shell_command',
description: 'Run command',
resultDisplay: undefined,
status: CoreToolCallStatus.AwaitingApproval,
confirmationDetails: {
type: 'exec',
title: 'Run shell command',
command: 'ls',
rootCommand: 'ls',
rootCommands: ['ls'],
},
},
],
},
],
});
let unmount: (() => void) | undefined;
await act(async () => {
const rendered = renderAppContainer();
unmount = rendered.unmount;
});
expect(
terminalNotificationsMocks.notifyViaTerminal,
).not.toHaveBeenCalled();
await act(async () => {
unmount?.();
});
});
it('sends attention notification when focus reporting is unavailable', async () => {
mockedUseFocusState.mockReturnValue({
isFocused: true,
hasReceivedFocusEvent: false,
});
mockedUseGeminiStream.mockReturnValue({
...DEFAULT_GEMINI_STREAM_MOCK,
pendingHistoryItems: [
{
type: 'tool_group',
tools: [
{
callId: 'call-focus-unknown',
name: 'run_shell_command',
description: 'Run command',
resultDisplay: undefined,
status: CoreToolCallStatus.AwaitingApproval,
confirmationDetails: {
type: 'exec',
title: 'Run shell command',
command: 'ls',
rootCommand: 'ls',
rootCommands: ['ls'],
},
},
],
},
],
});
let unmount: (() => void) | undefined;
await act(async () => {
const rendered = renderAppContainer();
unmount = rendered.unmount;
});
await waitFor(() =>
expect(terminalNotificationsMocks.notifyViaTerminal).toHaveBeenCalled(),
);
await act(async () => {
unmount?.();
});
});
it('sends a macOS notification when a response completes while unfocused', async () => {
mockedUseFocusState.mockReturnValue({
isFocused: false,
hasReceivedFocusEvent: true,
});
let currentStreamingState: 'idle' | 'responding' = 'responding';
mockedUseGeminiStream.mockImplementation(() => ({
...DEFAULT_GEMINI_STREAM_MOCK,
streamingState: currentStreamingState,
}));
let unmount: (() => void) | undefined;
let rerender: ((tree: ReactElement) => void) | undefined;
await act(async () => {
const rendered = renderAppContainer();
unmount = rendered.unmount;
rerender = rendered.rerender;
});
currentStreamingState = 'idle';
await act(async () => {
rerender?.(getAppContainer());
});
await waitFor(() =>
expect(
terminalNotificationsMocks.buildRunEventNotificationContent,
).toHaveBeenCalledWith(
expect.objectContaining({
type: 'session_complete',
detail: 'Gemini CLI finished responding.',
}),
),
);
expect(terminalNotificationsMocks.notifyViaTerminal).toHaveBeenCalled();
await act(async () => {
unmount?.();
});
});
it('sends completion notification when focus reporting is unavailable', async () => {
mockedUseFocusState.mockReturnValue({
isFocused: true,
hasReceivedFocusEvent: false,
});
let currentStreamingState: 'idle' | 'responding' = 'responding';
mockedUseGeminiStream.mockImplementation(() => ({
...DEFAULT_GEMINI_STREAM_MOCK,
streamingState: currentStreamingState,
}));
let unmount: (() => void) | undefined;
let rerender: ((tree: ReactElement) => void) | undefined;
await act(async () => {
const rendered = renderAppContainer();
unmount = rendered.unmount;
rerender = rendered.rerender;
});
currentStreamingState = 'idle';
await act(async () => {
rerender?.(getAppContainer());
});
await waitFor(() =>
expect(
terminalNotificationsMocks.buildRunEventNotificationContent,
).toHaveBeenCalledWith(
expect.objectContaining({
type: 'session_complete',
detail: 'Gemini CLI finished responding.',
}),
),
);
await act(async () => {
unmount?.();
});
});
it('does not send completion notification when another action-required dialog is pending', async () => {
mockedUseFocusState.mockReturnValue({
isFocused: false,
hasReceivedFocusEvent: true,
});
mockedUseQuotaAndFallback.mockReturnValue({
proQuotaRequest: { kind: 'upgrade' },
handleProQuotaChoice: vi.fn(),
});
let currentStreamingState: 'idle' | 'responding' = 'responding';
mockedUseGeminiStream.mockImplementation(() => ({
...DEFAULT_GEMINI_STREAM_MOCK,
streamingState: currentStreamingState,
}));
let unmount: (() => void) | undefined;
let rerender: ((tree: ReactElement) => void) | undefined;
await act(async () => {
const rendered = renderAppContainer();
unmount = rendered.unmount;
rerender = rendered.rerender;
});
currentStreamingState = 'idle';
await act(async () => {
rerender?.(getAppContainer());
});
expect(
terminalNotificationsMocks.notifyViaTerminal,
).not.toHaveBeenCalled();
await act(async () => {
unmount?.();
});
});
it('can send repeated attention notifications for the same key after pending state clears', async () => {
mockedUseFocusState.mockReturnValue({
isFocused: false,
hasReceivedFocusEvent: true,
});
let pendingHistoryItems = [
{
type: 'tool_group',
tools: [
{
callId: 'repeat-key-call',
name: 'run_shell_command',
description: 'Run command',
resultDisplay: undefined,
status: CoreToolCallStatus.AwaitingApproval,
confirmationDetails: {
type: 'exec',
title: 'Run shell command',
command: 'ls',
rootCommand: 'ls',
rootCommands: ['ls'],
},
},
],
},
];
mockedUseGeminiStream.mockImplementation(() => ({
...DEFAULT_GEMINI_STREAM_MOCK,
pendingHistoryItems,
}));
let unmount: (() => void) | undefined;
let rerender: ((tree: ReactElement) => void) | undefined;
await act(async () => {
const rendered = renderAppContainer();
unmount = rendered.unmount;
rerender = rendered.rerender;
});
await waitFor(() =>
expect(
terminalNotificationsMocks.notifyViaTerminal,
).toHaveBeenCalledTimes(1),
);
pendingHistoryItems = [];
await act(async () => {
rerender?.(getAppContainer());
});
pendingHistoryItems = [
{
type: 'tool_group',
tools: [
{
callId: 'repeat-key-call',
name: 'run_shell_command',
description: 'Run command',
resultDisplay: undefined,
status: CoreToolCallStatus.AwaitingApproval,
confirmationDetails: {
type: 'exec',
title: 'Run shell command',
command: 'ls',
rootCommand: 'ls',
rootCommands: ['ls'],
},
},
],
},
];
await act(async () => {
rerender?.(getAppContainer());
});
await waitFor(() =>
expect(
terminalNotificationsMocks.notifyViaTerminal,
).toHaveBeenCalledTimes(2),
);
await act(async () => {
unmount?.();
});
});
it('initializes with theme error from initialization result', async () => {
const initResultWithError = {
...mockInitResult,
+133 -3
View File
@@ -79,6 +79,8 @@ import {
type AgentsDiscoveredPayload,
ChangeAuthRequestedError,
CoreToolCallStatus,
generateSteeringAckMessage,
buildUserSteeringHintPrompt,
} from '@google/gemini-cli-core';
import { validateAuthMethod } from '../config/auth.js';
import process from 'node:process';
@@ -156,6 +158,8 @@ import { useTerminalTheme } from './hooks/useTerminalTheme.js';
import { useTimedMessage } from './hooks/useTimedMessage.js';
import { shouldDismissShortcutsHelpOnHotkey } from './utils/shortcutsHelp.js';
import { useSuspend } from './hooks/useSuspend.js';
import { useRunEventNotifications } from './hooks/useRunEventNotifications.js';
import { isNotificationsEnabled } from '../utils/terminalNotifications.js';
function isToolExecuting(pendingHistoryItems: HistoryItemWithoutId[]) {
return pendingHistoryItems.some((item) => {
@@ -209,6 +213,7 @@ const SHELL_HEIGHT_PADDING = 10;
export const AppContainer = (props: AppContainerProps) => {
const { config, initializationResult, resumedSessionData } = props;
const settings = useSettings();
const notificationsEnabled = isNotificationsEnabled(settings);
const historyManager = useHistory({
chatRecordingService: config.getGeminiClient()?.getChatRecordingService(),
@@ -694,6 +699,7 @@ export const AppContainer = (props: AppContainerProps) => {
settings.setValue(scope, 'security.auth.selectedType', authType);
try {
config.setRemoteAdminSettings(undefined);
await config.refreshAuth(authType);
setAuthState(AuthState.Authenticated);
} catch (e) {
@@ -993,6 +999,30 @@ Logging in with Google... Restarting Gemini CLI to continue.
}
}, [pendingRestorePrompt, inputHistory, historyManager.history]);
const pendingHintsRef = useRef<string[]>([]);
const [pendingHintCount, setPendingHintCount] = useState(0);
const consumePendingHints = useCallback(() => {
if (pendingHintsRef.current.length === 0) {
return null;
}
const hint = pendingHintsRef.current.join('\n');
pendingHintsRef.current = [];
setPendingHintCount(0);
return hint;
}, []);
useEffect(() => {
const hintListener = (hint: string) => {
pendingHintsRef.current.push(hint);
setPendingHintCount((prev) => prev + 1);
};
config.userHintService.onUserHint(hintListener);
return () => {
config.userHintService.offUserHint(hintListener);
};
}, [config]);
const {
streamingState,
submitQuery,
@@ -1031,6 +1061,7 @@ Logging in with Google... Restarting Gemini CLI to continue.
terminalWidth,
terminalHeight,
embeddedShellFocused,
consumePendingHints,
);
toggleBackgroundShellRef.current = toggleBackgroundShell;
@@ -1139,10 +1170,38 @@ Logging in with Google... Restarting Gemini CLI to continue.
],
);
const handleHintSubmit = useCallback(
(hint: string) => {
const trimmed = hint.trim();
if (!trimmed) {
return;
}
config.userHintService.addUserHint(trimmed);
// Render hints with a distinct style.
historyManager.addItem({
type: 'hint',
text: trimmed,
});
},
[config, historyManager],
);
const handleFinalSubmit = useCallback(
async (submittedValue: string) => {
const isSlash = isSlashCommand(submittedValue.trim());
const isIdle = streamingState === StreamingState.Idle;
const isAgentRunning =
streamingState === StreamingState.Responding ||
isToolExecuting([
...pendingSlashCommandHistoryItems,
...pendingGeminiHistoryItems,
]);
if (config.isModelSteeringEnabled() && isAgentRunning && !isSlash) {
handleHintSubmit(submittedValue);
addInput(submittedValue);
return;
}
if (isSlash || (isIdle && isMcpReady)) {
if (!isSlash) {
@@ -1184,7 +1243,10 @@ Logging in with Google... Restarting Gemini CLI to continue.
isMcpReady,
streamingState,
messageQueue.length,
pendingSlashCommandHistoryItems,
pendingGeminiHistoryItems,
config,
handleHintSubmit,
],
);
@@ -1247,7 +1309,7 @@ Logging in with Google... Restarting Gemini CLI to continue.
sanitizationConfig: config.sanitizationConfig,
});
const isFocused = useFocus();
const { isFocused, hasReceivedFocusEvent } = useFocus();
// Context file names computation
const contextFileNames = useMemo(() => {
@@ -1879,12 +1941,17 @@ Logging in with Google... Restarting Gemini CLI to continue.
[pendingHistoryItems],
);
const hasConfirmUpdateExtensionRequests =
confirmUpdateExtensionRequests.length > 0;
const hasLoopDetectionConfirmationRequest =
!!loopDetectionConfirmationRequest;
const hasPendingActionRequired =
hasPendingToolConfirmation ||
!!commandConfirmationRequest ||
!!authConsentRequest ||
confirmUpdateExtensionRequests.length > 0 ||
!!loopDetectionConfirmationRequest ||
hasConfirmUpdateExtensionRequests ||
hasLoopDetectionConfirmationRequest ||
!!proQuotaRequest ||
!!validationRequest ||
!!customDialog;
@@ -1902,6 +1969,20 @@ Logging in with Google... Restarting Gemini CLI to continue.
allowPlanMode,
});
useRunEventNotifications({
notificationsEnabled,
isFocused,
hasReceivedFocusEvent,
streamingState,
hasPendingActionRequired,
pendingHistoryItems,
commandConfirmationRequest,
authConsentRequest,
permissionConfirmationRequest,
hasConfirmUpdateExtensionRequests,
hasLoopDetectionConfirmationRequest,
});
const isPassiveShortcutsHelpState =
isInputActive &&
streamingState === StreamingState.Idle &&
@@ -1917,6 +1998,44 @@ Logging in with Google... Restarting Gemini CLI to continue.
setShortcutsHelpVisible,
]);
useEffect(() => {
if (
!isConfigInitialized ||
!config.isModelSteeringEnabled() ||
streamingState !== StreamingState.Idle ||
!isMcpReady ||
isToolAwaitingConfirmation(pendingHistoryItems)
) {
return;
}
const pendingHint = consumePendingHints();
if (!pendingHint) {
return;
}
void generateSteeringAckMessage(
config.getBaseLlmClient(),
pendingHint,
).then((ackText) => {
historyManager.addItem({
type: 'info',
text: ackText,
});
});
void submitQuery([{ text: buildUserSteeringHintPrompt(pendingHint) }]);
}, [
config,
historyManager,
isConfigInitialized,
isMcpReady,
streamingState,
submitQuery,
consumePendingHints,
pendingHistoryItems,
pendingHintCount,
]);
const allToolCalls = useMemo(
() =>
pendingHistoryItems
@@ -2083,6 +2202,13 @@ Logging in with Google... Restarting Gemini CLI to continue.
isBackgroundShellListOpen,
adminSettingsChanged,
newAgents,
hintMode:
config.isModelSteeringEnabled() &&
isToolExecuting([
...pendingSlashCommandHistoryItems,
...pendingGeminiHistoryItems,
]),
hintBuffer: '',
}),
[
isThemeDialogOpen,
@@ -2254,6 +2380,10 @@ Logging in with Google... Restarting Gemini CLI to continue.
setActiveBackgroundShellPid,
setIsBackgroundShellListOpen,
setAuthContext,
onHintInput: () => {},
onHintBackspace: () => {},
onHintClear: () => {},
onHintSubmit: () => {},
handleRestart: async () => {
if (process.send) {
const remoteSettings = config.getRemoteAdminSettings();
@@ -11,8 +11,6 @@ import { IdeIntegrationNudge } from './IdeIntegrationNudge.js';
import { KeypressProvider } from './contexts/KeypressContext.js';
import { debugLogger } from '@google/gemini-cli-core';
const delay = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms));
// Mock debugLogger
vi.mock('@google/gemini-cli-core', async (importOriginal) => {
const actual =
@@ -56,128 +54,129 @@ describe('IdeIntegrationNudge', () => {
});
it('renders correctly with default options', async () => {
const { lastFrame } = render(
const { lastFrame, waitUntilReady, unmount } = render(
<KeypressProvider>
<IdeIntegrationNudge {...defaultProps} />
</KeypressProvider>,
);
await act(async () => {
await delay(100);
});
await waitUntilReady();
const frame = lastFrame();
expect(frame).toContain('Do you want to connect VS Code to Gemini CLI?');
expect(frame).toContain('Yes');
expect(frame).toContain('No (esc)');
expect(frame).toContain("No, don't ask again");
unmount();
});
it('handles "Yes" selection', async () => {
const onComplete = vi.fn();
const { stdin } = render(
const { stdin, waitUntilReady, unmount } = render(
<KeypressProvider>
<IdeIntegrationNudge {...defaultProps} onComplete={onComplete} />
</KeypressProvider>,
);
await act(async () => {
await delay(100);
});
await waitUntilReady();
// "Yes" is the first option and selected by default usually.
await act(async () => {
stdin.write('\r');
await delay(100);
});
await waitUntilReady();
expect(onComplete).toHaveBeenCalledWith({
userSelection: 'yes',
isExtensionPreInstalled: false,
});
unmount();
});
it('handles "No" selection', async () => {
const onComplete = vi.fn();
const { stdin } = render(
const { stdin, waitUntilReady, unmount } = render(
<KeypressProvider>
<IdeIntegrationNudge {...defaultProps} onComplete={onComplete} />
</KeypressProvider>,
);
await act(async () => {
await delay(100);
});
await waitUntilReady();
// Navigate down to "No (esc)"
await act(async () => {
stdin.write('\u001B[B'); // Down arrow
await delay(100);
});
await waitUntilReady();
await act(async () => {
stdin.write('\r'); // Enter
await delay(100);
});
await waitUntilReady();
expect(onComplete).toHaveBeenCalledWith({
userSelection: 'no',
isExtensionPreInstalled: false,
});
unmount();
});
it('handles "Dismiss" selection', async () => {
const onComplete = vi.fn();
const { stdin } = render(
const { stdin, waitUntilReady, unmount } = render(
<KeypressProvider>
<IdeIntegrationNudge {...defaultProps} onComplete={onComplete} />
</KeypressProvider>,
);
await act(async () => {
await delay(100);
});
await waitUntilReady();
// Navigate down to "No, don't ask again"
await act(async () => {
stdin.write('\u001B[B'); // Down arrow
await delay(100);
});
await waitUntilReady();
await act(async () => {
stdin.write('\u001B[B'); // Down arrow
await delay(100);
});
await waitUntilReady();
await act(async () => {
stdin.write('\r'); // Enter
await delay(100);
});
await waitUntilReady();
expect(onComplete).toHaveBeenCalledWith({
userSelection: 'dismiss',
isExtensionPreInstalled: false,
});
unmount();
});
it('handles Escape key press', async () => {
const onComplete = vi.fn();
const { stdin } = render(
const { stdin, waitUntilReady, unmount } = render(
<KeypressProvider>
<IdeIntegrationNudge {...defaultProps} onComplete={onComplete} />
</KeypressProvider>,
);
await act(async () => {
await delay(100);
});
await waitUntilReady();
// Press Escape
await act(async () => {
stdin.write('\u001B');
await delay(100);
});
// Escape key has a timeout in KeypressContext, so we need to wrap waitUntilReady in act
await act(async () => {
await waitUntilReady();
});
expect(onComplete).toHaveBeenCalledWith({
userSelection: 'no',
isExtensionPreInstalled: false,
});
unmount();
});
it('displays correct text and handles selection when extension is pre-installed', async () => {
@@ -185,15 +184,13 @@ describe('IdeIntegrationNudge', () => {
vi.stubEnv('GEMINI_CLI_IDE_WORKSPACE_PATH', '/tmp');
const onComplete = vi.fn();
const { lastFrame, stdin } = render(
const { lastFrame, stdin, waitUntilReady, unmount } = render(
<KeypressProvider>
<IdeIntegrationNudge {...defaultProps} onComplete={onComplete} />
</KeypressProvider>,
);
await act(async () => {
await delay(100);
});
await waitUntilReady();
const frame = lastFrame();
@@ -205,12 +202,13 @@ describe('IdeIntegrationNudge', () => {
// Select "Yes"
await act(async () => {
stdin.write('\r');
await delay(100);
});
await waitUntilReady();
expect(onComplete).toHaveBeenCalledWith({
userSelection: 'yes',
isExtensionPreInstalled: true,
});
unmount();
});
});
@@ -61,7 +61,8 @@ Tips for getting started:
2. Be specific for the best results.
3. Create GEMINI.md files to customize your interactions with Gemini.
4. /help for more information.
Composer"
Composer
"
`;
exports[`App > Snapshots > renders with dialogs visible 1`] = `
@@ -124,19 +125,19 @@ Tips for getting started:
3. Create GEMINI.md files to customize your interactions with Gemini.
4. /help for more information.
HistoryItemDisplay
╭──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────
│ Action Required
│ ? ls list directory
│ ls
│ Allow execution of: 'ls'?
│ ● 1. Allow once
│ 2. Allow for this session
│ 3. No, suggest changes (esc)
╰──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────
╭──────────────────────────────────────────────────────────────────────────────────────────────────╮
│ Action Required │
│ │
│ ? ls list directory │
│ │
│ ls │
│ Allow execution of: 'ls'? │
│ │
│ ● 1. Allow once │
│ 2. Allow for this session │
│ 3. No, suggest changes (esc) │
│ │
╰──────────────────────────────────────────────────────────────────────────────────────────────────╯
@@ -65,21 +65,24 @@ describe('ApiAuthDialog', () => {
mockedUseTextBuffer.mockReturnValue(mockBuffer);
});
it('renders correctly', () => {
const { lastFrame } = render(
it('renders correctly', async () => {
const { lastFrame, waitUntilReady, unmount } = render(
<ApiAuthDialog onSubmit={onSubmit} onCancel={onCancel} />,
);
await waitUntilReady();
expect(lastFrame()).toMatchSnapshot();
unmount();
});
it('renders with a defaultValue', () => {
render(
it('renders with a defaultValue', async () => {
const { waitUntilReady, unmount } = render(
<ApiAuthDialog
onSubmit={onSubmit}
onCancel={onCancel}
defaultValue="test-key"
/>,
);
await waitUntilReady();
expect(mockedUseTextBuffer).toHaveBeenCalledWith(
expect.objectContaining({
initialText: 'test-key',
@@ -88,6 +91,7 @@ describe('ApiAuthDialog', () => {
}),
}),
);
unmount();
});
it.each([
@@ -100,9 +104,12 @@ describe('ApiAuthDialog', () => {
{ keyName: 'escape', sequence: '\u001b', expectedCall: onCancel, args: [] },
])(
'calls $expectedCall.name when $keyName is pressed',
({ keyName, sequence, expectedCall, args }) => {
async ({ keyName, sequence, expectedCall, args }) => {
mockBuffer.text = 'submitted-key'; // Set for the onSubmit case
render(<ApiAuthDialog onSubmit={onSubmit} onCancel={onCancel} />);
const { waitUntilReady, unmount } = render(
<ApiAuthDialog onSubmit={onSubmit} onCancel={onCancel} />,
);
await waitUntilReady();
// calls[0] is the ApiAuthDialog's useKeypress (Ctrl+C handler)
// calls[1] is the TextInput's useKeypress (typing handler)
const keypressHandler = mockedUseKeypress.mock.calls[1][0];
@@ -117,23 +124,29 @@ describe('ApiAuthDialog', () => {
});
expect(expectedCall).toHaveBeenCalledWith(...args);
unmount();
},
);
it('displays an error message', () => {
const { lastFrame } = render(
it('displays an error message', async () => {
const { lastFrame, waitUntilReady, unmount } = render(
<ApiAuthDialog
onSubmit={onSubmit}
onCancel={onCancel}
error="Invalid API Key"
/>,
);
await waitUntilReady();
expect(lastFrame()).toContain('Invalid API Key');
unmount();
});
it('calls clearApiKey and clears buffer when Ctrl+C is pressed', async () => {
render(<ApiAuthDialog onSubmit={onSubmit} onCancel={onCancel} />);
const { waitUntilReady, unmount } = render(
<ApiAuthDialog onSubmit={onSubmit} onCancel={onCancel} />,
);
await waitUntilReady();
// Call 0 is ApiAuthDialog (isActive: true)
// Call 1 is TextInput (isActive: true, priority: true)
const keypressHandler = mockedUseKeypress.mock.calls[0][0];
@@ -149,5 +162,6 @@ describe('ApiAuthDialog', () => {
expect(clearApiKey).toHaveBeenCalled();
expect(mockBuffer.setText).toHaveBeenCalledWith('');
});
unmount();
});
});
+95 -27
View File
@@ -139,11 +139,14 @@ describe('AuthDialog', () => {
},
])(
'correctly shows/hides COMPUTE_ADC options $desc',
({ env, shouldContain, shouldNotContain }) => {
async ({ env, shouldContain, shouldNotContain }) => {
for (const [key, value] of Object.entries(env)) {
vi.stubEnv(key, value as string);
}
renderWithProviders(<AuthDialog {...props} />);
const { waitUntilReady, unmount } = renderWithProviders(
<AuthDialog {...props} />,
);
await waitUntilReady();
const items = mockedRadioButtonSelect.mock.calls[0][0].items;
for (const item of shouldContain) {
expect(items).toContainEqual(item);
@@ -151,23 +154,32 @@ describe('AuthDialog', () => {
for (const item of shouldNotContain) {
expect(items).not.toContainEqual(item);
}
unmount();
},
);
});
it('filters auth types when enforcedType is set', () => {
it('filters auth types when enforcedType is set', async () => {
props.settings.merged.security.auth.enforcedType = AuthType.USE_GEMINI;
renderWithProviders(<AuthDialog {...props} />);
const { waitUntilReady, unmount } = renderWithProviders(
<AuthDialog {...props} />,
);
await waitUntilReady();
const items = mockedRadioButtonSelect.mock.calls[0][0].items;
expect(items).toHaveLength(1);
expect(items[0].value).toBe(AuthType.USE_GEMINI);
unmount();
});
it('sets initial index to 0 when enforcedType is set', () => {
it('sets initial index to 0 when enforcedType is set', async () => {
props.settings.merged.security.auth.enforcedType = AuthType.USE_GEMINI;
renderWithProviders(<AuthDialog {...props} />);
const { waitUntilReady, unmount } = renderWithProviders(
<AuthDialog {...props} />,
);
await waitUntilReady();
const { initialIndex } = mockedRadioButtonSelect.mock.calls[0][0];
expect(initialIndex).toBe(0);
unmount();
});
describe('Initial Auth Type Selection', () => {
@@ -199,18 +211,25 @@ describe('AuthDialog', () => {
expected: AuthType.LOGIN_WITH_GOOGLE,
desc: 'defaults to Login with Google',
},
])('selects initial auth type $desc', ({ setup, expected }) => {
])('selects initial auth type $desc', async ({ setup, expected }) => {
setup();
renderWithProviders(<AuthDialog {...props} />);
const { waitUntilReady, unmount } = renderWithProviders(
<AuthDialog {...props} />,
);
await waitUntilReady();
const { items, initialIndex } = mockedRadioButtonSelect.mock.calls[0][0];
expect(items[initialIndex].value).toBe(expected);
unmount();
});
});
describe('handleAuthSelect', () => {
it('calls onAuthError if validation fails', () => {
it('calls onAuthError if validation fails', async () => {
mockedValidateAuthMethod.mockReturnValue('Invalid method');
renderWithProviders(<AuthDialog {...props} />);
const { waitUntilReady, unmount } = renderWithProviders(
<AuthDialog {...props} />,
);
await waitUntilReady();
const { onSelect: handleAuthSelect } =
mockedRadioButtonSelect.mock.calls[0][0];
handleAuthSelect(AuthType.USE_GEMINI);
@@ -221,11 +240,15 @@ describe('AuthDialog', () => {
);
expect(props.onAuthError).toHaveBeenCalledWith('Invalid method');
expect(props.settings.setValue).not.toHaveBeenCalled();
unmount();
});
it('sets auth context with requiresRestart: true for LOGIN_WITH_GOOGLE', async () => {
mockedValidateAuthMethod.mockReturnValue(null);
renderWithProviders(<AuthDialog {...props} />);
const { waitUntilReady, unmount } = renderWithProviders(
<AuthDialog {...props} />,
);
await waitUntilReady();
const { onSelect: handleAuthSelect } =
mockedRadioButtonSelect.mock.calls[0][0];
await handleAuthSelect(AuthType.LOGIN_WITH_GOOGLE);
@@ -233,16 +256,21 @@ describe('AuthDialog', () => {
expect(props.setAuthContext).toHaveBeenCalledWith({
requiresRestart: true,
});
unmount();
});
it('sets auth context with empty object for other auth types', async () => {
mockedValidateAuthMethod.mockReturnValue(null);
renderWithProviders(<AuthDialog {...props} />);
const { waitUntilReady, unmount } = renderWithProviders(
<AuthDialog {...props} />,
);
await waitUntilReady();
const { onSelect: handleAuthSelect } =
mockedRadioButtonSelect.mock.calls[0][0];
await handleAuthSelect(AuthType.USE_GEMINI);
expect(props.setAuthContext).toHaveBeenCalledWith({});
unmount();
});
it('skips API key dialog on initial setup if env var is present', async () => {
@@ -250,7 +278,10 @@ describe('AuthDialog', () => {
vi.stubEnv('GEMINI_API_KEY', 'test-key-from-env');
// props.settings.merged.security.auth.selectedType is undefined here, simulating initial setup
renderWithProviders(<AuthDialog {...props} />);
const { waitUntilReady, unmount } = renderWithProviders(
<AuthDialog {...props} />,
);
await waitUntilReady();
const { onSelect: handleAuthSelect } =
mockedRadioButtonSelect.mock.calls[0][0];
await handleAuthSelect(AuthType.USE_GEMINI);
@@ -258,6 +289,7 @@ describe('AuthDialog', () => {
expect(props.setAuthState).toHaveBeenCalledWith(
AuthState.Unauthenticated,
);
unmount();
});
it('skips API key dialog if env var is present but empty', async () => {
@@ -265,7 +297,10 @@ describe('AuthDialog', () => {
vi.stubEnv('GEMINI_API_KEY', ''); // Empty string
// props.settings.merged.security.auth.selectedType is undefined here
renderWithProviders(<AuthDialog {...props} />);
const { waitUntilReady, unmount } = renderWithProviders(
<AuthDialog {...props} />,
);
await waitUntilReady();
const { onSelect: handleAuthSelect } =
mockedRadioButtonSelect.mock.calls[0][0];
await handleAuthSelect(AuthType.USE_GEMINI);
@@ -273,6 +308,7 @@ describe('AuthDialog', () => {
expect(props.setAuthState).toHaveBeenCalledWith(
AuthState.Unauthenticated,
);
unmount();
});
it('shows API key dialog on initial setup if no env var is present', async () => {
@@ -280,7 +316,10 @@ describe('AuthDialog', () => {
// process.env['GEMINI_API_KEY'] is not set
// props.settings.merged.security.auth.selectedType is undefined here, simulating initial setup
renderWithProviders(<AuthDialog {...props} />);
const { waitUntilReady, unmount } = renderWithProviders(
<AuthDialog {...props} />,
);
await waitUntilReady();
const { onSelect: handleAuthSelect } =
mockedRadioButtonSelect.mock.calls[0][0];
await handleAuthSelect(AuthType.USE_GEMINI);
@@ -288,6 +327,7 @@ describe('AuthDialog', () => {
expect(props.setAuthState).toHaveBeenCalledWith(
AuthState.AwaitingApiKeyInput,
);
unmount();
});
it('skips API key dialog on re-auth if env var is present (cannot edit)', async () => {
@@ -297,7 +337,10 @@ describe('AuthDialog', () => {
props.settings.merged.security.auth.selectedType =
AuthType.LOGIN_WITH_GOOGLE;
renderWithProviders(<AuthDialog {...props} />);
const { waitUntilReady, unmount } = renderWithProviders(
<AuthDialog {...props} />,
);
await waitUntilReady();
const { onSelect: handleAuthSelect } =
mockedRadioButtonSelect.mock.calls[0][0];
await handleAuthSelect(AuthType.USE_GEMINI);
@@ -305,6 +348,7 @@ describe('AuthDialog', () => {
expect(props.setAuthState).toHaveBeenCalledWith(
AuthState.Unauthenticated,
);
unmount();
});
it('exits process for Login with Google when browser is suppressed', async () => {
@@ -316,7 +360,10 @@ describe('AuthDialog', () => {
vi.mocked(props.config.isBrowserLaunchSuppressed).mockReturnValue(true);
mockedValidateAuthMethod.mockReturnValue(null);
renderWithProviders(<AuthDialog {...props} />);
const { waitUntilReady, unmount } = renderWithProviders(
<AuthDialog {...props} />,
);
await waitUntilReady();
const { onSelect: handleAuthSelect } =
mockedRadioButtonSelect.mock.calls[0][0];
await act(async () => {
@@ -330,13 +377,18 @@ describe('AuthDialog', () => {
exitSpy.mockRestore();
logSpy.mockRestore();
vi.useRealTimers();
unmount();
});
});
it('displays authError when provided', () => {
it('displays authError when provided', async () => {
props.authError = 'Something went wrong';
const { lastFrame } = renderWithProviders(<AuthDialog {...props} />);
const { lastFrame, waitUntilReady, unmount } = renderWithProviders(
<AuthDialog {...props} />,
);
await waitUntilReady();
expect(lastFrame()).toContain('Something went wrong');
unmount();
});
describe('useKeypress', () => {
@@ -375,31 +427,47 @@ describe('AuthDialog', () => {
expect(p.settings.setValue).not.toHaveBeenCalled();
},
},
])('$desc', ({ setup, expectations }) => {
])('$desc', async ({ setup, expectations }) => {
setup();
renderWithProviders(<AuthDialog {...props} />);
const { waitUntilReady, unmount } = renderWithProviders(
<AuthDialog {...props} />,
);
await waitUntilReady();
const keypressHandler = mockedUseKeypress.mock.calls[0][0];
keypressHandler({ name: 'escape' });
expectations(props);
unmount();
});
});
describe('Snapshots', () => {
it('renders correctly with default props', () => {
const { lastFrame } = renderWithProviders(<AuthDialog {...props} />);
it('renders correctly with default props', async () => {
const { lastFrame, waitUntilReady, unmount } = renderWithProviders(
<AuthDialog {...props} />,
);
await waitUntilReady();
expect(lastFrame()).toMatchSnapshot();
unmount();
});
it('renders correctly with auth error', () => {
it('renders correctly with auth error', async () => {
props.authError = 'Something went wrong';
const { lastFrame } = renderWithProviders(<AuthDialog {...props} />);
const { lastFrame, waitUntilReady, unmount } = renderWithProviders(
<AuthDialog {...props} />,
);
await waitUntilReady();
expect(lastFrame()).toMatchSnapshot();
unmount();
});
it('renders correctly with enforced auth type', () => {
it('renders correctly with enforced auth type', async () => {
props.settings.merged.security.auth.enforcedType = AuthType.USE_GEMINI;
const { lastFrame } = renderWithProviders(<AuthDialog {...props} />);
const { lastFrame, waitUntilReady, unmount } = renderWithProviders(
<AuthDialog {...props} />,
);
await waitUntilReady();
expect(lastFrame()).toMatchSnapshot();
unmount();
});
});
});
@@ -54,48 +54,80 @@ describe('AuthInProgress', () => {
vi.useRealTimers();
});
it('renders initial state with spinner', () => {
const { lastFrame } = render(<AuthInProgress onTimeout={onTimeout} />);
it('renders initial state with spinner', async () => {
const { lastFrame, waitUntilReady, unmount } = render(
<AuthInProgress onTimeout={onTimeout} />,
);
await waitUntilReady();
expect(lastFrame()).toContain('[Spinner] Waiting for auth...');
expect(lastFrame()).toContain('Press ESC or CTRL+C to cancel');
unmount();
});
it('calls onTimeout when ESC is pressed', () => {
render(<AuthInProgress onTimeout={onTimeout} />);
it('calls onTimeout when ESC is pressed', async () => {
const { waitUntilReady, unmount } = render(
<AuthInProgress onTimeout={onTimeout} />,
);
await waitUntilReady();
const keypressHandler = vi.mocked(useKeypress).mock.calls[0][0];
keypressHandler({ name: 'escape' } as unknown as Key);
await act(async () => {
keypressHandler({ name: 'escape' } as unknown as Key);
});
// Escape key has a 50ms timeout in KeypressContext, so we need to wrap waitUntilReady in act
await act(async () => {
await waitUntilReady();
});
expect(onTimeout).toHaveBeenCalled();
unmount();
});
it('calls onTimeout when Ctrl+C is pressed', () => {
render(<AuthInProgress onTimeout={onTimeout} />);
it('calls onTimeout when Ctrl+C is pressed', async () => {
const { waitUntilReady, unmount } = render(
<AuthInProgress onTimeout={onTimeout} />,
);
await waitUntilReady();
const keypressHandler = vi.mocked(useKeypress).mock.calls[0][0];
keypressHandler({ name: 'c', ctrl: true } as unknown as Key);
await act(async () => {
keypressHandler({ name: 'c', ctrl: true } as unknown as Key);
});
await waitUntilReady();
expect(onTimeout).toHaveBeenCalled();
unmount();
});
it('calls onTimeout and shows timeout message after 3 minutes', async () => {
const { lastFrame } = render(<AuthInProgress onTimeout={onTimeout} />);
const { lastFrame, waitUntilReady, unmount } = render(
<AuthInProgress onTimeout={onTimeout} />,
);
await waitUntilReady();
await act(async () => {
vi.advanceTimersByTime(180000);
});
await waitUntilReady();
expect(onTimeout).toHaveBeenCalled();
await vi.waitUntil(
() => lastFrame()?.includes('Authentication timed out'),
{ timeout: 1000 },
);
expect(lastFrame()).toContain('Authentication timed out');
unmount();
});
it('clears timer on unmount', () => {
const { unmount } = render(<AuthInProgress onTimeout={onTimeout} />);
act(() => {
it('clears timer on unmount', async () => {
const { waitUntilReady, unmount } = render(
<AuthInProgress onTimeout={onTimeout} />,
);
await waitUntilReady();
await act(async () => {
unmount();
});
vi.advanceTimersByTime(180000);
await act(async () => {
vi.advanceTimersByTime(180000);
});
expect(onTimeout).not.toHaveBeenCalled();
});
});
@@ -40,23 +40,26 @@ describe('LoginWithGoogleRestartDialog', () => {
vi.useRealTimers();
});
it('renders correctly', () => {
const { lastFrame } = render(
it('renders correctly', async () => {
const { lastFrame, waitUntilReady, unmount } = render(
<LoginWithGoogleRestartDialog
onDismiss={onDismiss}
config={mockConfig}
/>,
);
await waitUntilReady();
expect(lastFrame()).toMatchSnapshot();
unmount();
});
it('calls onDismiss when escape is pressed', () => {
render(
it('calls onDismiss when escape is pressed', async () => {
const { waitUntilReady, unmount } = render(
<LoginWithGoogleRestartDialog
onDismiss={onDismiss}
config={mockConfig}
/>,
);
await waitUntilReady();
const keypressHandler = mockedUseKeypress.mock.calls[0][0];
keypressHandler({
@@ -68,6 +71,7 @@ describe('LoginWithGoogleRestartDialog', () => {
});
expect(onDismiss).toHaveBeenCalledTimes(1);
unmount();
});
it.each(['r', 'R'])(
@@ -75,12 +79,13 @@ describe('LoginWithGoogleRestartDialog', () => {
async (keyName) => {
vi.useFakeTimers();
render(
const { waitUntilReady, unmount } = render(
<LoginWithGoogleRestartDialog
onDismiss={onDismiss}
config={mockConfig}
/>,
);
await waitUntilReady();
const keypressHandler = mockedUseKeypress.mock.calls[0][0];
keypressHandler({
@@ -98,6 +103,7 @@ describe('LoginWithGoogleRestartDialog', () => {
expect(exitSpy).toHaveBeenCalledWith(RELAUNCH_EXIT_CODE);
vi.useRealTimers();
unmount();
},
);
});
@@ -14,5 +14,6 @@ exports[`ApiAuthDialog > renders correctly 1`] = `
│ │
│ (Press Enter to submit, Esc to cancel, Ctrl+C to clear stored key) │
│ │
╰──────────────────────────────────────────────────────────────────────────────────────────────────╯"
╰──────────────────────────────────────────────────────────────────────────────────────────────────╯
"
`;
@@ -1,57 +1,60 @@
// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
exports[`AuthDialog > Snapshots > renders correctly with auth error 1`] = `
"╭──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────
│ ? Get started
│ How would you like to authenticate for this project?
│ (selected) Login with Google(not selected) Use Gemini API Key(not selected) Vertex AI
│ Something went wrong
│ (Use Enter to select)
│ Terms of Services and Privacy Notice for Gemini CLI
│ https://github.com/google-gemini/gemini-cli/blob/main/docs/tos-privacy.md
╰──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯"
"╭──────────────────────────────────────────────────────────────────────────────────────────────────╮
│ │
│ ? Get started │
│ │
│ How would you like to authenticate for this project? │
│ │
│ (selected) Login with Google(not selected) Use Gemini API Key(not selected) Vertex AI │
│ │
│ Something went wrong │
│ │
│ (Use Enter to select) │
│ │
│ Terms of Services and Privacy Notice for Gemini CLI │
│ │
│ https://github.com/google-gemini/gemini-cli/blob/main/docs/tos-privacy.md │
│ │
╰──────────────────────────────────────────────────────────────────────────────────────────────────
"
`;
exports[`AuthDialog > Snapshots > renders correctly with default props 1`] = `
"╭──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────
│ ? Get started
│ How would you like to authenticate for this project?
│ (selected) Login with Google(not selected) Use Gemini API Key(not selected) Vertex AI
│ (Use Enter to select)
│ Terms of Services and Privacy Notice for Gemini CLI
│ https://github.com/google-gemini/gemini-cli/blob/main/docs/tos-privacy.md
╰──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯"
"╭──────────────────────────────────────────────────────────────────────────────────────────────────╮
│ │
│ ? Get started │
│ │
│ How would you like to authenticate for this project? │
│ │
│ (selected) Login with Google(not selected) Use Gemini API Key(not selected) Vertex AI │
│ │
│ (Use Enter to select) │
│ │
│ Terms of Services and Privacy Notice for Gemini CLI │
│ │
│ https://github.com/google-gemini/gemini-cli/blob/main/docs/tos-privacy.md │
│ │
╰──────────────────────────────────────────────────────────────────────────────────────────────────
"
`;
exports[`AuthDialog > Snapshots > renders correctly with enforced auth type 1`] = `
"╭──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────
│ ? Get started
│ How would you like to authenticate for this project?
│ (selected) Use Gemini API Key
│ (Use Enter to select)
│ Terms of Services and Privacy Notice for Gemini CLI
│ https://github.com/google-gemini/gemini-cli/blob/main/docs/tos-privacy.md
╰──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯"
"╭──────────────────────────────────────────────────────────────────────────────────────────────────╮
│ │
│ ? Get started │
│ │
│ How would you like to authenticate for this project? │
│ │
│ (selected) Use Gemini API Key │
│ │
│ (Use Enter to select) │
│ │
│ Terms of Services and Privacy Notice for Gemini CLI │
│ │
│ https://github.com/google-gemini/gemini-cli/blob/main/docs/tos-privacy.md │
│ │
╰──────────────────────────────────────────────────────────────────────────────────────────────────
"
`;
@@ -4,5 +4,6 @@ exports[`LoginWithGoogleRestartDialog > renders correctly 1`] = `
"╭──────────────────────────────────────────────────────────────────────────────────────────────────╮
│ You have successfully logged in with Google. Gemini CLI needs to be restarted. Press 'r' to │
│ restart, or 'escape' to choose a different auth method. │
╰──────────────────────────────────────────────────────────────────────────────────────────────────╯"
╰──────────────────────────────────────────────────────────────────────────────────────────────────╯
"
`;
@@ -27,9 +27,11 @@ import { uiTelemetryService } from '@google/gemini-cli-core';
describe('clearCommand', () => {
let mockContext: CommandContext;
let mockResetChat: ReturnType<typeof vi.fn>;
let mockHintClear: ReturnType<typeof vi.fn>;
beforeEach(() => {
mockResetChat = vi.fn().mockResolvedValue(undefined);
mockHintClear = vi.fn();
const mockGetChatRecordingService = vi.fn();
vi.clearAllMocks();
@@ -50,12 +52,15 @@ describe('clearCommand', () => {
fireSessionEndEvent: vi.fn().mockResolvedValue(undefined),
fireSessionStartEvent: vi.fn().mockResolvedValue(undefined),
}),
userHintService: {
clear: mockHintClear,
},
},
},
});
});
it('should set debug message, reset chat, reset telemetry, and clear UI when config is available', async () => {
it('should set debug message, reset chat, reset telemetry, clear hints, and clear UI when config is available', async () => {
if (!clearCommand.action) {
throw new Error('clearCommand must have an action.');
}
@@ -68,6 +73,7 @@ describe('clearCommand', () => {
expect(mockContext.ui.setDebugMessage).toHaveBeenCalledTimes(1);
expect(mockResetChat).toHaveBeenCalledTimes(1);
expect(mockHintClear).toHaveBeenCalledTimes(1);
expect(uiTelemetryService.setLastPromptTokenCount).toHaveBeenCalledWith(0);
expect(uiTelemetryService.setLastPromptTokenCount).toHaveBeenCalledTimes(1);
expect(mockContext.ui.clear).toHaveBeenCalledTimes(1);
@@ -43,6 +43,9 @@ export const clearCommand: SlashCommand = {
context.ui.setDebugMessage('Clearing terminal.');
}
// Reset user steering hints
config?.userHintService.clear();
// Start a new conversation recording with a new session ID
if (config && chatRecordingService) {
const newSessionId = randomUUID();
@@ -290,7 +290,7 @@ describe('extensionsCommand', () => {
it('should inform user if there are no extensions to update with --all', async () => {
mockDispatchExtensionState.mockImplementationOnce(
(action: ExtensionUpdateAction) => {
async (action: ExtensionUpdateAction) => {
if (action.type === 'SCHEDULE_UPDATE') {
action.payload.onComplete([]);
}
@@ -306,7 +306,7 @@ describe('extensionsCommand', () => {
it('should call setPendingItem and addItem in a finally block on success', async () => {
mockDispatchExtensionState.mockImplementationOnce(
(action: ExtensionUpdateAction) => {
async (action: ExtensionUpdateAction) => {
if (action.type === 'SCHEDULE_UPDATE') {
action.payload.onComplete([
{
@@ -357,7 +357,7 @@ describe('extensionsCommand', () => {
it('should update a single extension by name', async () => {
mockDispatchExtensionState.mockImplementationOnce(
(action: ExtensionUpdateAction) => {
async (action: ExtensionUpdateAction) => {
if (action.type === 'SCHEDULE_UPDATE') {
action.payload.onComplete([
{
@@ -382,7 +382,7 @@ describe('extensionsCommand', () => {
it('should update multiple extensions by name', async () => {
mockDispatchExtensionState.mockImplementationOnce(
(action: ExtensionUpdateAction) => {
async (action: ExtensionUpdateAction) => {
if (action.type === 'SCHEDULE_UPDATE') {
action.payload.onComplete([
{
@@ -24,8 +24,11 @@ describe('AboutBox', () => {
ideClient: '',
};
it('renders with required props', () => {
const { lastFrame } = renderWithProviders(<AboutBox {...defaultProps} />);
it('renders with required props', async () => {
const { lastFrame, waitUntilReady, unmount } = renderWithProviders(
<AboutBox {...defaultProps} />,
);
await waitUntilReady();
const output = lastFrame();
expect(output).toContain('About Gemini CLI');
expect(output).toContain('1.0.0');
@@ -34,31 +37,44 @@ describe('AboutBox', () => {
expect(output).toContain('default');
expect(output).toContain('macOS');
expect(output).toContain('Logged in with Google');
unmount();
});
it.each([
['gcpProject', 'my-project', 'GCP Project'],
['ideClient', 'vscode', 'IDE Client'],
['tier', 'Enterprise', 'Tier'],
])('renders optional prop %s', (prop, value, label) => {
])('renders optional prop %s', async (prop, value, label) => {
const props = { ...defaultProps, [prop]: value };
const { lastFrame } = renderWithProviders(<AboutBox {...props} />);
const { lastFrame, waitUntilReady, unmount } = renderWithProviders(
<AboutBox {...props} />,
);
await waitUntilReady();
const output = lastFrame();
expect(output).toContain(label);
expect(output).toContain(value);
unmount();
});
it('renders Auth Method with email when userEmail is provided', () => {
it('renders Auth Method with email when userEmail is provided', async () => {
const props = { ...defaultProps, userEmail: 'test@example.com' };
const { lastFrame } = renderWithProviders(<AboutBox {...props} />);
const { lastFrame, waitUntilReady, unmount } = renderWithProviders(
<AboutBox {...props} />,
);
await waitUntilReady();
const output = lastFrame();
expect(output).toContain('Logged in with Google (test@example.com)');
unmount();
});
it('renders Auth Method correctly when not oauth', () => {
it('renders Auth Method correctly when not oauth', async () => {
const props = { ...defaultProps, selectedAuthType: 'api-key' };
const { lastFrame } = renderWithProviders(<AboutBox {...props} />);
const { lastFrame, waitUntilReady, unmount } = renderWithProviders(
<AboutBox {...props} />,
);
await waitUntilReady();
const output = lastFrame();
expect(output).toContain('api-key');
unmount();
});
});
@@ -16,17 +16,24 @@ describe('AdminSettingsChangedDialog', () => {
vi.restoreAllMocks();
});
it('renders correctly', () => {
const { lastFrame } = renderWithProviders(<AdminSettingsChangedDialog />);
it('renders correctly', async () => {
const { lastFrame, waitUntilReady } = renderWithProviders(
<AdminSettingsChangedDialog />,
);
await waitUntilReady();
expect(lastFrame()).toMatchSnapshot();
});
it('restarts on "r" key press', async () => {
const { stdin } = renderWithProviders(<AdminSettingsChangedDialog />, {
uiActions: {
handleRestart: handleRestartMock,
const { stdin, waitUntilReady } = renderWithProviders(
<AdminSettingsChangedDialog />,
{
uiActions: {
handleRestart: handleRestartMock,
},
},
});
);
await waitUntilReady();
act(() => {
stdin.write('r');
@@ -36,11 +43,15 @@ describe('AdminSettingsChangedDialog', () => {
});
it.each(['r', 'R'])('restarts on "%s" key press', async (key) => {
const { stdin } = renderWithProviders(<AdminSettingsChangedDialog />, {
uiActions: {
handleRestart: handleRestartMock,
const { stdin, waitUntilReady } = renderWithProviders(
<AdminSettingsChangedDialog />,
{
uiActions: {
handleRestart: handleRestartMock,
},
},
});
);
await waitUntilReady();
act(() => {
stdin.write(key);
@@ -118,11 +118,11 @@ describe('AgentConfigDialog', () => {
mockOnSave = vi.fn();
});
const renderDialog = (
const renderDialog = async (
settings: LoadedSettings,
definition: AgentDefinition = createMockAgentDefinition(),
) =>
render(
) => {
const result = render(
<KeypressProvider>
<AgentConfigDialog
agentName="test-agent"
@@ -134,18 +134,21 @@ describe('AgentConfigDialog', () => {
/>
</KeypressProvider>,
);
await result.waitUntilReady();
return result;
};
describe('rendering', () => {
it('should render the dialog with title', () => {
it('should render the dialog with title', async () => {
const settings = createMockSettings();
const { lastFrame } = renderDialog(settings);
const { lastFrame, unmount } = await renderDialog(settings);
expect(lastFrame()).toContain('Configure: Test Agent');
unmount();
});
it('should render all configuration fields', () => {
it('should render all configuration fields', async () => {
const settings = createMockSettings();
const { lastFrame } = renderDialog(settings);
const { lastFrame, unmount } = await renderDialog(settings);
const frame = lastFrame();
expect(frame).toContain('Enabled');
@@ -156,44 +159,53 @@ describe('AgentConfigDialog', () => {
expect(frame).toContain('Max Output Tokens');
expect(frame).toContain('Max Time (minutes)');
expect(frame).toContain('Max Turns');
unmount();
});
it('should render scope selector', () => {
it('should render scope selector', async () => {
const settings = createMockSettings();
const { lastFrame } = renderDialog(settings);
const { lastFrame, unmount } = await renderDialog(settings);
expect(lastFrame()).toContain('Apply To');
expect(lastFrame()).toContain('User Settings');
expect(lastFrame()).toContain('Workspace Settings');
unmount();
});
it('should render help text', () => {
it('should render help text', async () => {
const settings = createMockSettings();
const { lastFrame } = renderDialog(settings);
const { lastFrame, unmount } = await renderDialog(settings);
expect(lastFrame()).toContain('Use Enter to select');
expect(lastFrame()).toContain('Tab to change focus');
expect(lastFrame()).toContain('Esc to close');
unmount();
});
});
describe('keyboard navigation', () => {
it('should close dialog on Escape', async () => {
const settings = createMockSettings();
const { stdin } = renderDialog(settings);
const { stdin, waitUntilReady, unmount } = await renderDialog(settings);
await act(async () => {
stdin.write(TerminalKeys.ESCAPE);
});
// Escape key has a 50ms timeout in KeypressContext, so we need to wrap waitUntilReady in act
await act(async () => {
await waitUntilReady();
});
await waitFor(() => {
expect(mockOnClose).toHaveBeenCalled();
});
unmount();
});
it('should navigate down with arrow key', async () => {
const settings = createMockSettings();
const { lastFrame, stdin } = renderDialog(settings);
const { lastFrame, stdin, waitUntilReady, unmount } =
await renderDialog(settings);
// Initially first item (Enabled) should be active
expect(lastFrame()).toContain('●');
@@ -202,16 +214,19 @@ describe('AgentConfigDialog', () => {
await act(async () => {
stdin.write(TerminalKeys.DOWN_ARROW);
});
await waitUntilReady();
await waitFor(() => {
// Model field should now be highlighted
expect(lastFrame()).toContain('Model');
});
unmount();
});
it('should switch focus with Tab', async () => {
const settings = createMockSettings();
const { lastFrame, stdin } = renderDialog(settings);
const { lastFrame, stdin, waitUntilReady, unmount } =
await renderDialog(settings);
// Initially settings section is focused
expect(lastFrame()).toContain('> Configure: Test Agent');
@@ -220,22 +235,25 @@ describe('AgentConfigDialog', () => {
await act(async () => {
stdin.write(TerminalKeys.TAB);
});
await waitUntilReady();
await waitFor(() => {
expect(lastFrame()).toContain('> Apply To');
});
unmount();
});
});
describe('boolean toggle', () => {
it('should toggle enabled field on Enter', async () => {
const settings = createMockSettings();
const { stdin } = renderDialog(settings);
const { stdin, waitUntilReady, unmount } = await renderDialog(settings);
// Press Enter to toggle the first field (Enabled)
await act(async () => {
stdin.write(TerminalKeys.ENTER);
});
await waitUntilReady();
await waitFor(() => {
expect(settings.setValue).toHaveBeenCalledWith(
@@ -245,11 +263,12 @@ describe('AgentConfigDialog', () => {
);
expect(mockOnSave).toHaveBeenCalled();
});
unmount();
});
});
describe('default values', () => {
it('should show values from agent definition as defaults', () => {
it('should show values from agent definition as defaults', async () => {
const definition = createMockAgentDefinition({
modelConfig: {
model: 'gemini-2.0-flash',
@@ -263,29 +282,31 @@ describe('AgentConfigDialog', () => {
},
});
const settings = createMockSettings();
const { lastFrame } = renderDialog(settings, definition);
const { lastFrame, unmount } = await renderDialog(settings, definition);
const frame = lastFrame();
expect(frame).toContain('gemini-2.0-flash');
expect(frame).toContain('0.7');
expect(frame).toContain('10');
expect(frame).toContain('20');
unmount();
});
it('should show experimental agents as disabled by default', () => {
it('should show experimental agents as disabled by default', async () => {
const definition = createMockAgentDefinition({
experimental: true,
});
const settings = createMockSettings();
const { lastFrame } = renderDialog(settings, definition);
const { lastFrame, unmount } = await renderDialog(settings, definition);
// Experimental agents default to disabled
expect(lastFrame()).toContain('false');
unmount();
});
});
describe('existing overrides', () => {
it('should show existing override values with * indicator', () => {
it('should show existing override values with * indicator', async () => {
const settings = createMockSettings({
agents: {
overrides: {
@@ -298,12 +319,13 @@ describe('AgentConfigDialog', () => {
},
},
});
const { lastFrame } = renderDialog(settings);
const { lastFrame, unmount } = await renderDialog(settings);
const frame = lastFrame();
// Should show the overridden values
expect(frame).toContain('custom-model');
expect(frame).toContain('false');
unmount();
});
});
});
@@ -106,9 +106,9 @@ describe('AlternateBufferQuittingDisplay', () => {
},
};
it('renders with active and pending tool messages', () => {
it('renders with active and pending tool messages', async () => {
persistentStateMock.setData({ tipsShown: 0 });
const { lastFrame } = renderWithProviders(
const { lastFrame, waitUntilReady, unmount } = renderWithProviders(
<AlternateBufferQuittingDisplay />,
{
uiState: {
@@ -118,12 +118,14 @@ describe('AlternateBufferQuittingDisplay', () => {
},
},
);
await waitUntilReady();
expect(lastFrame()).toMatchSnapshot('with_history_and_pending');
unmount();
});
it('renders with empty history and no pending items', () => {
it('renders with empty history and no pending items', async () => {
persistentStateMock.setData({ tipsShown: 0 });
const { lastFrame } = renderWithProviders(
const { lastFrame, waitUntilReady, unmount } = renderWithProviders(
<AlternateBufferQuittingDisplay />,
{
uiState: {
@@ -133,12 +135,14 @@ describe('AlternateBufferQuittingDisplay', () => {
},
},
);
await waitUntilReady();
expect(lastFrame()).toMatchSnapshot('empty');
unmount();
});
it('renders with history but no pending items', () => {
it('renders with history but no pending items', async () => {
persistentStateMock.setData({ tipsShown: 0 });
const { lastFrame } = renderWithProviders(
const { lastFrame, waitUntilReady, unmount } = renderWithProviders(
<AlternateBufferQuittingDisplay />,
{
uiState: {
@@ -148,12 +152,14 @@ describe('AlternateBufferQuittingDisplay', () => {
},
},
);
await waitUntilReady();
expect(lastFrame()).toMatchSnapshot('with_history_no_pending');
unmount();
});
it('renders with pending items but no history', () => {
it('renders with pending items but no history', async () => {
persistentStateMock.setData({ tipsShown: 0 });
const { lastFrame } = renderWithProviders(
const { lastFrame, waitUntilReady, unmount } = renderWithProviders(
<AlternateBufferQuittingDisplay />,
{
uiState: {
@@ -163,10 +169,12 @@ describe('AlternateBufferQuittingDisplay', () => {
},
},
);
await waitUntilReady();
expect(lastFrame()).toMatchSnapshot('with_pending_no_history');
unmount();
});
it('renders with a tool awaiting confirmation', () => {
it('renders with a tool awaiting confirmation', async () => {
persistentStateMock.setData({ tipsShown: 0 });
const pendingHistoryItems: HistoryItemWithoutId[] = [
{
@@ -187,7 +195,7 @@ describe('AlternateBufferQuittingDisplay', () => {
],
},
];
const { lastFrame } = renderWithProviders(
const { lastFrame, waitUntilReady, unmount } = renderWithProviders(
<AlternateBufferQuittingDisplay />,
{
uiState: {
@@ -197,20 +205,22 @@ describe('AlternateBufferQuittingDisplay', () => {
},
},
);
await waitUntilReady();
const output = lastFrame();
expect(output).toContain('Action Required (was prompted):');
expect(output).toContain('confirming_tool');
expect(output).toContain('Confirming tool description');
expect(output).toMatchSnapshot('with_confirming_tool');
unmount();
});
it('renders with user and gemini messages', () => {
it('renders with user and gemini messages', async () => {
persistentStateMock.setData({ tipsShown: 0 });
const history: HistoryItem[] = [
{ id: 1, type: 'user', text: 'Hello Gemini' },
{ id: 2, type: 'gemini', text: 'Hello User!' },
];
const { lastFrame } = renderWithProviders(
const { lastFrame, waitUntilReady, unmount } = renderWithProviders(
<AlternateBufferQuittingDisplay />,
{
uiState: {
@@ -220,6 +230,8 @@ describe('AlternateBufferQuittingDisplay', () => {
},
},
);
await waitUntilReady();
expect(lastFrame()).toMatchSnapshot('with_user_gemini_messages');
unmount();
});
});
@@ -22,15 +22,19 @@ const createAnsiToken = (overrides: Partial<AnsiToken>): AnsiToken => ({
});
describe('<AnsiOutputText />', () => {
it('renders a simple AnsiOutput object correctly', () => {
it('renders a simple AnsiOutput object correctly', async () => {
const data: AnsiOutput = [
[
createAnsiToken({ text: 'Hello, ' }),
createAnsiToken({ text: 'world!' }),
],
];
const { lastFrame } = render(<AnsiOutputText data={data} width={80} />);
expect(lastFrame()).toBe('Hello, world!');
const { lastFrame, waitUntilReady, unmount } = render(
<AnsiOutputText data={data} width={80} />,
);
await waitUntilReady();
expect(lastFrame()).toBe('Hello, world!\n');
unmount();
});
// Note: ink-testing-library doesn't render styles, so we can only check the text.
@@ -41,73 +45,89 @@ describe('<AnsiOutputText />', () => {
{ style: { underline: true }, text: 'Underline' },
{ style: { dim: true }, text: 'Dim' },
{ style: { inverse: true }, text: 'Inverse' },
])('correctly applies style $text', ({ style, text }) => {
])('correctly applies style $text', async ({ style, text }) => {
const data: AnsiOutput = [[createAnsiToken({ text, ...style })]];
const { lastFrame } = render(<AnsiOutputText data={data} width={80} />);
expect(lastFrame()).toBe(text);
const { lastFrame, waitUntilReady, unmount } = render(
<AnsiOutputText data={data} width={80} />,
);
await waitUntilReady();
expect(lastFrame()).toBe(text + '\n');
unmount();
});
it.each([
{ color: { fg: '#ff0000' }, text: 'Red FG' },
{ color: { bg: '#0000ff' }, text: 'Blue BG' },
{ color: { fg: '#00ff00', bg: '#ff00ff' }, text: 'Green FG Magenta BG' },
])('correctly applies color $text', ({ color, text }) => {
])('correctly applies color $text', async ({ color, text }) => {
const data: AnsiOutput = [[createAnsiToken({ text, ...color })]];
const { lastFrame } = render(<AnsiOutputText data={data} width={80} />);
expect(lastFrame()).toBe(text);
const { lastFrame, waitUntilReady, unmount } = render(
<AnsiOutputText data={data} width={80} />,
);
await waitUntilReady();
expect(lastFrame()).toBe(text + '\n');
unmount();
});
it('handles empty lines and empty tokens', () => {
it('handles empty lines and empty tokens', async () => {
const data: AnsiOutput = [
[createAnsiToken({ text: 'First line' })],
[],
[createAnsiToken({ text: 'Third line' })],
[createAnsiToken({ text: '' })],
];
const { lastFrame } = render(<AnsiOutputText data={data} width={80} />);
const { lastFrame, waitUntilReady, unmount } = render(
<AnsiOutputText data={data} width={80} />,
);
await waitUntilReady();
const output = lastFrame();
expect(output).toBeDefined();
const lines = output!.split('\n');
const lines = output.split('\n');
expect(lines[0].trim()).toBe('First line');
expect(lines[1].trim()).toBe('');
expect(lines[2].trim()).toBe('Third line');
unmount();
});
it('respects the availableTerminalHeight prop and slices the lines correctly', () => {
it('respects the availableTerminalHeight prop and slices the lines correctly', async () => {
const data: AnsiOutput = [
[createAnsiToken({ text: 'Line 1' })],
[createAnsiToken({ text: 'Line 2' })],
[createAnsiToken({ text: 'Line 3' })],
[createAnsiToken({ text: 'Line 4' })],
];
const { lastFrame } = render(
const { lastFrame, waitUntilReady, unmount } = render(
<AnsiOutputText data={data} availableTerminalHeight={2} width={80} />,
);
await waitUntilReady();
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');
unmount();
});
it('respects the maxLines prop and slices the lines correctly', () => {
it('respects the maxLines prop and slices the lines correctly', async () => {
const data: AnsiOutput = [
[createAnsiToken({ text: 'Line 1' })],
[createAnsiToken({ text: 'Line 2' })],
[createAnsiToken({ text: 'Line 3' })],
[createAnsiToken({ text: 'Line 4' })],
];
const { lastFrame } = render(
const { lastFrame, waitUntilReady, unmount } = render(
<AnsiOutputText data={data} maxLines={2} width={80} />,
);
await waitUntilReady();
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');
unmount();
});
it('prioritizes maxLines over availableTerminalHeight if maxLines is smaller', () => {
it('prioritizes maxLines over availableTerminalHeight if maxLines is smaller', async () => {
const data: AnsiOutput = [
[createAnsiToken({ text: 'Line 1' })],
[createAnsiToken({ text: 'Line 2' })],
@@ -115,7 +135,7 @@ describe('<AnsiOutputText />', () => {
[createAnsiToken({ text: 'Line 4' })],
];
// availableTerminalHeight=3, maxLines=2 => show 2 lines
const { lastFrame } = render(
const { lastFrame, waitUntilReady, unmount } = render(
<AnsiOutputText
data={data}
availableTerminalHeight={3}
@@ -123,21 +143,25 @@ describe('<AnsiOutputText />', () => {
width={80}
/>,
);
await waitUntilReady();
const output = lastFrame();
expect(output).not.toContain('Line 2');
expect(output).toContain('Line 3');
expect(output).toContain('Line 4');
unmount();
});
it('renders a large AnsiOutput object without crashing', () => {
it('renders a large AnsiOutput object without crashing', async () => {
const largeData: AnsiOutput = [];
for (let i = 0; i < 1000; i++) {
largeData.push([createAnsiToken({ text: `Line ${i}` })]);
}
const { lastFrame } = render(
const { lastFrame, waitUntilReady, unmount } = render(
<AnsiOutputText data={largeData} width={80} />,
);
await waitUntilReady();
// We are just checking that it renders something without crashing.
expect(lastFrame()).toBeDefined();
unmount();
});
});
@@ -18,7 +18,7 @@ vi.mock('../utils/terminalSetup.js', () => ({
}));
describe('<AppHeader />', () => {
it('should render the banner with default text', () => {
it('should render the banner with default text', async () => {
const mockConfig = makeFakeConfig();
const uiState = {
history: [],
@@ -29,20 +29,21 @@ describe('<AppHeader />', () => {
bannerVisible: true,
};
const { lastFrame, unmount } = renderWithProviders(
const { lastFrame, waitUntilReady, unmount } = renderWithProviders(
<AppHeader version="1.0.0" />,
{
config: mockConfig,
uiState,
},
);
await waitUntilReady();
expect(lastFrame()).toContain('This is the default banner');
expect(lastFrame()).toMatchSnapshot();
unmount();
});
it('should render the banner with warning text', () => {
it('should render the banner with warning text', async () => {
const mockConfig = makeFakeConfig();
const uiState = {
history: [],
@@ -53,20 +54,21 @@ describe('<AppHeader />', () => {
bannerVisible: true,
};
const { lastFrame, unmount } = renderWithProviders(
const { lastFrame, waitUntilReady, unmount } = renderWithProviders(
<AppHeader version="1.0.0" />,
{
config: mockConfig,
uiState,
},
);
await waitUntilReady();
expect(lastFrame()).toContain('There are capacity issues');
expect(lastFrame()).toMatchSnapshot();
unmount();
});
it('should not render the banner when no flags are set', () => {
it('should not render the banner when no flags are set', async () => {
const mockConfig = makeFakeConfig();
const uiState = {
history: [],
@@ -76,20 +78,21 @@ describe('<AppHeader />', () => {
},
};
const { lastFrame, unmount } = renderWithProviders(
const { lastFrame, waitUntilReady, unmount } = renderWithProviders(
<AppHeader version="1.0.0" />,
{
config: mockConfig,
uiState,
},
);
await waitUntilReady();
expect(lastFrame()).not.toContain('Banner');
expect(lastFrame()).toMatchSnapshot();
unmount();
});
it('should not render the default banner if shown count is 5 or more', () => {
it('should not render the default banner if shown count is 5 or more', async () => {
const mockConfig = makeFakeConfig();
const uiState = {
history: [],
@@ -108,20 +111,21 @@ describe('<AppHeader />', () => {
},
});
const { lastFrame, unmount } = renderWithProviders(
const { lastFrame, waitUntilReady, unmount } = renderWithProviders(
<AppHeader version="1.0.0" />,
{
config: mockConfig,
uiState,
},
);
await waitUntilReady();
expect(lastFrame()).not.toContain('This is the default banner');
expect(lastFrame()).toMatchSnapshot();
unmount();
});
it('should increment the version count when default banner is displayed', () => {
it('should increment the version count when default banner is displayed', async () => {
const mockConfig = makeFakeConfig();
const uiState = {
history: [],
@@ -135,10 +139,14 @@ describe('<AppHeader />', () => {
// and interfering with the expected persistentState.set call.
persistentStateMock.setData({ tipsShown: 10 });
const { unmount } = renderWithProviders(<AppHeader version="1.0.0" />, {
config: mockConfig,
uiState,
});
const { waitUntilReady, unmount } = renderWithProviders(
<AppHeader version="1.0.0" />,
{
config: mockConfig,
uiState,
},
);
await waitUntilReady();
expect(persistentStateMock.set).toHaveBeenCalledWith(
'defaultBannerShownCount',
@@ -152,7 +160,7 @@ describe('<AppHeader />', () => {
unmount();
});
it('should render banner text with unescaped newlines', () => {
it('should render banner text with unescaped newlines', async () => {
const mockConfig = makeFakeConfig();
const uiState = {
history: [],
@@ -163,19 +171,20 @@ describe('<AppHeader />', () => {
bannerVisible: true,
};
const { lastFrame, unmount } = renderWithProviders(
const { lastFrame, waitUntilReady, unmount } = renderWithProviders(
<AppHeader version="1.0.0" />,
{
config: mockConfig,
uiState,
},
);
await waitUntilReady();
expect(lastFrame()).not.toContain('First line\\nSecond line');
unmount();
});
it('should render Tips when tipsShown is less than 10', () => {
it('should render Tips when tipsShown is less than 10', async () => {
const mockConfig = makeFakeConfig();
const uiState = {
history: [],
@@ -188,36 +197,38 @@ describe('<AppHeader />', () => {
persistentStateMock.setData({ tipsShown: 5 });
const { lastFrame, unmount } = renderWithProviders(
const { lastFrame, waitUntilReady, unmount } = renderWithProviders(
<AppHeader version="1.0.0" />,
{
config: mockConfig,
uiState,
},
);
await waitUntilReady();
expect(lastFrame()).toContain('Tips');
expect(persistentStateMock.set).toHaveBeenCalledWith('tipsShown', 6);
unmount();
});
it('should NOT render Tips when tipsShown is 10 or more', () => {
it('should NOT render Tips when tipsShown is 10 or more', async () => {
const mockConfig = makeFakeConfig();
persistentStateMock.setData({ tipsShown: 10 });
const { lastFrame, unmount } = renderWithProviders(
const { lastFrame, waitUntilReady, unmount } = renderWithProviders(
<AppHeader version="1.0.0" />,
{
config: mockConfig,
},
);
await waitUntilReady();
expect(lastFrame()).not.toContain('Tips');
unmount();
});
it('should show tips until they have been shown 10 times (persistence flow)', () => {
it('should show tips until they have been shown 10 times (persistence flow)', async () => {
persistentStateMock.setData({ tipsShown: 9 });
const mockConfig = makeFakeConfig();
@@ -235,6 +246,7 @@ describe('<AppHeader />', () => {
config: mockConfig,
uiState,
});
await session1.waitUntilReady();
expect(session1.lastFrame()).toContain('Tips');
expect(persistentStateMock.get('tipsShown')).toBe(10);
@@ -244,6 +256,7 @@ describe('<AppHeader />', () => {
const session2 = renderWithProviders(<AppHeader version="1.0.0" />, {
config: mockConfig,
});
await session2.waitUntilReady();
expect(session2.lastFrame()).not.toContain('Tips');
session2.unmount();
@@ -10,51 +10,57 @@ import { describe, it, expect } from 'vitest';
import { ApprovalMode } from '@google/gemini-cli-core';
describe('ApprovalModeIndicator', () => {
it('renders correctly for AUTO_EDIT mode', () => {
const { lastFrame } = render(
it('renders correctly for AUTO_EDIT mode', async () => {
const { lastFrame, waitUntilReady } = render(
<ApprovalModeIndicator approvalMode={ApprovalMode.AUTO_EDIT} />,
);
await waitUntilReady();
expect(lastFrame()).toMatchSnapshot();
});
it('renders correctly for AUTO_EDIT mode with plan enabled', () => {
const { lastFrame } = render(
it('renders correctly for AUTO_EDIT mode with plan enabled', async () => {
const { lastFrame, waitUntilReady } = render(
<ApprovalModeIndicator
approvalMode={ApprovalMode.AUTO_EDIT}
allowPlanMode={true}
/>,
);
await waitUntilReady();
expect(lastFrame()).toMatchSnapshot();
});
it('renders correctly for PLAN mode', () => {
const { lastFrame } = render(
it('renders correctly for PLAN mode', async () => {
const { lastFrame, waitUntilReady } = render(
<ApprovalModeIndicator approvalMode={ApprovalMode.PLAN} />,
);
await waitUntilReady();
expect(lastFrame()).toMatchSnapshot();
});
it('renders correctly for YOLO mode', () => {
const { lastFrame } = render(
it('renders correctly for YOLO mode', async () => {
const { lastFrame, waitUntilReady } = render(
<ApprovalModeIndicator approvalMode={ApprovalMode.YOLO} />,
);
await waitUntilReady();
expect(lastFrame()).toMatchSnapshot();
});
it('renders correctly for DEFAULT mode', () => {
const { lastFrame } = render(
it('renders correctly for DEFAULT mode', async () => {
const { lastFrame, waitUntilReady } = render(
<ApprovalModeIndicator approvalMode={ApprovalMode.DEFAULT} />,
);
await waitUntilReady();
expect(lastFrame()).toMatchSnapshot();
});
it('renders correctly for DEFAULT mode with plan enabled', () => {
const { lastFrame } = render(
it('renders correctly for DEFAULT mode with plan enabled', async () => {
const { lastFrame, waitUntilReady } = render(
<ApprovalModeIndicator
approvalMode={ApprovalMode.DEFAULT}
allowPlanMode={true}
/>,
);
await waitUntilReady();
expect(lastFrame()).toMatchSnapshot();
});
});
@@ -46,8 +46,8 @@ describe('AskUserDialog', () => {
},
];
it('renders question and options', () => {
const { lastFrame } = renderWithProviders(
it('renders question and options', async () => {
const { lastFrame, waitUntilReady } = renderWithProviders(
<AskUserDialog
questions={authQuestion}
onSubmit={vi.fn()}
@@ -57,6 +57,7 @@ describe('AskUserDialog', () => {
{ width: 120 },
);
await waitUntilReady();
expect(lastFrame()).toMatchSnapshot();
});
@@ -125,7 +126,7 @@ describe('AskUserDialog', () => {
actions(stdin);
await waitFor(() => {
await waitFor(async () => {
expect(onSubmit).toHaveBeenCalledWith(expectedSubmit);
});
});
@@ -133,7 +134,7 @@ describe('AskUserDialog', () => {
it('handles custom option in single select with inline typing', async () => {
const onSubmit = vi.fn();
const { stdin, lastFrame } = renderWithProviders(
const { stdin, lastFrame, waitUntilReady } = renderWithProviders(
<AskUserDialog
questions={authQuestion}
onSubmit={onSubmit}
@@ -147,7 +148,8 @@ describe('AskUserDialog', () => {
writeKey(stdin, '\x1b[B');
writeKey(stdin, '\x1b[B');
await waitFor(() => {
await waitFor(async () => {
await waitUntilReady();
expect(lastFrame()).toContain('Enter a custom value');
});
@@ -156,14 +158,15 @@ describe('AskUserDialog', () => {
writeKey(stdin, char);
}
await waitFor(() => {
await waitFor(async () => {
await waitUntilReady();
expect(lastFrame()).toContain('API Key');
});
// Press Enter to submit the custom value
writeKey(stdin, '\r');
await waitFor(() => {
await waitFor(async () => {
expect(onSubmit).toHaveBeenCalledWith({ '0': 'API Key' });
});
});
@@ -180,7 +183,7 @@ describe('AskUserDialog', () => {
];
const onSubmit = vi.fn();
const { stdin, lastFrame } = renderWithProviders(
const { stdin, lastFrame, waitUntilReady } = renderWithProviders(
<AskUserDialog
questions={authQuestionWithOther}
onSubmit={onSubmit}
@@ -207,15 +210,17 @@ describe('AskUserDialog', () => {
writeKey(stdin, char);
}
await waitFor(() => {
await waitFor(async () => {
await waitUntilReady();
expect(lastFrame()).toContain('Line 1');
await waitUntilReady();
expect(lastFrame()).toContain('Line 2');
});
// Press Enter to submit
writeKey(stdin, '\r');
await waitFor(() => {
await waitFor(async () => {
expect(onSubmit).toHaveBeenCalledWith({ '0': 'Line 1\nLine 2' });
});
});
@@ -240,7 +245,7 @@ describe('AskUserDialog', () => {
},
];
const { lastFrame } = renderWithProviders(
const { lastFrame, waitUntilReady } = renderWithProviders(
<AskUserDialog
questions={questions}
onSubmit={vi.fn()}
@@ -251,14 +256,19 @@ describe('AskUserDialog', () => {
{ useAlternateBuffer },
);
await waitFor(() => {
await waitFor(async () => {
if (expectedArrows) {
await waitUntilReady();
expect(lastFrame()).toContain('▲');
await waitUntilReady();
expect(lastFrame()).toContain('▼');
} else {
await waitUntilReady();
expect(lastFrame()).not.toContain('▲');
await waitUntilReady();
expect(lastFrame()).not.toContain('▼');
}
await waitUntilReady();
expect(lastFrame()).toMatchSnapshot();
});
});
@@ -266,7 +276,7 @@ describe('AskUserDialog', () => {
);
it('navigates to custom option when typing unbound characters (Type-to-Jump)', async () => {
const { stdin, lastFrame } = renderWithProviders(
const { stdin, lastFrame, waitUntilReady } = renderWithProviders(
<AskUserDialog
questions={authQuestion}
onSubmit={vi.fn()}
@@ -279,10 +289,12 @@ describe('AskUserDialog', () => {
// Type a character without navigating down
writeKey(stdin, 'A');
await waitFor(() => {
await waitFor(async () => {
// Should show the custom input with 'A'
// Placeholder is hidden when text is present
await waitUntilReady();
expect(lastFrame()).toContain('A');
await waitUntilReady();
expect(lastFrame()).toContain('3. A');
});
@@ -290,12 +302,13 @@ describe('AskUserDialog', () => {
writeKey(stdin, 'P');
writeKey(stdin, 'I');
await waitFor(() => {
await waitFor(async () => {
await waitUntilReady();
expect(lastFrame()).toContain('API');
});
});
it('shows progress header for multiple questions', () => {
it('shows progress header for multiple questions', async () => {
const multiQuestions: Question[] = [
{
question: 'Which database should we use?',
@@ -319,7 +332,7 @@ describe('AskUserDialog', () => {
},
];
const { lastFrame } = renderWithProviders(
const { lastFrame, waitUntilReady } = renderWithProviders(
<AskUserDialog
questions={multiQuestions}
onSubmit={vi.fn()}
@@ -329,11 +342,12 @@ describe('AskUserDialog', () => {
{ width: 120 },
);
await waitUntilReady();
expect(lastFrame()).toMatchSnapshot();
});
it('hides progress header for single question', () => {
const { lastFrame } = renderWithProviders(
it('hides progress header for single question', async () => {
const { lastFrame, waitUntilReady } = renderWithProviders(
<AskUserDialog
questions={authQuestion}
onSubmit={vi.fn()}
@@ -343,11 +357,12 @@ describe('AskUserDialog', () => {
{ width: 120 },
);
await waitUntilReady();
expect(lastFrame()).toMatchSnapshot();
});
it('shows keyboard hints', () => {
const { lastFrame } = renderWithProviders(
it('shows keyboard hints', async () => {
const { lastFrame, waitUntilReady } = renderWithProviders(
<AskUserDialog
questions={authQuestion}
onSubmit={vi.fn()}
@@ -357,6 +372,7 @@ describe('AskUserDialog', () => {
{ width: 120 },
);
await waitUntilReady();
expect(lastFrame()).toMatchSnapshot();
});
@@ -380,7 +396,7 @@ describe('AskUserDialog', () => {
},
];
const { stdin, lastFrame } = renderWithProviders(
const { stdin, lastFrame, waitUntilReady } = renderWithProviders(
<AskUserDialog
questions={multiQuestions}
onSubmit={vi.fn()}
@@ -390,17 +406,20 @@ describe('AskUserDialog', () => {
{ width: 120 },
);
await waitUntilReady();
expect(lastFrame()).toContain('Which testing framework?');
writeKey(stdin, '\x1b[C'); // Right arrow
await waitFor(() => {
await waitFor(async () => {
await waitUntilReady();
expect(lastFrame()).toContain('Which CI provider?');
});
writeKey(stdin, '\x1b[D'); // Left arrow
await waitFor(() => {
await waitFor(async () => {
await waitUntilReady();
expect(lastFrame()).toContain('Which testing framework?');
});
});
@@ -424,7 +443,7 @@ describe('AskUserDialog', () => {
];
const onSubmit = vi.fn();
const { stdin, lastFrame } = renderWithProviders(
const { stdin, lastFrame, waitUntilReady } = renderWithProviders(
<AskUserDialog
questions={multiQuestions}
onSubmit={onSubmit}
@@ -437,40 +456,44 @@ describe('AskUserDialog', () => {
// Answer first question (should auto-advance)
writeKey(stdin, '\r');
await waitFor(() => {
await waitFor(async () => {
await waitUntilReady();
expect(lastFrame()).toContain('Which bundler?');
});
// Navigate back
writeKey(stdin, '\x1b[D');
await waitFor(() => {
await waitFor(async () => {
await waitUntilReady();
expect(lastFrame()).toContain('Which package manager?');
});
// Navigate forward
writeKey(stdin, '\x1b[C');
await waitFor(() => {
await waitFor(async () => {
await waitUntilReady();
expect(lastFrame()).toContain('Which bundler?');
});
// Answer second question
writeKey(stdin, '\r');
await waitFor(() => {
await waitFor(async () => {
await waitUntilReady();
expect(lastFrame()).toContain('Review your answers:');
});
// Submit from Review
writeKey(stdin, '\r');
await waitFor(() => {
await waitFor(async () => {
expect(onSubmit).toHaveBeenCalledWith({ '0': 'pnpm', '1': 'Vite' });
});
});
it('shows Review tab in progress header for multiple questions', () => {
it('shows Review tab in progress header for multiple questions', async () => {
const multiQuestions: Question[] = [
{
question: 'Which framework?',
@@ -494,7 +517,7 @@ describe('AskUserDialog', () => {
},
];
const { lastFrame } = renderWithProviders(
const { lastFrame, waitUntilReady } = renderWithProviders(
<AskUserDialog
questions={multiQuestions}
onSubmit={vi.fn()}
@@ -504,6 +527,7 @@ describe('AskUserDialog', () => {
{ width: 120 },
);
await waitUntilReady();
expect(lastFrame()).toMatchSnapshot();
});
@@ -525,7 +549,7 @@ describe('AskUserDialog', () => {
},
];
const { stdin, lastFrame } = renderWithProviders(
const { stdin, lastFrame, waitUntilReady } = renderWithProviders(
<AskUserDialog
questions={multiQuestions}
onSubmit={vi.fn()}
@@ -537,19 +561,22 @@ describe('AskUserDialog', () => {
writeKey(stdin, '\x1b[C'); // Right arrow
await waitFor(() => {
await waitFor(async () => {
await waitUntilReady();
expect(lastFrame()).toContain('Add documentation?');
});
writeKey(stdin, '\x1b[C'); // Right arrow to Review
await waitFor(() => {
await waitFor(async () => {
await waitUntilReady();
expect(lastFrame()).toMatchSnapshot();
});
writeKey(stdin, '\x1b[D'); // Left arrow back
await waitFor(() => {
await waitFor(async () => {
await waitUntilReady();
expect(lastFrame()).toContain('Add documentation?');
});
});
@@ -572,7 +599,7 @@ describe('AskUserDialog', () => {
},
];
const { stdin, lastFrame } = renderWithProviders(
const { stdin, lastFrame, waitUntilReady } = renderWithProviders(
<AskUserDialog
questions={multiQuestions}
onSubmit={vi.fn()}
@@ -586,7 +613,8 @@ describe('AskUserDialog', () => {
writeKey(stdin, '\x1b[C');
writeKey(stdin, '\x1b[C');
await waitFor(() => {
await waitFor(async () => {
await waitUntilReady();
expect(lastFrame()).toMatchSnapshot();
});
});
@@ -627,13 +655,13 @@ describe('AskUserDialog', () => {
// Submit
writeKey(stdin, '\r');
await waitFor(() => {
await waitFor(async () => {
expect(onSubmit).toHaveBeenCalledWith({ '0': 'Node 20' });
});
});
describe('Text type questions', () => {
it('renders text input for type: "text"', () => {
it('renders text input for type: "text"', async () => {
const textQuestion: Question[] = [
{
question: 'What should we name this component?',
@@ -643,7 +671,7 @@ describe('AskUserDialog', () => {
},
];
const { lastFrame } = renderWithProviders(
const { lastFrame, waitUntilReady } = renderWithProviders(
<AskUserDialog
questions={textQuestion}
onSubmit={vi.fn()}
@@ -653,10 +681,11 @@ describe('AskUserDialog', () => {
{ width: 120 },
);
await waitUntilReady();
expect(lastFrame()).toMatchSnapshot();
});
it('shows default placeholder when none provided', () => {
it('shows default placeholder when none provided', async () => {
const textQuestion: Question[] = [
{
question: 'Enter the database connection string:',
@@ -665,7 +694,7 @@ describe('AskUserDialog', () => {
},
];
const { lastFrame } = renderWithProviders(
const { lastFrame, waitUntilReady } = renderWithProviders(
<AskUserDialog
questions={textQuestion}
onSubmit={vi.fn()}
@@ -675,6 +704,7 @@ describe('AskUserDialog', () => {
{ width: 120 },
);
await waitUntilReady();
expect(lastFrame()).toMatchSnapshot();
});
@@ -687,7 +717,7 @@ describe('AskUserDialog', () => {
},
];
const { stdin, lastFrame } = renderWithProviders(
const { stdin, lastFrame, waitUntilReady } = renderWithProviders(
<AskUserDialog
questions={textQuestion}
onSubmit={vi.fn()}
@@ -701,19 +731,22 @@ describe('AskUserDialog', () => {
writeKey(stdin, char);
}
await waitFor(() => {
await waitFor(async () => {
await waitUntilReady();
expect(lastFrame()).toContain('abc');
});
writeKey(stdin, '\x7f'); // Backspace
await waitFor(() => {
await waitFor(async () => {
await waitUntilReady();
expect(lastFrame()).toContain('ab');
await waitUntilReady();
expect(lastFrame()).not.toContain('abc');
});
});
it('shows correct keyboard hints for text type', () => {
it('shows correct keyboard hints for text type', async () => {
const textQuestion: Question[] = [
{
question: 'Enter the variable name:',
@@ -722,7 +755,7 @@ describe('AskUserDialog', () => {
},
];
const { lastFrame } = renderWithProviders(
const { lastFrame, waitUntilReady } = renderWithProviders(
<AskUserDialog
questions={textQuestion}
onSubmit={vi.fn()}
@@ -732,6 +765,7 @@ describe('AskUserDialog', () => {
{ width: 120 },
);
await waitUntilReady();
expect(lastFrame()).toMatchSnapshot();
});
@@ -754,7 +788,7 @@ describe('AskUserDialog', () => {
},
];
const { stdin, lastFrame } = renderWithProviders(
const { stdin, lastFrame, waitUntilReady } = renderWithProviders(
<AskUserDialog
questions={mixedQuestions}
onSubmit={vi.fn()}
@@ -770,13 +804,15 @@ describe('AskUserDialog', () => {
writeKey(stdin, '\t'); // Use Tab instead of Right arrow when text input is active
await waitFor(() => {
await waitFor(async () => {
await waitUntilReady();
expect(lastFrame()).toContain('Should it be async?');
});
writeKey(stdin, '\x1b[D'); // Left arrow should work when NOT focusing a text input
await waitFor(() => {
await waitFor(async () => {
await waitUntilReady();
expect(lastFrame()).toContain('useAuth');
});
});
@@ -802,7 +838,7 @@ describe('AskUserDialog', () => {
];
const onSubmit = vi.fn();
const { stdin, lastFrame } = renderWithProviders(
const { stdin, lastFrame, waitUntilReady } = renderWithProviders(
<AskUserDialog
questions={mixedQuestions}
onSubmit={onSubmit}
@@ -818,23 +854,29 @@ describe('AskUserDialog', () => {
writeKey(stdin, '\r');
await waitFor(() => {
await waitFor(async () => {
await waitUntilReady();
expect(lastFrame()).toContain('Which styling approach?');
});
writeKey(stdin, '\r');
await waitFor(() => {
await waitFor(async () => {
await waitUntilReady();
expect(lastFrame()).toContain('Review your answers:');
await waitUntilReady();
expect(lastFrame()).toContain('Name');
await waitUntilReady();
expect(lastFrame()).toContain('DataTable');
await waitUntilReady();
expect(lastFrame()).toContain('Style');
await waitUntilReady();
expect(lastFrame()).toContain('CSS Modules');
});
writeKey(stdin, '\r');
await waitFor(() => {
await waitFor(async () => {
expect(onSubmit).toHaveBeenCalledWith({
'0': 'DataTable',
'1': 'CSS Modules',
@@ -864,7 +906,7 @@ describe('AskUserDialog', () => {
writeKey(stdin, '\r');
await waitFor(() => {
await waitFor(async () => {
expect(onSubmit).toHaveBeenCalledWith({});
});
});
@@ -879,7 +921,7 @@ describe('AskUserDialog', () => {
];
const onCancel = vi.fn();
const { stdin, lastFrame } = renderWithProviders(
const { stdin, lastFrame, waitUntilReady } = renderWithProviders(
<AskUserDialog
questions={textQuestion}
onSubmit={vi.fn()}
@@ -893,16 +935,19 @@ describe('AskUserDialog', () => {
writeKey(stdin, char);
}
await waitFor(() => {
await waitFor(async () => {
await waitUntilReady();
expect(lastFrame()).toContain('SomeText');
});
// Send Ctrl+C
writeKey(stdin, '\x03'); // Ctrl+C
await waitFor(() => {
await waitFor(async () => {
// Text should be cleared
await waitUntilReady();
expect(lastFrame()).not.toContain('SomeText');
await waitUntilReady();
expect(lastFrame()).toContain('>');
});
@@ -926,7 +971,7 @@ describe('AskUserDialog', () => {
},
];
const { stdin, lastFrame } = renderWithProviders(
const { stdin, lastFrame, waitUntilReady } = renderWithProviders(
<AskUserDialog
questions={multiQuestions}
onSubmit={vi.fn()}
@@ -938,13 +983,15 @@ describe('AskUserDialog', () => {
// 1. Move to Text Q (Right arrow works for Choice Q)
writeKey(stdin, '\x1b[C');
await waitFor(() => {
await waitFor(async () => {
await waitUntilReady();
expect(lastFrame()).toContain('Text Q?');
});
// 2. Type something in Text Q to make isEditingCustomOption true
writeKey(stdin, 'a');
await waitFor(() => {
await waitFor(async () => {
await waitUntilReady();
expect(lastFrame()).toContain('a');
});
@@ -952,18 +999,21 @@ describe('AskUserDialog', () => {
// When typing 'a', cursor is at index 1.
// We need to move cursor to index 0 first for Left arrow to work for navigation.
writeKey(stdin, '\x1b[D'); // Left arrow moves cursor to index 0
await waitFor(() => {
await waitFor(async () => {
await waitUntilReady();
expect(lastFrame()).toContain('Text Q?');
});
writeKey(stdin, '\x1b[D'); // Second Left arrow should now trigger navigation
await waitFor(() => {
await waitFor(async () => {
await waitUntilReady();
expect(lastFrame()).toContain('Choice Q?');
});
// 4. Immediately try Right arrow to go back to Text Q
writeKey(stdin, '\x1b[C');
await waitFor(() => {
await waitFor(async () => {
await waitUntilReady();
expect(lastFrame()).toContain('Text Q?');
});
});
@@ -987,7 +1037,7 @@ describe('AskUserDialog', () => {
];
const onSubmit = vi.fn();
const { stdin, lastFrame } = renderWithProviders(
const { stdin, lastFrame, waitUntilReady } = renderWithProviders(
<AskUserDialog
questions={multiQuestions}
onSubmit={onSubmit}
@@ -1001,14 +1051,16 @@ describe('AskUserDialog', () => {
act(() => {
stdin.write('\r'); // Select A1 for Q1 -> triggers autoAdvance
});
await waitFor(() => {
await waitFor(async () => {
await waitUntilReady();
expect(lastFrame()).toContain('Question 2?');
});
act(() => {
stdin.write('\r'); // Select A2 for Q2 -> triggers autoAdvance to Review
});
await waitFor(() => {
await waitFor(async () => {
await waitUntilReady();
expect(lastFrame()).toContain('Review your answers:');
});
@@ -1016,7 +1068,7 @@ describe('AskUserDialog', () => {
stdin.write('\r'); // Submit from Review
});
await waitFor(() => {
await waitFor(async () => {
expect(onSubmit).toHaveBeenCalledWith({
'0': 'A1',
'1': 'A2',
@@ -1037,7 +1089,7 @@ describe('AskUserDialog', () => {
},
];
const { lastFrame } = renderWithProviders(
const { lastFrame, waitUntilReady } = renderWithProviders(
<AskUserDialog
questions={questions}
onSubmit={vi.fn()}
@@ -1048,7 +1100,8 @@ describe('AskUserDialog', () => {
{ width: 120 },
);
await waitFor(() => {
await waitFor(async () => {
await waitUntilReady();
const frame = lastFrame();
// Plain text should be rendered as bold
expect(frame).toContain(chalk.bold('Which option do you prefer?'));
@@ -1066,7 +1119,7 @@ describe('AskUserDialog', () => {
},
];
const { lastFrame } = renderWithProviders(
const { lastFrame, waitUntilReady } = renderWithProviders(
<AskUserDialog
questions={questions}
onSubmit={vi.fn()}
@@ -1077,7 +1130,8 @@ describe('AskUserDialog', () => {
{ width: 120 },
);
await waitFor(() => {
await waitFor(async () => {
await waitUntilReady();
const frame = lastFrame();
// Should NOT have double-bold (the whole question bolded AND "this" bolded)
// "Is " should not be bold, only "this" should be bold
@@ -1098,7 +1152,7 @@ describe('AskUserDialog', () => {
},
];
const { lastFrame } = renderWithProviders(
const { lastFrame, waitUntilReady } = renderWithProviders(
<AskUserDialog
questions={questions}
onSubmit={vi.fn()}
@@ -1109,7 +1163,8 @@ describe('AskUserDialog', () => {
{ width: 120 },
);
await waitFor(() => {
await waitFor(async () => {
await waitUntilReady();
const frame = lastFrame();
// Check for chalk.bold('this') - asterisks should be gone, text should be bold
expect(frame).toContain(chalk.bold('this'));
@@ -1128,7 +1183,7 @@ describe('AskUserDialog', () => {
},
];
const { lastFrame } = renderWithProviders(
const { lastFrame, waitUntilReady } = renderWithProviders(
<AskUserDialog
questions={questions}
onSubmit={vi.fn()}
@@ -1139,7 +1194,8 @@ describe('AskUserDialog', () => {
{ width: 120 },
);
await waitFor(() => {
await waitFor(async () => {
await waitUntilReady();
const frame = lastFrame();
// Backticks should be removed
expect(frame).toContain('npm start');
@@ -1148,7 +1204,7 @@ describe('AskUserDialog', () => {
});
});
it('uses availableTerminalHeight from UIStateContext if availableHeight prop is missing', () => {
it('uses availableTerminalHeight from UIStateContext if availableHeight prop is missing', async () => {
const questions: Question[] = [
{
question: 'Choose an option',
@@ -1166,7 +1222,7 @@ describe('AskUserDialog', () => {
availableTerminalHeight: 5, // Small height to force scroll arrows
} as UIState;
const { lastFrame } = renderWithProviders(
const { lastFrame, waitUntilReady } = renderWithProviders(
<UIStateContext.Provider value={mockUIState}>
<AskUserDialog
questions={questions}
@@ -1179,11 +1235,13 @@ describe('AskUserDialog', () => {
);
// With height 5 and alternate buffer disabled, it should show scroll arrows (▲)
await waitUntilReady();
expect(lastFrame()).toContain('▲');
await waitUntilReady();
expect(lastFrame()).toContain('▼');
});
it('does NOT truncate the question when in alternate buffer mode even with small height', () => {
it('does NOT truncate the question when in alternate buffer mode even with small height', async () => {
const longQuestion =
'This is a very long question ' + 'with many words '.repeat(10);
const questions: Question[] = [
@@ -1200,7 +1258,7 @@ describe('AskUserDialog', () => {
availableTerminalHeight: 5,
} as UIState;
const { lastFrame } = renderWithProviders(
const { lastFrame, waitUntilReady } = renderWithProviders(
<UIStateContext.Provider value={mockUIState}>
<AskUserDialog
questions={questions}
@@ -1213,8 +1271,10 @@ describe('AskUserDialog', () => {
);
// Should NOT contain the truncation message
await waitUntilReady();
expect(lastFrame()).not.toContain('hidden ...');
// Should contain the full long question (or at least its parts)
await waitUntilReady();
expect(lastFrame()).toContain('This is a very long question');
});
@@ -1234,7 +1294,7 @@ describe('AskUserDialog', () => {
},
];
const { stdin, lastFrame } = renderWithProviders(
const { stdin, lastFrame, waitUntilReady } = renderWithProviders(
<AskUserDialog
questions={questions}
onSubmit={vi.fn()}
@@ -1248,7 +1308,8 @@ describe('AskUserDialog', () => {
writeKey(stdin, '\x1b[B'); // Down
writeKey(stdin, '\x1b[B'); // Down to Other
await waitFor(() => {
await waitFor(async () => {
await waitUntilReady();
expect(lastFrame()).toMatchSnapshot();
});
});
@@ -1267,7 +1328,7 @@ describe('AskUserDialog', () => {
},
];
const { stdin, lastFrame } = renderWithProviders(
const { stdin, lastFrame, waitUntilReady } = renderWithProviders(
<AskUserDialog
questions={questions}
onSubmit={vi.fn()}
@@ -1281,7 +1342,8 @@ describe('AskUserDialog', () => {
writeKey(stdin, '\x1b[B'); // Down
writeKey(stdin, '\x1b[B'); // Down to Other
await waitFor(() => {
await waitFor(async () => {
await waitUntilReady();
expect(lastFrame()).toMatchSnapshot();
});
});
@@ -14,8 +14,6 @@ import { type Key, type KeypressHandler } from '../contexts/KeypressContext.js';
import { ScrollProvider } from '../contexts/ScrollProvider.js';
import { Box } from 'ink';
const delay = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms));
// Mock dependencies
const mockDismissBackgroundShell = vi.fn();
const mockSetActiveBackgroundShellPid = vi.fn();
@@ -142,81 +140,84 @@ describe('<BackgroundShellDisplay />', () => {
});
it('renders the output of the active shell', async () => {
const { lastFrame } = render(
const width = 80;
const { lastFrame, waitUntilReady, unmount } = render(
<ScrollProvider>
<BackgroundShellDisplay
shells={mockShells}
activePid={shell1.pid}
width={80}
width={width}
height={24}
isFocused={false}
isListOpenProp={false}
/>
</ScrollProvider>,
width,
);
await act(async () => {
await delay(0);
});
await waitUntilReady();
expect(lastFrame()).toMatchSnapshot();
unmount();
});
it('renders tabs for multiple shells', async () => {
const { lastFrame } = render(
const width = 100;
const { lastFrame, waitUntilReady, unmount } = render(
<ScrollProvider>
<BackgroundShellDisplay
shells={mockShells}
activePid={shell1.pid}
width={100}
width={width}
height={24}
isFocused={false}
isListOpenProp={false}
/>
</ScrollProvider>,
width,
);
await act(async () => {
await delay(0);
});
await waitUntilReady();
expect(lastFrame()).toMatchSnapshot();
unmount();
});
it('highlights the focused state', async () => {
const { lastFrame } = render(
const width = 80;
const { lastFrame, waitUntilReady, unmount } = render(
<ScrollProvider>
<BackgroundShellDisplay
shells={mockShells}
activePid={shell1.pid}
width={80}
width={width}
height={24}
isFocused={true} // Focused
isFocused={true}
isListOpenProp={false}
/>
</ScrollProvider>,
width,
);
await act(async () => {
await delay(0);
});
await waitUntilReady();
expect(lastFrame()).toMatchSnapshot();
unmount();
});
it('resizes the PTY on mount and when dimensions change', async () => {
const { rerender } = render(
const width = 80;
const { rerender, waitUntilReady, unmount } = render(
<ScrollProvider>
<BackgroundShellDisplay
shells={mockShells}
activePid={shell1.pid}
width={80}
width={width}
height={24}
isFocused={false}
isListOpenProp={false}
/>
</ScrollProvider>,
width,
);
await act(async () => {
await delay(0);
});
await waitUntilReady();
expect(ShellExecutionService.resizePty).toHaveBeenCalledWith(
shell1.pid,
@@ -236,143 +237,152 @@ describe('<BackgroundShellDisplay />', () => {
/>
</ScrollProvider>,
);
await act(async () => {
await delay(0);
});
await waitUntilReady();
expect(ShellExecutionService.resizePty).toHaveBeenCalledWith(
shell1.pid,
96,
27,
);
unmount();
});
it('renders the process list when isListOpenProp is true', async () => {
const { lastFrame } = render(
const width = 80;
const { lastFrame, waitUntilReady, unmount } = render(
<ScrollProvider>
<BackgroundShellDisplay
shells={mockShells}
activePid={shell1.pid}
width={80}
width={width}
height={24}
isFocused={true}
isListOpenProp={true}
/>
</ScrollProvider>,
width,
);
await act(async () => {
await delay(0);
});
await waitUntilReady();
expect(lastFrame()).toMatchSnapshot();
unmount();
});
it('selects the current process and closes the list when Ctrl+L is pressed in list view', async () => {
render(
const width = 80;
const { waitUntilReady, unmount } = render(
<ScrollProvider>
<BackgroundShellDisplay
shells={mockShells}
activePid={shell1.pid}
width={80}
width={width}
height={24}
isFocused={true}
isListOpenProp={true}
/>
</ScrollProvider>,
width,
);
await act(async () => {
await delay(0);
});
await waitUntilReady();
// Simulate down arrow to select the second process (handled by RadioButtonSelect)
act(() => {
await act(async () => {
simulateKey({ name: 'down' });
});
await waitUntilReady();
// Simulate Ctrl+L (handled by BackgroundShellDisplay)
act(() => {
await act(async () => {
simulateKey({ name: 'l', ctrl: true });
});
await waitUntilReady();
expect(mockSetActiveBackgroundShellPid).toHaveBeenCalledWith(shell2.pid);
expect(mockSetIsBackgroundShellListOpen).toHaveBeenCalledWith(false);
unmount();
});
it('kills the highlighted process when Ctrl+K is pressed in list view', async () => {
render(
const width = 80;
const { waitUntilReady, unmount } = render(
<ScrollProvider>
<BackgroundShellDisplay
shells={mockShells}
activePid={shell1.pid}
width={80}
width={width}
height={24}
isFocused={true}
isListOpenProp={true}
/>
</ScrollProvider>,
width,
);
await act(async () => {
await delay(0);
});
await waitUntilReady();
// Initial state: shell1 (active) is highlighted
// Move to shell2
act(() => {
await act(async () => {
simulateKey({ name: 'down' });
});
await waitUntilReady();
// Press Ctrl+K
act(() => {
await act(async () => {
simulateKey({ name: 'k', ctrl: true });
});
await waitUntilReady();
expect(mockDismissBackgroundShell).toHaveBeenCalledWith(shell2.pid);
unmount();
});
it('kills the active process when Ctrl+K is pressed in output view', async () => {
render(
const width = 80;
const { waitUntilReady, unmount } = render(
<ScrollProvider>
<BackgroundShellDisplay
shells={mockShells}
activePid={shell1.pid}
width={80}
width={width}
height={24}
isFocused={true}
isListOpenProp={false}
/>
</ScrollProvider>,
width,
);
await act(async () => {
await delay(0);
});
await waitUntilReady();
act(() => {
await act(async () => {
simulateKey({ name: 'k', ctrl: true });
});
await waitUntilReady();
expect(mockDismissBackgroundShell).toHaveBeenCalledWith(shell1.pid);
unmount();
});
it('scrolls to active shell when list opens', async () => {
// shell2 is active
const { lastFrame } = render(
const width = 80;
const { lastFrame, waitUntilReady, unmount } = render(
<ScrollProvider>
<BackgroundShellDisplay
shells={mockShells}
activePid={shell2.pid}
width={80}
width={width}
height={24}
isFocused={true}
isListOpenProp={true}
/>
</ScrollProvider>,
width,
);
await act(async () => {
await delay(0);
});
await waitUntilReady();
expect(lastFrame()).toMatchSnapshot();
unmount();
});
it('keeps exit code status color even when selected', async () => {
@@ -387,22 +397,23 @@ describe('<BackgroundShellDisplay />', () => {
};
mockShells.set(exitedShell.pid, exitedShell);
const { lastFrame } = render(
const width = 80;
const { lastFrame, waitUntilReady, unmount } = render(
<ScrollProvider>
<BackgroundShellDisplay
shells={mockShells}
activePid={exitedShell.pid}
width={80}
width={width}
height={24}
isFocused={true}
isListOpenProp={true}
/>
</ScrollProvider>,
width,
);
await act(async () => {
await delay(0);
});
await waitUntilReady();
expect(lastFrame()).toMatchSnapshot();
unmount();
});
});
@@ -12,18 +12,22 @@ describe('Banner', () => {
it.each([
['warning mode', true, 'Warning Message'],
['info mode', false, 'Info Message'],
])('renders in %s', (_, isWarning, text) => {
const { lastFrame } = render(
])('renders in %s', async (_, isWarning, text) => {
const { lastFrame, waitUntilReady, unmount } = render(
<Banner bannerText={text} isWarning={isWarning} width={80} />,
);
await waitUntilReady();
expect(lastFrame()).toMatchSnapshot();
unmount();
});
it('handles newlines in text', () => {
it('handles newlines in text', async () => {
const text = 'Line 1\\nLine 2';
const { lastFrame } = render(
const { lastFrame, waitUntilReady, unmount } = render(
<Banner bannerText={text} isWarning={false} width={80} />,
);
await waitUntilReady();
expect(lastFrame()).toMatchSnapshot();
unmount();
});
});
@@ -17,26 +17,28 @@ describe('<Checklist />', () => {
{ status: 'cancelled', label: 'Task 4' },
];
it('renders nothing when list is empty', () => {
const { lastFrame } = render(
it('renders nothing when list is empty', async () => {
const { lastFrame, waitUntilReady } = render(
<Checklist title="Test List" items={[]} isExpanded={true} />,
);
expect(lastFrame()).toBe('');
await waitUntilReady();
expect(lastFrame({ allowEmpty: true })).toBe('');
});
it('renders nothing when collapsed and no active items', () => {
it('renders nothing when collapsed and no active items', async () => {
const inactiveItems: ChecklistItemData[] = [
{ status: 'completed', label: 'Task 1' },
{ status: 'cancelled', label: 'Task 2' },
];
const { lastFrame } = render(
const { lastFrame, waitUntilReady } = render(
<Checklist title="Test List" items={inactiveItems} isExpanded={false} />,
);
expect(lastFrame()).toBe('');
await waitUntilReady();
expect(lastFrame({ allowEmpty: true })).toBe('');
});
it('renders summary view correctly (collapsed)', () => {
const { lastFrame } = render(
it('renders summary view correctly (collapsed)', async () => {
const { lastFrame, waitUntilReady } = render(
<Checklist
title="Test List"
items={items}
@@ -44,11 +46,12 @@ describe('<Checklist />', () => {
toggleHint="toggle me"
/>,
);
await waitUntilReady();
expect(lastFrame()).toMatchSnapshot();
});
it('renders expanded view correctly', () => {
const { lastFrame } = render(
it('renders expanded view correctly', async () => {
const { lastFrame, waitUntilReady } = render(
<Checklist
title="Test List"
items={items}
@@ -56,17 +59,19 @@ describe('<Checklist />', () => {
toggleHint="toggle me"
/>,
);
await waitUntilReady();
expect(lastFrame()).toMatchSnapshot();
});
it('renders summary view without in-progress item if none exists', () => {
it('renders summary view without in-progress item if none exists', async () => {
const pendingItems: ChecklistItemData[] = [
{ status: 'completed', label: 'Task 1' },
{ status: 'pending', label: 'Task 2' },
];
const { lastFrame } = render(
const { lastFrame, waitUntilReady } = render(
<Checklist title="Test List" items={pendingItems} isExpanded={false} />,
);
await waitUntilReady();
expect(lastFrame()).toMatchSnapshot();
});
});
@@ -15,36 +15,39 @@ describe('<ChecklistItem />', () => {
{ status: 'in_progress', label: 'Doing this' },
{ status: 'completed', label: 'Done this' },
{ status: 'cancelled', label: 'Skipped this' },
] as ChecklistItemData[])('renders %s item correctly', (item) => {
const { lastFrame } = render(<ChecklistItem item={item} />);
] as ChecklistItemData[])('renders %s item correctly', async (item) => {
const { lastFrame, waitUntilReady } = render(<ChecklistItem item={item} />);
await waitUntilReady();
expect(lastFrame()).toMatchSnapshot();
});
it('truncates long text when wrap="truncate"', () => {
it('truncates long text when wrap="truncate"', async () => {
const item: ChecklistItemData = {
status: 'in_progress',
label:
'This is a very long text that should be truncated because the wrap prop is set to truncate',
};
const { lastFrame } = render(
const { lastFrame, waitUntilReady } = render(
<Box width={30}>
<ChecklistItem item={item} wrap="truncate" />
</Box>,
);
await waitUntilReady();
expect(lastFrame()).toMatchSnapshot();
});
it('wraps long text by default', () => {
it('wraps long text by default', async () => {
const item: ChecklistItemData = {
status: 'in_progress',
label:
'This is a very long text that should wrap because the default behavior is wrapping',
};
const { lastFrame } = render(
const { lastFrame, waitUntilReady } = render(
<Box width={30}>
<ChecklistItem item={item} />
</Box>,
);
await waitUntilReady();
expect(lastFrame()).toMatchSnapshot();
});
});
@@ -15,17 +15,23 @@ describe('<CliSpinner />', () => {
debugState.debugNumAnimatedComponents = 0;
});
it('should increment debugNumAnimatedComponents on mount and decrement on unmount', () => {
it('should increment debugNumAnimatedComponents on mount and decrement on unmount', async () => {
expect(debugState.debugNumAnimatedComponents).toBe(0);
const { unmount } = renderWithProviders(<CliSpinner />);
const { waitUntilReady, unmount } = renderWithProviders(<CliSpinner />);
await waitUntilReady();
expect(debugState.debugNumAnimatedComponents).toBe(1);
unmount();
expect(debugState.debugNumAnimatedComponents).toBe(0);
});
it('should not render when showSpinner is false', () => {
it('should not render when showSpinner is false', async () => {
const settings = createMockSettings({ ui: { showSpinner: false } });
const { lastFrame } = renderWithProviders(<CliSpinner />, { settings });
expect(lastFrame()).toBe('');
const { lastFrame, waitUntilReady, unmount } = renderWithProviders(
<CliSpinner />,
{ settings },
);
await waitUntilReady();
expect(lastFrame({ allowEmpty: true })).toBe('');
unmount();
});
});
+103 -100
View File
@@ -245,13 +245,13 @@ const createMockConfig = (overrides = {}): Config =>
...overrides,
}) as unknown as Config;
const renderComposer = (
const renderComposer = async (
uiState: UIState,
settings = createMockSettings(),
config = createMockConfig(),
uiActions = createMockUIActions(),
) =>
render(
) => {
const result = render(
<ConfigContext.Provider value={config as unknown as Config}>
<SettingsContext.Provider value={settings as unknown as LoadedSettings}>
<UIStateContext.Provider value={uiState}>
@@ -262,6 +262,9 @@ const renderComposer = (
</SettingsContext.Provider>
</ConfigContext.Provider>,
);
await result.waitUntilReady();
return result;
};
describe('Composer', () => {
beforeEach(() => {
@@ -274,20 +277,20 @@ describe('Composer', () => {
});
describe('Footer Display Settings', () => {
it('renders Footer by default when hideFooter is false', () => {
it('renders Footer by default when hideFooter is false', async () => {
const uiState = createMockUIState();
const settings = createMockSettings({ ui: { hideFooter: false } });
const { lastFrame } = renderComposer(uiState, settings);
const { lastFrame } = await renderComposer(uiState, settings);
expect(lastFrame()).toContain('Footer');
});
it('does NOT render Footer when hideFooter is true', () => {
it('does NOT render Footer when hideFooter is true', async () => {
const uiState = createMockUIState();
const settings = createMockSettings({ ui: { hideFooter: true } });
const { lastFrame } = renderComposer(uiState, settings);
const { lastFrame } = await renderComposer(uiState, settings);
// Check for content that only appears IN the Footer component itself
expect(lastFrame()).not.toContain('[NORMAL]'); // Vim mode indicator
@@ -331,7 +334,7 @@ describe('Composer', () => {
setVimMode: vi.fn(),
} as unknown as ReturnType<typeof useVimMode>);
const { lastFrame } = renderComposer(uiState, settings, config);
const { lastFrame } = await renderComposer(uiState, settings, config);
expect(lastFrame()).toContain('Footer');
// Footer should be rendered with all the state passed through
@@ -339,7 +342,7 @@ describe('Composer', () => {
});
describe('Loading Indicator', () => {
it('renders LoadingIndicator with thought when streaming', () => {
it('renders LoadingIndicator with thought when streaming', async () => {
const uiState = createMockUIState({
streamingState: StreamingState.Responding,
thought: {
@@ -350,13 +353,13 @@ describe('Composer', () => {
elapsedTime: 1500,
});
const { lastFrame } = renderComposer(uiState);
const { lastFrame } = await renderComposer(uiState);
const output = lastFrame();
expect(output).toContain('LoadingIndicator: Processing');
});
it('renders generic thinking text in loading indicator when full inline thinking is enabled', () => {
it('renders generic thinking text in loading indicator when full inline thinking is enabled', async () => {
const uiState = createMockUIState({
streamingState: StreamingState.Responding,
thought: {
@@ -368,27 +371,27 @@ describe('Composer', () => {
ui: { inlineThinkingMode: 'full' },
});
const { lastFrame } = renderComposer(uiState, settings);
const { lastFrame } = await renderComposer(uiState, settings);
const output = lastFrame();
expect(output).toContain('LoadingIndicator: Thinking ...');
});
it('hides shortcuts hint while loading', () => {
it('hides shortcuts hint while loading', async () => {
const uiState = createMockUIState({
streamingState: StreamingState.Responding,
elapsedTime: 1,
cleanUiDetailsVisible: false,
});
const { lastFrame } = renderComposer(uiState);
const { lastFrame } = await renderComposer(uiState);
const output = lastFrame();
expect(output).toContain('LoadingIndicator');
expect(output).not.toContain('ShortcutsHint');
});
it('renders LoadingIndicator without thought when accessibility disables loading phrases', () => {
it('renders LoadingIndicator without thought when accessibility disables loading phrases', async () => {
const uiState = createMockUIState({
streamingState: StreamingState.Responding,
thought: { subject: 'Hidden', description: 'Should not show' },
@@ -397,14 +400,14 @@ describe('Composer', () => {
getAccessibility: vi.fn(() => ({ enableLoadingPhrases: false })),
});
const { lastFrame } = renderComposer(uiState, undefined, config);
const { lastFrame } = await renderComposer(uiState, undefined, config);
const output = lastFrame();
expect(output).toContain('LoadingIndicator');
expect(output).not.toContain('Should not show');
});
it('does not render LoadingIndicator when waiting for confirmation', () => {
it('does not render LoadingIndicator when waiting for confirmation', async () => {
const uiState = createMockUIState({
streamingState: StreamingState.WaitingForConfirmation,
thought: {
@@ -413,13 +416,13 @@ describe('Composer', () => {
},
});
const { lastFrame } = renderComposer(uiState);
const { lastFrame } = await renderComposer(uiState);
const output = lastFrame();
expect(output).not.toContain('LoadingIndicator');
});
it('does not render LoadingIndicator when a tool confirmation is pending', () => {
it('does not render LoadingIndicator when a tool confirmation is pending', async () => {
const uiState = createMockUIState({
streamingState: StreamingState.Responding,
pendingHistoryItems: [
@@ -439,27 +442,27 @@ describe('Composer', () => {
],
});
const { lastFrame } = renderComposer(uiState);
const { lastFrame } = await renderComposer(uiState);
const output = lastFrame();
expect(output).not.toContain('LoadingIndicator');
expect(output).not.toContain('esc to cancel');
});
it('renders LoadingIndicator when embedded shell is focused but background shell is visible', () => {
it('renders LoadingIndicator when embedded shell is focused but background shell is visible', async () => {
const uiState = createMockUIState({
streamingState: StreamingState.Responding,
embeddedShellFocused: true,
isBackgroundShellVisible: true,
});
const { lastFrame } = renderComposer(uiState);
const { lastFrame } = await renderComposer(uiState);
const output = lastFrame();
expect(output).toContain('LoadingIndicator');
});
it('renders both LoadingIndicator and ApprovalModeIndicator when streaming in full UI mode', () => {
it('renders both LoadingIndicator and ApprovalModeIndicator when streaming in full UI mode', async () => {
const uiState = createMockUIState({
streamingState: StreamingState.Responding,
thought: {
@@ -469,21 +472,21 @@ describe('Composer', () => {
showApprovalModeIndicator: ApprovalMode.PLAN,
});
const { lastFrame } = renderComposer(uiState);
const { lastFrame } = await renderComposer(uiState);
const output = lastFrame();
expect(output).toContain('LoadingIndicator: Thinking');
expect(output).toContain('ApprovalModeIndicator');
});
it('does NOT render LoadingIndicator when embedded shell is focused and background shell is NOT visible', () => {
it('does NOT render LoadingIndicator when embedded shell is focused and background shell is NOT visible', async () => {
const uiState = createMockUIState({
streamingState: StreamingState.Responding,
embeddedShellFocused: true,
isBackgroundShellVisible: false,
});
const { lastFrame } = renderComposer(uiState);
const { lastFrame } = await renderComposer(uiState);
const output = lastFrame();
expect(output).not.toContain('LoadingIndicator');
@@ -491,7 +494,7 @@ describe('Composer', () => {
});
describe('Message Queue Display', () => {
it('displays queued messages when present', () => {
it('displays queued messages when present', async () => {
const uiState = createMockUIState({
messageQueue: [
'First queued message',
@@ -500,7 +503,7 @@ describe('Composer', () => {
],
});
const { lastFrame } = renderComposer(uiState);
const { lastFrame } = await renderComposer(uiState);
const output = lastFrame();
expect(output).toContain('First queued message');
@@ -508,12 +511,12 @@ describe('Composer', () => {
expect(output).toContain('Third queued message');
});
it('renders QueuedMessageDisplay with empty message queue', () => {
it('renders QueuedMessageDisplay with empty message queue', async () => {
const uiState = createMockUIState({
messageQueue: [],
});
const { lastFrame } = renderComposer(uiState);
const { lastFrame } = await renderComposer(uiState);
// The component should render but return null for empty queue
// This test verifies that the component receives the correct prop
@@ -523,14 +526,14 @@ describe('Composer', () => {
});
describe('Context and Status Display', () => {
it('shows StatusDisplay and ApprovalModeIndicator in normal state', () => {
it('shows StatusDisplay and ApprovalModeIndicator in normal state', async () => {
const uiState = createMockUIState({
ctrlCPressedOnce: false,
ctrlDPressedOnce: false,
showEscapePrompt: false,
});
const { lastFrame } = renderComposer(uiState);
const { lastFrame } = await renderComposer(uiState);
const output = lastFrame();
expect(output).toContain('StatusDisplay');
@@ -538,12 +541,12 @@ describe('Composer', () => {
expect(output).not.toContain('ToastDisplay');
});
it('shows ToastDisplay and hides ApprovalModeIndicator when a toast is present', () => {
it('shows ToastDisplay and hides ApprovalModeIndicator when a toast is present', async () => {
const uiState = createMockUIState({
ctrlCPressedOnce: true,
});
const { lastFrame } = renderComposer(uiState);
const { lastFrame } = await renderComposer(uiState);
const output = lastFrame();
expect(output).toContain('ToastDisplay');
@@ -551,7 +554,7 @@ describe('Composer', () => {
expect(output).toContain('StatusDisplay');
});
it('shows ToastDisplay for other toast types', () => {
it('shows ToastDisplay for other toast types', async () => {
const uiState = createMockUIState({
transientMessage: {
text: 'Warning',
@@ -559,7 +562,7 @@ describe('Composer', () => {
},
});
const { lastFrame } = renderComposer(uiState);
const { lastFrame } = await renderComposer(uiState);
const output = lastFrame();
expect(output).toContain('ToastDisplay');
@@ -568,12 +571,12 @@ describe('Composer', () => {
});
describe('Input and Indicators', () => {
it('hides non-essential UI details in clean mode', () => {
it('hides non-essential UI details in clean mode', async () => {
const uiState = createMockUIState({
cleanUiDetailsVisible: false,
});
const { lastFrame } = renderComposer(uiState);
const { lastFrame } = await renderComposer(uiState);
const output = lastFrame();
expect(output).toContain('ShortcutsHint');
@@ -583,22 +586,22 @@ describe('Composer', () => {
expect(output).not.toContain('ContextSummaryDisplay');
});
it('renders InputPrompt when input is active', () => {
it('renders InputPrompt when input is active', async () => {
const uiState = createMockUIState({
isInputActive: true,
});
const { lastFrame } = renderComposer(uiState);
const { lastFrame } = await renderComposer(uiState);
expect(lastFrame()).toContain('InputPrompt');
});
it('does not render InputPrompt when input is inactive', () => {
it('does not render InputPrompt when input is inactive', async () => {
const uiState = createMockUIState({
isInputActive: false,
});
const { lastFrame } = renderComposer(uiState);
const { lastFrame } = await renderComposer(uiState);
expect(lastFrame()).not.toContain('InputPrompt');
});
@@ -610,44 +613,44 @@ describe('Composer', () => {
[ApprovalMode.YOLO],
])(
'shows ApprovalModeIndicator when approval mode is %s and shell mode is inactive',
(mode) => {
async (mode) => {
const uiState = createMockUIState({
showApprovalModeIndicator: mode,
shellModeActive: false,
});
const { lastFrame } = renderComposer(uiState);
const { lastFrame } = await renderComposer(uiState);
expect(lastFrame()).toMatch(/ApprovalModeIndic[\s\S]*ator/);
},
);
it('shows ShellModeIndicator when shell mode is active', () => {
it('shows ShellModeIndicator when shell mode is active', async () => {
const uiState = createMockUIState({
shellModeActive: true,
});
const { lastFrame } = renderComposer(uiState);
const { lastFrame } = await renderComposer(uiState);
expect(lastFrame()).toMatch(/ShellModeIndic[\s\S]*tor/);
});
it('shows RawMarkdownIndicator when renderMarkdown is false', () => {
it('shows RawMarkdownIndicator when renderMarkdown is false', async () => {
const uiState = createMockUIState({
renderMarkdown: false,
});
const { lastFrame } = renderComposer(uiState);
const { lastFrame } = await renderComposer(uiState);
expect(lastFrame()).toContain('raw markdown mode');
});
it('does not show RawMarkdownIndicator when renderMarkdown is true', () => {
it('does not show RawMarkdownIndicator when renderMarkdown is true', async () => {
const uiState = createMockUIState({
renderMarkdown: true,
});
const { lastFrame } = renderComposer(uiState);
const { lastFrame } = await renderComposer(uiState);
expect(lastFrame()).not.toContain('raw markdown mode');
});
@@ -658,18 +661,18 @@ describe('Composer', () => {
[ApprovalMode.AUTO_EDIT, 'auto edit'],
])(
'shows minimal mode badge "%s" when clean UI details are hidden',
(mode, label) => {
async (mode, label) => {
const uiState = createMockUIState({
cleanUiDetailsVisible: false,
showApprovalModeIndicator: mode,
});
const { lastFrame } = renderComposer(uiState);
const { lastFrame } = await renderComposer(uiState);
expect(lastFrame()).toContain(label);
},
);
it('hides minimal mode badge while loading in clean mode', () => {
it('hides minimal mode badge while loading in clean mode', async () => {
const uiState = createMockUIState({
cleanUiDetailsVisible: false,
streamingState: StreamingState.Responding,
@@ -677,14 +680,14 @@ describe('Composer', () => {
showApprovalModeIndicator: ApprovalMode.PLAN,
});
const { lastFrame } = renderComposer(uiState);
const { lastFrame } = await renderComposer(uiState);
const output = lastFrame();
expect(output).toContain('LoadingIndicator');
expect(output).not.toContain('plan');
expect(output).not.toContain('ShortcutsHint');
});
it('hides minimal mode badge while action-required state is active', () => {
it('hides minimal mode badge while action-required state is active', async () => {
const uiState = createMockUIState({
cleanUiDetailsVisible: false,
showApprovalModeIndicator: ApprovalMode.PLAN,
@@ -695,26 +698,26 @@ describe('Composer', () => {
),
});
const { lastFrame } = renderComposer(uiState);
const { lastFrame } = await renderComposer(uiState);
const output = lastFrame();
expect(output).not.toContain('plan');
expect(output).not.toContain('ShortcutsHint');
});
it('shows Esc rewind prompt in minimal mode without showing full UI', () => {
it('shows Esc rewind prompt in minimal mode without showing full UI', async () => {
const uiState = createMockUIState({
cleanUiDetailsVisible: false,
showEscapePrompt: true,
history: [{ id: 1, type: 'user', text: 'msg' }],
});
const { lastFrame } = renderComposer(uiState);
const { lastFrame } = await renderComposer(uiState);
const output = lastFrame();
expect(output).toContain('ToastDisplay');
expect(output).not.toContain('ContextSummaryDisplay');
});
it('shows context usage bleed-through when over 60%', () => {
it('shows context usage bleed-through when over 60%', async () => {
const model = 'gemini-2.5-pro';
const uiState = createMockUIState({
cleanUiDetailsVisible: false,
@@ -734,13 +737,13 @@ describe('Composer', () => {
},
});
const { lastFrame } = renderComposer(uiState, settings);
const { lastFrame } = await renderComposer(uiState, settings);
expect(lastFrame()).toContain('%');
});
});
describe('Error Details Display', () => {
it('shows DetailedMessagesDisplay when showErrorDetails is true', () => {
it('shows DetailedMessagesDisplay when showErrorDetails is true', async () => {
const uiState = createMockUIState({
showErrorDetails: true,
filteredConsoleMessages: [
@@ -752,18 +755,18 @@ describe('Composer', () => {
],
});
const { lastFrame } = renderComposer(uiState);
const { lastFrame } = await renderComposer(uiState);
expect(lastFrame()).toContain('DetailedMessagesDisplay');
expect(lastFrame()).toContain('ShowMoreLines');
});
it('does not show error details when showErrorDetails is false', () => {
it('does not show error details when showErrorDetails is false', async () => {
const uiState = createMockUIState({
showErrorDetails: false,
});
const { lastFrame } = renderComposer(uiState);
const { lastFrame } = await renderComposer(uiState);
expect(lastFrame()).not.toContain('DetailedMessagesDisplay');
});
@@ -780,7 +783,7 @@ describe('Composer', () => {
setVimMode: vi.fn(),
});
const { lastFrame } = renderComposer(uiState);
const { lastFrame } = await renderComposer(uiState);
expect(lastFrame()).toContain(
"InputPrompt: Press 'Esc' for NORMAL mode.",
@@ -797,7 +800,7 @@ describe('Composer', () => {
setVimMode: vi.fn(),
});
const { lastFrame } = renderComposer(uiState);
const { lastFrame } = await renderComposer(uiState);
expect(lastFrame()).toContain(
"InputPrompt: Press 'i' for INSERT mode.",
@@ -806,7 +809,7 @@ describe('Composer', () => {
});
describe('Shortcuts Hint', () => {
it('hides shortcuts hint when showShortcutsHint setting is false', () => {
it('hides shortcuts hint when showShortcutsHint setting is false', async () => {
const uiState = createMockUIState();
const settings = createMockSettings({
ui: {
@@ -814,12 +817,12 @@ describe('Composer', () => {
},
});
const { lastFrame } = renderComposer(uiState, settings);
const { lastFrame } = await renderComposer(uiState, settings);
expect(lastFrame()).not.toContain('ShortcutsHint');
});
it('hides shortcuts hint when a action is required (e.g. dialog is open)', () => {
it('hides shortcuts hint when a action is required (e.g. dialog is open)', async () => {
const uiState = createMockUIState({
customDialog: (
<Box>
@@ -829,55 +832,55 @@ describe('Composer', () => {
),
});
const { lastFrame } = renderComposer(uiState);
const { lastFrame } = await renderComposer(uiState);
expect(lastFrame()).not.toContain('ShortcutsHint');
});
it('keeps shortcuts hint visible when no action is required', () => {
it('keeps shortcuts hint visible when no action is required', async () => {
const uiState = createMockUIState({
cleanUiDetailsVisible: false,
});
const { lastFrame } = renderComposer(uiState);
const { lastFrame } = await renderComposer(uiState);
expect(lastFrame()).toContain('ShortcutsHint');
});
it('shows shortcuts hint when full UI details are visible', () => {
it('shows shortcuts hint when full UI details are visible', async () => {
const uiState = createMockUIState({
cleanUiDetailsVisible: true,
});
const { lastFrame } = renderComposer(uiState);
const { lastFrame } = await renderComposer(uiState);
expect(lastFrame()).toContain('ShortcutsHint');
});
it('hides shortcuts hint while loading in minimal mode', () => {
it('hides shortcuts hint while loading in minimal mode', async () => {
const uiState = createMockUIState({
cleanUiDetailsVisible: false,
streamingState: StreamingState.Responding,
elapsedTime: 1,
});
const { lastFrame } = renderComposer(uiState);
const { lastFrame } = await renderComposer(uiState);
expect(lastFrame()).not.toContain('ShortcutsHint');
});
it('shows shortcuts help in minimal mode when toggled on', () => {
it('shows shortcuts help in minimal mode when toggled on', async () => {
const uiState = createMockUIState({
cleanUiDetailsVisible: false,
shortcutsHelpVisible: true,
});
const { lastFrame } = renderComposer(uiState);
const { lastFrame } = await renderComposer(uiState);
expect(lastFrame()).toContain('ShortcutsHelp');
});
it('hides shortcuts hint when suggestions are visible above input in alternate buffer', () => {
it('hides shortcuts hint when suggestions are visible above input in alternate buffer', async () => {
composerTestControls.isAlternateBuffer = true;
composerTestControls.suggestionsVisible = true;
@@ -886,13 +889,13 @@ describe('Composer', () => {
showApprovalModeIndicator: ApprovalMode.PLAN,
});
const { lastFrame } = renderComposer(uiState);
const { lastFrame } = await renderComposer(uiState);
expect(lastFrame()).not.toContain('ShortcutsHint');
expect(lastFrame()).not.toContain('plan');
});
it('hides approval mode indicator when suggestions are visible above input in alternate buffer', () => {
it('hides approval mode indicator when suggestions are visible above input in alternate buffer', async () => {
composerTestControls.isAlternateBuffer = true;
composerTestControls.suggestionsVisible = true;
@@ -901,12 +904,12 @@ describe('Composer', () => {
showApprovalModeIndicator: ApprovalMode.YOLO,
});
const { lastFrame } = renderComposer(uiState);
const { lastFrame } = await renderComposer(uiState);
expect(lastFrame()).not.toContain('ApprovalModeIndicator');
});
it('keeps shortcuts hint when suggestions are visible below input in regular buffer', () => {
it('keeps shortcuts hint when suggestions are visible below input in regular buffer', async () => {
composerTestControls.isAlternateBuffer = false;
composerTestControls.suggestionsVisible = true;
@@ -914,36 +917,36 @@ describe('Composer', () => {
cleanUiDetailsVisible: false,
});
const { lastFrame } = renderComposer(uiState);
const { lastFrame } = await renderComposer(uiState);
expect(lastFrame()).toContain('ShortcutsHint');
});
});
describe('Shortcuts Help', () => {
it('shows shortcuts help in passive state', () => {
it('shows shortcuts help in passive state', async () => {
const uiState = createMockUIState({
shortcutsHelpVisible: true,
streamingState: StreamingState.Idle,
});
const { lastFrame } = renderComposer(uiState);
const { lastFrame } = await renderComposer(uiState);
expect(lastFrame()).toContain('ShortcutsHelp');
});
it('hides shortcuts help while streaming', () => {
it('hides shortcuts help while streaming', async () => {
const uiState = createMockUIState({
shortcutsHelpVisible: true,
streamingState: StreamingState.Responding,
});
const { lastFrame } = renderComposer(uiState);
const { lastFrame } = await renderComposer(uiState);
expect(lastFrame()).not.toContain('ShortcutsHelp');
});
it('hides shortcuts help when action is required', () => {
it('hides shortcuts help when action is required', async () => {
const uiState = createMockUIState({
shortcutsHelpVisible: true,
customDialog: (
@@ -953,20 +956,20 @@ describe('Composer', () => {
),
});
const { lastFrame } = renderComposer(uiState);
const { lastFrame } = await renderComposer(uiState);
expect(lastFrame()).not.toContain('ShortcutsHelp');
});
});
describe('Snapshots', () => {
it('matches snapshot in idle state', () => {
it('matches snapshot in idle state', async () => {
const uiState = createMockUIState();
const { lastFrame } = renderComposer(uiState);
const { lastFrame } = await renderComposer(uiState);
expect(lastFrame()).toMatchSnapshot();
});
it('matches snapshot while streaming', () => {
it('matches snapshot while streaming', async () => {
const uiState = createMockUIState({
streamingState: StreamingState.Responding,
thought: {
@@ -974,33 +977,33 @@ describe('Composer', () => {
description: 'Thinking about the meaning of life...',
},
});
const { lastFrame } = renderComposer(uiState);
const { lastFrame } = await renderComposer(uiState);
expect(lastFrame()).toMatchSnapshot();
});
it('matches snapshot in narrow view', () => {
it('matches snapshot in narrow view', async () => {
const uiState = createMockUIState({
terminalWidth: 40,
});
const { lastFrame } = renderComposer(uiState);
const { lastFrame } = await renderComposer(uiState);
expect(lastFrame()).toMatchSnapshot();
});
it('matches snapshot in minimal UI mode', () => {
it('matches snapshot in minimal UI mode', async () => {
const uiState = createMockUIState({
cleanUiDetailsVisible: false,
});
const { lastFrame } = renderComposer(uiState);
const { lastFrame } = await renderComposer(uiState);
expect(lastFrame()).toMatchSnapshot();
});
it('matches snapshot in minimal UI mode while loading', () => {
it('matches snapshot in minimal UI mode while loading', async () => {
const uiState = createMockUIState({
cleanUiDetailsVisible: false,
streamingState: StreamingState.Responding,
elapsedTime: 1000,
});
const { lastFrame } = renderComposer(uiState);
const { lastFrame } = await renderComposer(uiState);
expect(lastFrame()).toMatchSnapshot();
});
});
@@ -42,8 +42,9 @@ describe('ConfigInitDisplay', () => {
vi.restoreAllMocks();
});
it('renders initial state', () => {
const { lastFrame } = render(<ConfigInitDisplay />);
it('renders initial state', async () => {
const { lastFrame, waitUntilReady } = render(<ConfigInitDisplay />);
await waitUntilReady();
expect(lastFrame()).toMatchSnapshot();
});
@@ -31,15 +31,16 @@ describe('ConsentPrompt', () => {
vi.clearAllMocks();
});
it('renders a string prompt with MarkdownDisplay', () => {
it('renders a string prompt with MarkdownDisplay', async () => {
const prompt = 'Are you sure?';
const { unmount } = render(
const { waitUntilReady, unmount } = render(
<ConsentPrompt
prompt={prompt}
onConfirm={onConfirm}
terminalWidth={terminalWidth}
/>,
);
await waitUntilReady();
expect(MockedMarkdownDisplay).toHaveBeenCalledWith(
{
@@ -52,15 +53,16 @@ describe('ConsentPrompt', () => {
unmount();
});
it('renders a ReactNode prompt directly', () => {
it('renders a ReactNode prompt directly', async () => {
const prompt = <Text>Are you sure?</Text>;
const { lastFrame, unmount } = render(
const { lastFrame, waitUntilReady, unmount } = render(
<ConsentPrompt
prompt={prompt}
onConfirm={onConfirm}
terminalWidth={terminalWidth}
/>,
);
await waitUntilReady();
expect(MockedMarkdownDisplay).not.toHaveBeenCalled();
expect(lastFrame()).toContain('Are you sure?');
@@ -69,18 +71,20 @@ describe('ConsentPrompt', () => {
it('calls onConfirm with true when "Yes" is selected', async () => {
const prompt = 'Are you sure?';
const { unmount } = render(
const { waitUntilReady, unmount } = render(
<ConsentPrompt
prompt={prompt}
onConfirm={onConfirm}
terminalWidth={terminalWidth}
/>,
);
await waitUntilReady();
const onSelect = MockedRadioButtonSelect.mock.calls[0][0].onSelect;
await act(async () => {
onSelect(true);
});
await waitUntilReady();
expect(onConfirm).toHaveBeenCalledWith(true);
unmount();
@@ -88,32 +92,35 @@ describe('ConsentPrompt', () => {
it('calls onConfirm with false when "No" is selected', async () => {
const prompt = 'Are you sure?';
const { unmount } = render(
const { waitUntilReady, unmount } = render(
<ConsentPrompt
prompt={prompt}
onConfirm={onConfirm}
terminalWidth={terminalWidth}
/>,
);
await waitUntilReady();
const onSelect = MockedRadioButtonSelect.mock.calls[0][0].onSelect;
await act(async () => {
onSelect(false);
});
await waitUntilReady();
expect(onConfirm).toHaveBeenCalledWith(false);
unmount();
});
it('passes correct items to RadioButtonSelect', () => {
it('passes correct items to RadioButtonSelect', async () => {
const prompt = 'Are you sure?';
const { unmount } = render(
const { waitUntilReady, unmount } = render(
<ConsentPrompt
prompt={prompt}
onConfirm={onConfirm}
terminalWidth={terminalWidth}
/>,
);
await waitUntilReady();
expect(MockedRadioButtonSelect).toHaveBeenCalledWith(
expect.objectContaining({
@@ -9,19 +9,27 @@ import { ConsoleSummaryDisplay } from './ConsoleSummaryDisplay.js';
import { describe, it, expect } from 'vitest';
describe('ConsoleSummaryDisplay', () => {
it('renders nothing when errorCount is 0', () => {
const { lastFrame } = render(<ConsoleSummaryDisplay errorCount={0} />);
expect(lastFrame()).toBe('');
it('renders nothing when errorCount is 0', async () => {
const { lastFrame, waitUntilReady, unmount } = render(
<ConsoleSummaryDisplay errorCount={0} />,
);
await waitUntilReady();
expect(lastFrame({ allowEmpty: true })).toBe('');
unmount();
});
it.each([
[1, '1 error'],
[5, '5 errors'],
])('renders correct message for %i errors', (count, expectedText) => {
const { lastFrame } = render(<ConsoleSummaryDisplay errorCount={count} />);
])('renders correct message for %i errors', async (count, expectedText) => {
const { lastFrame, waitUntilReady, unmount } = render(
<ConsoleSummaryDisplay errorCount={count} />,
);
await waitUntilReady();
const output = lastFrame();
expect(output).toContain(expectedText);
expect(output).toContain('✖');
expect(output).toContain('(F12 for details)');
unmount();
});
});
@@ -21,12 +21,14 @@ afterEach(() => {
vi.useRealTimers();
});
const renderWithWidth = (
const renderWithWidth = async (
width: number,
props: React.ComponentProps<typeof ContextSummaryDisplay>,
) => {
useTerminalSizeMock.mockReturnValue({ columns: width, rows: 24 });
return render(<ContextSummaryDisplay {...props} />);
const result = render(<ContextSummaryDisplay {...props} />);
await result.waitUntilReady();
return result;
};
describe('<ContextSummaryDisplay />', () => {
@@ -42,7 +44,7 @@ describe('<ContextSummaryDisplay />', () => {
skillCount: 1,
};
it('should render on a single line on a wide screen', () => {
it('should render on a single line on a wide screen', async () => {
const props = {
...baseProps,
geminiMdFileCount: 1,
@@ -54,12 +56,12 @@ describe('<ContextSummaryDisplay />', () => {
},
},
};
const { lastFrame, unmount } = renderWithWidth(120, props);
const { lastFrame, unmount } = await renderWithWidth(120, props);
expect(lastFrame()).toMatchSnapshot();
unmount();
});
it('should render on multiple lines on a narrow screen', () => {
it('should render on multiple lines on a narrow screen', async () => {
const props = {
...baseProps,
geminiMdFileCount: 1,
@@ -71,12 +73,12 @@ describe('<ContextSummaryDisplay />', () => {
},
},
};
const { lastFrame, unmount } = renderWithWidth(60, props);
const { lastFrame, unmount } = await renderWithWidth(60, props);
expect(lastFrame()).toMatchSnapshot();
unmount();
});
it('should switch layout at the 80-column breakpoint', () => {
it('should switch layout at the 80-column breakpoint', async () => {
const props = {
...baseProps,
geminiMdFileCount: 1,
@@ -90,23 +92,19 @@ describe('<ContextSummaryDisplay />', () => {
};
// At 80 columns, should be on one line
const { lastFrame: wideFrame, unmount: unmountWide } = renderWithWidth(
80,
props,
);
expect(wideFrame()!.includes('\n')).toBe(false);
const { lastFrame: wideFrame, unmount: unmountWide } =
await renderWithWidth(80, props);
expect(wideFrame().trim().includes('\n')).toBe(false);
unmountWide();
// At 79 columns, should be on multiple lines
const { lastFrame: narrowFrame, unmount: unmountNarrow } = renderWithWidth(
79,
props,
);
expect(narrowFrame()!.includes('\n')).toBe(true);
expect(narrowFrame()!.split('\n').length).toBe(4);
const { lastFrame: narrowFrame, unmount: unmountNarrow } =
await renderWithWidth(79, props);
expect(narrowFrame().trim().includes('\n')).toBe(true);
expect(narrowFrame().trim().split('\n').length).toBe(4);
unmountNarrow();
});
it('should not render empty parts', () => {
it('should not render empty parts', async () => {
const props = {
...baseProps,
geminiMdFileCount: 0,
@@ -119,7 +117,7 @@ describe('<ContextSummaryDisplay />', () => {
},
},
};
const { lastFrame, unmount } = renderWithWidth(60, props);
const { lastFrame, unmount } = await renderWithWidth(60, props);
expect(lastFrame()).toMatchSnapshot();
unmount();
});

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