Compare commits

...

55 Commits

Author SHA1 Message Date
Aishanee Shah e040f9caef Add complex HTML modification behavioral test 2026-02-18 22:51:45 +00:00
Aishanee Shah d8f740709f Merge XML handling evals into a single file 2026-02-18 22:20:01 +00:00
Aishanee Shah 7fd4bca00a Update eval to use structured JSON output 2026-02-18 22:13:03 +00:00
Aishanee Shah 4fd20c9200 test: add behavioral test for shell XML/HTML output safety 2026-02-18 21:54:13 +00:00
Aishanee Shah 91c07e724f revert: unrelated changes to strict-development-rules.md 2026-02-18 17:58:58 +00:00
Aishanee Shah a69ae5a283 Merge branch 'main' into task/subprocess_xml_tagging 2026-02-18 12:52:00 -05:00
Aishanee Shah 9ed588c761 fix(test): resolve type error in shell-xml-safety.test.ts 2026-02-18 17:51:09 +00:00
Aishanee Shah e7eb1d5811 security: implement secure XML protocol for subprocess tools 2026-02-18 17:38:13 +00:00
Tommaso Sciortino 5f6b7c0158 feat(cli): add Alt+D for forward word deletion (#19300) 2026-02-18 09:19:26 -08:00
Aishanee Shah 37c20a6691 Update tool-registry.ts
move exit code above other parts when tool fails
2026-02-18 11:49:45 -05:00
Aishanee Shah 032e40ff54 Update shell.ts
Add exit_code before output or error to signal the model
2026-02-18 11:40:05 -05: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
Aishanee Shah 72bf0c0add Update shell.ts 2026-02-18 00:06:27 -05:00
Aishanee Shah aa5f09626e Update shell.ts
Wrap XML content in CDATA
2026-02-18 00:01:33 -05:00
g-samroberts 4e11ddbf4a Release note generator fix (#19363) 2026-02-18 04:53:53 +00:00
Aishanee Shah 4501c78470 chore(core): align shell tool description with snapshots by removing returned info section 2026-02-18 04:12:23 +00:00
Aishanee Shah 6216573369 Revert "chore(core): update tool descriptions and snapshots for exit_code change"
This reverts commit 491b399c8d.
2026-02-18 03:49:25 +00:00
Aishanee Shah dc8424a87c Revert "chore(core): update tool model snapshots"
This reverts commit 96acad5602.
2026-02-18 03:49:25 +00:00
Jacob Richman fe65d562de Fix bottom border color (#19266) 2026-02-18 02:41:43 +00:00
Aishanee Shah 96acad5602 chore(core): update tool model snapshots 2026-02-18 02:07:33 +00:00
Aishanee Shah 491b399c8d chore(core): update tool descriptions and snapshots for exit_code change 2026-02-18 01:40:45 +00:00
Aishanee Shah 4342d773cc test(evals): add behavioral tests for subprocess XML tagging 2026-02-18 01:20:24 +00:00
Aishanee Shah 0643481cbd feat(shell): always include exit_code in subprocess_result XML 2026-02-18 01:20:24 +00:00
Aishanee Shah f8b3d14d26 feat(core): separate stdout and stderr in discovered tool output 2026-02-18 01:20:24 +00:00
Aishanee Shah 9e7c1b1984 feat(core): transition to self-describing XML output for subprocess tools 2026-02-18 01:18:48 +00:00
Aishanee Shah 4428c83be4 feat(core): deduplicate subprocess protocol in tool descriptions 2026-02-18 01:17:26 +00:00
Christian Gunderman ce84b3cb5f Use ranged reads and limited searches and fuzzy editing improvements (#19240) 2026-02-17 23:54:08 +00:00
joshualitt 55c628e967 feat(core): experimental in-progress steering hints (1 of 3) (#19008) 2026-02-17 22:59:33 +00:00
Spencer 5e2f5df62c fix(paths): Add cross-platform path normalization (#18939) 2026-02-17 22:52:55 +00:00
Aishanee Shah 4fe86dbd4f refactor(core): modularize tool definitions by model family (#19269) 2026-02-17 22:26:38 +00:00
Jenna Inouye 216d7272bb Docs: Clarify extensions documentation. (#19277) 2026-02-17 21:57:27 +00:00
Pavan Shinde b7004ad5ed docs: format UTC times in releases doc (#18169) 2026-02-17 21:25:03 +00:00
Valery Teplyakov f1aa1683dd fix(acp): Wait for mcp initialization in acp (#18893) (#18894)
Co-authored-by: Mervap <megavaprold@gmail.com>
Co-authored-by: Shreya Keshive <shreyakeshive@google.com>
2026-02-17 19:39:14 +00:00
Kevin Ramdass b56361559d feat(config): add setting to make directory tree context configurable (#19053) 2026-02-17 19:19:26 +00:00
g-samroberts c62601e90c Fix side breakage where anchors don't work in slugs. (#19261) 2026-02-17 18:38:00 +00:00
Jerop Kipruto fb32db5cd6 feat(cli): remove Plan Mode from rotation when actively working (#19262) 2026-02-17 17:36:59 +00:00
Yuna Seol 8aca3068cf feat: add role-specific statistics to telemetry and UI (cont. #15234) (#18824)
Co-authored-by: Yuna Seol <yunaseol@google.com>
2026-02-17 17:32:30 +00:00
Adib234 14aabbbe8b feat(plan): support project exploration without planning when in plan mode (#18992) 2026-02-17 16:52:59 +00:00
Jacob Richman 366f1df120 refactor(cli): code review cleanup fix for tab+tab (#18967) 2026-02-17 15:16:37 +00:00
Jerop Kipruto e5ff2023ad feat(cli): update approval mode cycle order (#19254) 2026-02-17 15:13:27 +00:00
Abhi bf9ca33c18 feat(telemetry): add keychain availability and token storage metrics (#18971) 2026-02-17 15:11:38 +00:00
Gaurav bbf6800778 fix(telemetry): replace JSON.stringify with safeJsonStringify in file exporters (#19244) 2026-02-17 15:01:54 +00:00
Ramón Medrano Llamas b38e0984b9 Add Solarized Dark and Solarized Light themes (#19064)
Co-authored-by: google-labs-jules[bot] <161369871+google-labs-jules[bot]@users.noreply.github.com>
2026-02-16 22:01:52 +00:00
Sehoon Shon ddd28f6431 chore(ui): remove outdated tip about model routing (#19226) 2026-02-16 20:50:13 +00:00
Jacob Richman 6ed1878c5f refactor: consolidate development rules and add cli guidelines (#19214) 2026-02-16 20:48:34 +00:00
N. Taylor Mullen 39d36108d7 feat(core): support custom reasoning models by default (#19227) 2026-02-16 20:47:58 +00:00
Krushna Korade 80f0cbd798 Add refresh/reload aliases to slash command subcommands (#19218)
Co-authored-by: Jack Wotherspoon <jackwoth@google.com>
2026-02-16 20:31:03 +00:00
Sehoon Shon 7d165e77f0 feat(cli): refactor model command to support set and manage subcommands (#19221) 2026-02-16 20:10:34 +00:00
kevinjwang1 c57a28f48a Disable workspace settings when starting GCLI in the home directory. (#19034) 2026-02-16 20:10:28 +00:00
Jack Wotherspoon a83ca11035 docs: custom themes in extensions (#19219) 2026-02-16 19:58:48 +00:00
Sehoon Shon 15ef1cd797 feat(cli): handle invalid model names in useQuotaAndFallback (#19222) 2026-02-16 19:55:17 +00:00
g-samroberts 5a74f7a2eb Add base branch to workflow. (#19189) 2026-02-16 17:25:19 +00:00
210 changed files with 9454 additions and 2790 deletions
+1 -35
View File
@@ -14,41 +14,7 @@ core architecture, component patterns, and testing standards.
In addition to the code context, you MUST strictly adhere to the following frontend-specific development guidelines while adding code to packages/cli.
## Testing Standards
* **Async Testing**: ALWAYS use `waitFor` from `packages/cli/src/test-utils/async.ts` instead of `vi.waitFor` to prevent flakiness and `act` warnings. NEVER use fixed waits (e.g., `await delay(100)`).
* **State Changes**: Wrap all blocks that change component state in `act`.
* **Snapshots**: Use `toMatchSnapshot` to verify rendering.
* **Rendering**: Use `render` or `renderWithProviders` from `packages/cli/src/test-utils/render.tsx` instead of `ink-testing-library` directly.
* **Mocking**:
* Reuse existing mocks/fakes where possible.
* **Parameterized Tests**: Use parameterized tests with explicit types to reduce duplication and ensure type safety.
* Avoid mocking the file system, os, or child_process if at all possible; if you have to mock the Mock critical dependencies (`fs`, `os`, `child_process`) do so ONLY at the top of the file.
## React & Ink Architecture
* **Keyboard Handling**: You MUST use `useKeyPress.ts` from Gemini CLI for keyboard handling, NOT the standard ink library. This is critical for supporting slow terminals and multiple events per frame.
* Handle multiple events gracefully (often requires a reducer pattern, see `text-buffer.ts`).
* **State Management**:
* NEVER trigger side effects from within the body of a `setState` callback. Use a reducer or `useRef` if necessary.
* Initialize state explicitly (e.g., use `undefined` rather than `true` if unknown).
* **Performance**:
* Avoid synchronous file I/O in components.
* Do not introduce excessive property drilling; leverage or extend existing providers.
## Configuration & Settings
* **Settings vs Args**: Use settings for user-configurable options; do not add new CLI arguments.
* **Schema**: Add new settings to `packages/cli/src/config/settingsSchema.ts`.
* **Documentation**:
* If `showInDialog: true`, document in `docs/get-started/configuration.md`.
* Ensure `requiresRestart` is correctly set.
## Keyboard Shortcuts
* **Registration**: Define new shortcuts in `packages/cli/src/config/keyBindings.ts`.
* **Documentation**: Document all new shortcuts in `docs/cli/keyboard-shortcuts.md`.
* **Compatibility**: Avoid function keys and common VSCode shortcuts. Be cautious with `Meta` key (Mac-limited support).
## General
* **Logging**: Use `debugLogger` for errors. NEVER leave `console.log/warn/error` in the code.
* **TypeScript**: Avoid the non-null assertion operator (`!`).
!{cat .gemini/commands/strict-development-rules.md}
{{args}}.
"""
+1 -35
View File
@@ -17,41 +17,7 @@ prompts.
In addition to the code context, you MUST strictly adhere to the following frontend-specific development guidelines when writing code in packages/cli.
## Testing Standards
* **Async Testing**: ALWAYS use `waitFor` from `packages/cli/src/test-utils/async.ts` instead of `vi.waitFor` to prevent flakiness and `act` warnings. NEVER use fixed waits (e.g., `await delay(100)`).
* **State Changes**: Wrap all blocks that change component state in `act`.
* **Snapshots**: Use `toMatchSnapshot` to verify rendering.
* **Rendering**: Use `render` or `renderWithProviders` from `packages/cli/src/test-utils/render.tsx` instead of `ink-testing-library` directly.
* **Mocking**:
* Reuse existing mocks/fakes where possible.
* **Parameterized Tests**: Use parameterized tests with explicit types to reduce duplication and ensure type safety.
* Avoid mocking the file system, os, or child_process if at all possible; if you have to mock the Mock critical dependencies (`fs`, `os`, `child_process`) do so ONLY at the top of the file.
## React & Ink Architecture
* **Keyboard Handling**: You MUST use `useKeyPress.ts` from Gemini CLI for keyboard handling, NOT the standard ink library. This is critical for supporting slow terminals and multiple events per frame.
* Handle multiple events gracefully (often requires a reducer pattern, see `text-buffer.ts`).
* **State Management**:
* NEVER trigger side effects from within the body of a `setState` callback. Use a reducer or `useRef` if necessary.
* Initialize state explicitly (e.g., use `undefined` rather than `true` if unknown).
* **Performance**:
* Avoid synchronous file I/O in components.
* Do not introduce excessive property drilling; leverage or extend existing providers.
## Configuration & Settings
* **Settings vs Args**: Use settings for user-configurable options; do not add new CLI arguments.
* **Schema**: Add new settings to `packages/cli/src/config/settingsSchema.ts`.
* **Documentation**:
* If `showInDialog: true`, document in `docs/get-started/configuration.md`.
* Ensure `requiresRestart` is correctly set.
## Keyboard Shortcuts
* **Registration**: Define new shortcuts in `packages/cli/src/config/keyBindings.ts`.
* **Documentation**: Document all new shortcuts in `docs/cli/keyboard-shortcuts.md`.
* **Compatibility**: Avoid function keys and common VSCode shortcuts. Be cautious with `Meta` key (Mac-limited support).
## General
* **Logging**: Use `debugLogger` for errors. NEVER leave `console.log/warn/error` in the code.
* **TypeScript**: Avoid the non-null assertion operator (`!`).
!{cat .gemini/commands/strict-development-rules.md}
{{args}}.
"""
+3 -126
View File
@@ -22,132 +22,9 @@ Follow these steps to conduct a thorough review:
4. Search the codebase if required.
5. Write a concise review of the changes, keeping in mind to encourage strong code quality and best practices. Pay particular attention to the Gemini MD file in the repo.
6. Consider ways the code may not be consistent with existing code in the repo. In particular it is critical that the react code uses patterns consistent with existing code in the repo.
7. Evaluate all tests on the changes and make sure that they are doing the following:
* Using `waitFor` from @{packages/cli/src/test-utils/async.ts} rather than
using `vi.waitFor` for all `waitFor` calls within `packages/cli`. Even if
tests pass, using the wrong `waitFor` could result in flaky tests as `act`
warnings could show up if timing is slightly different.
* Using `act` to wrap all blocks in tests that change component state.
* Using `toMatchSnapshot` to verify that rendering works as expected rather
than matching against the raw content of the output.
* If snapshots were changed as part of the changes, review the snapshots
changes to ensure they are intentional and comment if any look at all
suspicious. Too many snapshot changes that indicate bugs have been approved
in the past.
* Use `render` or `renderWithProviders` from
@{packages/cli/src/test-utils/render.tsx} rather than using `render` from
`ink-testing-library` directly. This is needed to ensure that we do not get
warnings about spurious `act` calls. If test cases specify providers
directly, consider whether the existing `renderWithProviders` should be
modified to support that use case.
* Ensure the test cases are using parameterized tests where that might reduce
the number of duplicated lines significantly.
* NEVER use fixed waits (e.g. 'await delay(100)'). Always use 'waitFor' with
a predicate to ensure tests are stable and fast.
* Ensure mocks are properly managed:
* Critical dependencies (fs, os, child_process) should only be mocked at
the top of the file. Ideally avoid mocking these dependencies altogether.
* Check to see if there are existing mocks or fakes that can be used rather
than creating new ones for the new tests added.
* Try to avoid mocking the file system whenever possible. If using the real
file system is difficult consider whether the test should be an
integration test rather than a unit test.
* `vi.restoreAllMocks()` should be called in `afterEach` to prevent test
pollution.
* Use `vi.useFakeTimers()` for tests involving time-based logic to avoid
flakiness.
* When creating parameterized tests, give the parameters types to ensure
that the tests are type-safe.
8. Evaluate all react logic carefully keeping in mind that the author of the
changes is not likely an expert on React. Key areas to audit carefully are:
* Whether `setState` calls trigger side effects from within the body of the
`setState` callback. If so, you *must* propose an alternate design using
reducers or other ways the code might be modified to not have to modify
state from within a `setState`. Make sure to comment about absolutely
every case like this as these cases have introduced multiple bugs in the
past. Typically these cases should be resolved using a reducer although
occassionally other techniques such as useRef are appropriate. Consider
suggesting that jacob314@ be tagged on the code review if the solution is
not 100% obvious.
* Whether code might introduce an infinite rendering loop in React.
* Whether keyboard handling is robust. Keyboard handling must go through
`useKeyPress.ts` from the Gemini CLI package rather than using the
standard ink library used by most keyboard handling. Unlike the standard
ink library, the keyboard handling library in Gemini CLI may report
multiple keyboard events one after another in the same React frame. This
is needed to support slow terminals but introduces complexity in all our
code that handles keyboard events. Handling this correctly often means
that reducers must be used or other mechanisms to ensure that multiple
state updates one after another are handled gracefully rather than
overriding values from the first update with the second update. Refer to
text-buffer.ts as a canonical example of using a reducer for this sort of
case.
* Ensure code does not use `console.log`, `console.warn`, or `console.error`
as these indicate debug logging that was accidentally left in the code.
* Avoid synchronous file I/O in React components as it will hang the UI.
* Ensure state initialization is explicit (e.g., use 'undefined' rather than
'true' as a default if the state is truly unknown initially).
* Carefully manage 'useEffect' dependencies. Prefer to use a reducer
whenever practical to resolve the issues. If that is not practical it is
ok to use 'useRef' to access the latest value of a prop or state inside an
effect without adding it to the dependency array if re-running the effect
is undesirable (common in event listeners).
* NEVER disable 'react-hooks/exhaustive-deps'. Fix the code to correctly
declare dependencies. Disabling this lint rule will almost always lead to
hard to detect bugs.
* Avoid making types nullable unless strictly necessary, as it hurts
readability.
* Do not introduce excessive property drilling. There are multiple providers
that can be leveraged to avoid property drilling. Make sure one of them
cannot be used. Do suggest a provider that might make sense to be extended
to include the new property or propose a new provider to add if the
property drilling is excessive. Only use providers for properties that are
consistent for the entire application.
9. Evaluate `packages/core` (Services, Tools, Utilities):
* Ensure services are implemented as classes with clear lifecycle management (e.g., `initialize()` methods).
* Verify that `debugLogger` from `packages/core/src/utils/debugLogger.ts` is used 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.
* Check that filesystem errors are handled gracefully using `isNodeError` from `packages/core/src/utils/errors.ts`.
* Verify that new tools are added to `packages/core/src/tools/` and registered in `packages/core/src/tools/tool-registry.ts`.
* Ensure all new public services, utilities, and types are exported from `packages/core/src/index.ts`.
* Check that services are 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.
10. 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 should 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 by using `coreEvents`. If a service is importing another service just to notify it of a change, it should probably be using an event instead.
11. General Gemini CLI design principles:
* Make sure that settings are only used for options that a user might
consider changing.
* Do not add new command line arguments and suggest settings instead.
* New settings must be added 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 for new settings.
* Use 'debugLogger' for rethrown errors to avoid duplicate logging.
* All new keyboard shortcuts MUST be documented in
docs/cli/keyboard-shortcuts.md.
* Ensure new keyboard shortcuts are defined in
packages/cli/src/config/keyBindings.ts.
* If new keyboard shortcuts are added, remind the user to test them in
VSCode, iTerm2, Ghostty, and Windows to ensure they work for all
users.
* Be careful of keybindings that require the meta key as only certain
meta key shortcuts are supported on Mac.
* Be skeptical of function keys and keyboard shortcuts that are commonly
bound in VSCode as they may conflict.
12. 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 and you are confident the value is not null.
* **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. Reject any code that uses 'any' or
'unknown' without narrowing.
13. **Ruthless Cleanup**:
* If you identify significant code duplication, technical debt, or "AI Slop" (boilerplate, redundant comments), explicitly suggest initiating a `ruthless-refactorer` loop to clean it up.
14. Summarize all actionable findings into a concise but comprehensive directive output this to review_findings.md and advance to phase 2.
7. Follow these detailed review rules:
!{cat .gemini/commands/strict-development-rules.md}
8. Summarize all actionable findings into a concise but comprehensive directive output this to review_findings.md and advance to phase 2.
Remember to use the GitHub CLI (`gh`) for all GitHub-related tasks, and local `git` commands if the target is 'staged'.
+4 -113
View File
@@ -18,121 +18,12 @@ Follow these steps:
8. Consider ways the code may not be consistent with existing code in the repo.
In particular it is critical that the react code uses patterns consistent
with existing code in the repo.
9. Evaluate all tests on the PR and make sure that they are doing the following:
* Using `waitFor` from @{packages/cli/src/test-utils/async.ts} rather than
using `vi.waitFor` for all `waitFor` calls within `packages/cli`. Even if
tests pass, using the wrong `waitFor` could result in flaky tests as `act`
warnings could show up if timing is slightly different.
* Using `act` to wrap all blocks in tests that change component state.
* Using `toMatchSnapshot` to verify that rendering works as expected rather
than matching against the raw content of the output.
* If snapshots were changed as part of the pull request, review the snapshots
changes to ensure they are intentional and comment if any look at all
suspicious. Too many snapshot changes that indicate bugs have been approved
in the past.
* Use `render` or `renderWithProviders` from
@{packages/cli/src/test-utils/render.tsx} rather than using `render` from
`ink-testing-library` directly. This is needed to ensure that we do not get
warnings about spurious `act` calls. If test cases specify providers
directly, consider whether the existing `renderWithProviders` should be
modified to support that use case.
* Ensure the test cases are using parameterized tests where that might reduce
the number of duplicated lines significantly.
* NEVER use fixed waits (e.g. 'await delay(100)'). Always use 'waitFor' with
a predicate to ensure tests are stable and fast.
* Ensure mocks are properly managed:
* Critical dependencies (fs, os, child_process) should only be mocked at
the top of the file. Ideally avoid mocking these dependencies altogether.
* Check to see if there are existing mocks or fakes that can be used rather
than creating new ones for the new tests added.
* Try to avoid mocking the file system whenever possible. If using the real
file system is difficult consider whether the test should be an
integration test rather than a unit test.
* `vi.restoreAllMocks()` should be called in `afterEach` to prevent test
pollution.
* Use `vi.useFakeTimers()` for tests involving time-based logic to avoid
flakiness.
* Avoid using `any` in tests; prefer proper types or `unknown` with
narrowing.
* When creating parameterized tests, give the parameters types to ensure
that the tests are type-safe.
10. Evaluate all react logic carefully keeping in mind that the author of the PR
is not likely an expert on React. Key areas to audit carefully are:
* Whether `setState` calls trigger side effects from within the body of the
`setState` callback. If so, you *must* propose an alternate design using
reducers or other ways the code might be modified to not have to modify
state from within a `setState`. Make sure to comment about absolutely
every case like this as these cases have introduced multiple bugs in the
past. Typically these cases should be resolved using a reducer although
occassionally other techniques such as useRef are appropriate. Consider
suggesting that jacob314@ be tagged on the code review if the solution is
not 100% obvious.
* Whether code might introduce an infinite rendering loop in React.
* Whether keyboard handling is robust. Keyboard handling must go through
`useKeyPress.ts` from the Gemini CLI package rather than using the
standard ink library used by most keyboard handling. Unlike the standard
ink library, the keyboard handling library in Gemini CLI may report
multiple keyboard events one after another in the same React frame. This
is needed to support slow terminals but introduces complexity in all our
code that handles keyboard events. Handling this correctly often means
that reducers must be used or other mechanisms to ensure that multiple
state updates one after another are handled gracefully rather than
overriding values from the first update with the second update. Refer to
text-buffer.ts as a canonical example of using a reducer for this sort of
case.
* Ensure code does not use `console.log`, `console.warn`, or `console.error`
as these indicate debug logging that was accidentally left in the code.
* Avoid synchronous file I/O in React components as it will hang the UI.
* Ensure state initialization is explicit (e.g., use 'undefined' rather than
'true' as a default if the state is truly unknown initially).
* Carefully manage 'useEffect' dependencies. Prefer to use a reducer
whenever practical to resolve the issues. If that is not practical it is
ok to use 'useRef' to access the latest value of a prop or state inside an
effect without adding it to the dependency array if re-running the effect
is undesirable (common in event listeners).
* NEVER disable 'react-hooks/exhaustive-deps'. Fix the code to correctly
declare dependencies. Disabling this lint rule will almost always lead to
hard to detect bugs.
* Avoid making types nullable unless strictly necessary, as it hurts
readability.
* Do not introduce excessive property drilling. There are multiple providers
that can be leveraged to avoid property drilling. Make sure one of them
cannot be used. Do suggest a provider that might make sense to be extended
to include the new property or propose a new provider to add if the
property drilling is excessive. Only use providers for properties that are
consistent for the entire application.
11. General Gemini CLI design principles:
* Make sure that settings are only used for options that a user might
consider changing.
* Do not add new command line arguments and suggest settings instead.
* New settings must be added 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 for new settings.
* Use 'debugLogger' for rethrown errors to avoid duplicate logging.
* All new keyboard shortcuts MUST be documented in
docs/cli/keyboard-shortcuts.md.
* Ensure new keyboard shortcuts are defined in
packages/cli/src/config/keyBindings.ts.
* If new keyboard shortcuts are added, remind the user to test them in
VSCode, iTerm2, Ghostty, and Windows to ensure they work for all
users.
* Be careful of keybindings that require the meta key as only certain
meta key shortcuts are supported on Mac.
* Be skeptical of function keys and keyboard shortcuts that are commonly
bound in VSCode as they may conflict.
12. 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 and you are confident the value is not null.
13. If the change might at all impact the prompts sent to Gemini CLI, flagged
that the change could impact Gemini CLI quality and make sure anj-s has been
tagged on the code review.
14. Discuss with me before making any comments on the issue. I will clarify
9. Follow these detailed review rules:
!{cat .gemini/commands/strict-development-rules.md}
10. Discuss with me before making any comments on the issue. I will clarify
which possible issues you identified are problems, which ones you need to
investigate further, and which ones I do not care about.
15. If I request you to add comments to the issue, use
11. If I request you to add comments to the issue, use
`gh pr comment {{args}} --body {{review}}` to post the review to the PR.
Remember to use the GitHub CLI (`gh`) with the Shell tool for all
@@ -0,0 +1,64 @@
# Gemini CLI Strict Development Rules
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.
## 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`.
## 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`.
## 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.
## 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.
## 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.
## 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.
## 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.
+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
+4 -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
@@ -80,5 +81,6 @@ jobs:
Please review and merge.
branch: 'changelog-${{ steps.release_info.outputs.VERSION }}'
base: 'main'
team-reviewers: 'gemini-cli-docs, gemini-cli-maintainers'
delete-branch: true
+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
+1 -1
View File
@@ -483,7 +483,7 @@ avoid collecting potentially sensitive information from user prompts.
You can enforce a specific authentication method for all users by setting the
`enforcedAuthType` in the system-level `settings.json` file. This prevents users
from choosing a different authentication method. See the
[Authentication docs](./authentication.md) for more details.
[Authentication docs](../get-started/authentication.md) for more details.
**Example:** Enforce the use of Google login for all users.
+28 -27
View File
@@ -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)` |
@@ -96,31 +96,31 @@ available combinations.
#### App Controls
| Action | Keys |
| ----------------------------------------------------------------------------------------------------- | ---------------- |
| Toggle detailed error information. | `F12` |
| Toggle the full TODO list. | `Ctrl + T` |
| Show IDE context details. | `Ctrl + G` |
| Toggle Markdown rendering. | `Alt + M` |
| Toggle copy mode when in alternate buffer mode. | `Ctrl + S` |
| Toggle YOLO (auto-approval) mode for tool calls. | `Ctrl + Y` |
| Cycle through approval modes: default (prompt), auto_edit (auto-approve edits), and plan (read-only). | `Shift + Tab` |
| Expand and collapse blocks of content when not in alternate buffer mode. | `Ctrl + O` |
| Expand or collapse a paste placeholder when cursor is over placeholder. | `Ctrl + O` |
| Toggle current background shell visibility. | `Ctrl + B` |
| Toggle background shell list. | `Ctrl + L` |
| Kill the active background shell. | `Ctrl + K` |
| Confirm selection in background shell list. | `Enter` |
| Dismiss background shell list. | `Esc` |
| Move focus from background shell to Gemini. | `Shift + Tab` |
| Move focus from background shell list to Gemini. | `Tab (no Shift)` |
| Show warning when trying to move focus away from background shell. | `Tab (no Shift)` |
| Show warning when trying to move focus away from shell input. | `Tab (no Shift)` |
| Move focus from Gemini to the active shell. | `Tab (no Shift)` |
| Move focus from the shell back to Gemini. | `Shift + Tab` |
| Clear the terminal screen and redraw the UI. | `Ctrl + L` |
| Restart the application. | `R` |
| Suspend the CLI and move it to the background. | `Ctrl + Z` |
| Action | Keys |
| -------------------------------------------------------------------------------------------------------------------------------------------------- | ---------------- |
| Toggle detailed error information. | `F12` |
| Toggle the full TODO list. | `Ctrl + T` |
| Show IDE context details. | `Ctrl + G` |
| Toggle Markdown rendering. | `Alt + M` |
| Toggle copy mode when in alternate buffer mode. | `Ctrl + S` |
| Toggle YOLO (auto-approval) mode for tool calls. | `Ctrl + Y` |
| Cycle through approval modes: default (prompt), auto_edit (auto-approve edits), and plan (read-only). Plan mode is skipped when the agent is busy. | `Shift + Tab` |
| Expand and collapse blocks of content when not in alternate buffer mode. | `Ctrl + O` |
| Expand or collapse a paste placeholder when cursor is over placeholder. | `Ctrl + O` |
| Toggle current background shell visibility. | `Ctrl + B` |
| Toggle background shell list. | `Ctrl + L` |
| Kill the active background shell. | `Ctrl + K` |
| Confirm selection in background shell list. | `Enter` |
| Dismiss background shell list. | `Esc` |
| Move focus from background shell to Gemini. | `Shift + Tab` |
| Move focus from background shell list to Gemini. | `Tab (no Shift)` |
| Show warning when trying to move focus away from background shell. | `Tab (no Shift)` |
| Show warning when trying to move focus away from shell input. | `Tab (no Shift)` |
| Move focus from Gemini to the active shell. | `Tab (no Shift)` |
| Move focus from the shell back to Gemini. | `Shift + Tab` |
| Clear the terminal screen and redraw the UI. | `Ctrl + L` |
| Restart the application. | `R` |
| Suspend the CLI and move it to the background. | `Ctrl + Z` |
<!-- KEYBINDINGS-AUTOGEN:END -->
@@ -138,7 +138,8 @@ available combinations.
details when no completion/search interaction is active. The selected mode is
remembered for future sessions. Full UI remains the default on first run, and
single `Tab` keeps its existing completion/focus behavior.
- `Shift + Tab` (while typing in the prompt): Cycle approval modes.
- `Shift + Tab` (while typing in the prompt): Cycle approval modes: default,
auto-edit, and plan (skipped when agent is busy).
- `\` (at end of a line) + `Enter`: Insert a newline without leaving single-line
mode.
- `Esc` pressed twice quickly: Clear the input prompt if it is not empty,
+5 -1
View File
@@ -61,7 +61,11 @@ Other ways to start in Plan Mode:
You can enter Plan Mode in three ways:
1. **Keyboard Shortcut:** Press `Shift+Tab` to cycle through approval modes
(`Default` -> `Plan` -> `Auto-Edit`).
(`Default` -> `Auto-Edit` -> `Plan`).
> **Note:** Plan Mode is automatically removed from the rotation when the
> agent is actively processing or showing confirmation dialogs.
2. **Command:** Type `/plan` in the input box.
3. **Natural Language:** Ask the agent to "start a plan for...". The agent will
then call the [`enter_plan_mode`] tool to switch modes.
+82 -48
View File
@@ -16,12 +16,14 @@ using the `/theme` command within Gemini CLI:
- `Default`
- `Dracula`
- `GitHub`
- `Solarized Dark`
- **Light themes:**
- `ANSI Light`
- `Ayu Light`
- `Default Light`
- `GitHub Light`
- `Google Code`
- `Solarized Light`
- `Xcode`
### Changing themes
@@ -54,7 +56,7 @@ used in the CLI.
Add a `customThemes` block to your user, project, or system `settings.json`
file. Each custom theme is defined as an object with a unique name and a set of
color keys. For example:
nested configuration objects. For example:
```json
{
@@ -63,50 +65,52 @@ color keys. For example:
"MyCustomTheme": {
"name": "MyCustomTheme",
"type": "custom",
"Background": "#181818",
...
"background": {
"primary": "#181818"
},
"text": {
"primary": "#f0f0f0",
"secondary": "#a0a0a0"
}
}
}
}
}
```
**Color keys:**
**Configuration objects:**
- `Background`
- `Foreground`
- `LightBlue`
- `AccentBlue`
- `AccentPurple`
- `AccentCyan`
- `AccentGreen`
- `AccentYellow`
- `AccentRed`
- `Comment`
- `Gray`
- `DiffAdded` (optional, for added lines in diffs)
- `DiffRemoved` (optional, for removed lines in diffs)
You can also override individual UI text roles by adding a nested `text` object.
This object supports the keys `primary`, `secondary`, `link`, `accent`, and
`response`. When `text.response` is provided it takes precedence over
`text.primary` for rendering model responses in chat.
- **`text`**: Defines text colors.
- `primary`: The default text color.
- `secondary`: Used for less prominent text.
- `link`: Color for URLs and links.
- `accent`: Used for highlights and emphasis.
- `response`: Precedence over `primary` for rendering model responses.
- **`background`**: Defines background colors.
- `primary`: The main background color of the UI.
- `diff.added`: Background for added lines in diffs.
- `diff.removed`: Background for removed lines in diffs.
- **`border`**: Defines border colors.
- `default`: The standard border color.
- `focused`: Border color when an element is focused.
- **`status`**: Colors for status indicators.
- `success`: Used for successful operations.
- `warning`: Used for warnings.
- `error`: Used for errors.
- **`ui`**: Other UI elements.
- `comment`: Color for code comments.
- `symbol`: Color for code symbols and operators.
- `gradient`: An array of colors used for gradient effects.
**Required properties:**
- `name` (must match the key in the `customThemes` object and be a string)
- `type` (must be the string `"custom"`)
- `Background`
- `Foreground`
- `LightBlue`
- `AccentBlue`
- `AccentPurple`
- `AccentCyan`
- `AccentGreen`
- `AccentYellow`
- `AccentRed`
- `Comment`
- `Gray`
While all sub-properties are technically optional, we recommend providing at
least `background.primary`, `text.primary`, `text.secondary`, and the various
accent colors via `text.link`, `text.accent`, and `status` to ensure a cohesive
UI.
You can use either hex codes (e.g., `#FF0000`) **or** standard CSS color names
(e.g., `coral`, `teal`, `blue`) for any color value. See
@@ -141,22 +145,35 @@ custom theme defined in `settings.json`.
```json
{
"name": "My File Theme",
"name": "Gruvbox Dark",
"type": "custom",
"Background": "#282A36",
"Foreground": "#F8F8F2",
"LightBlue": "#82AAFF",
"AccentBlue": "#61AFEF",
"AccentPurple": "#BD93F9",
"AccentCyan": "#8BE9FD",
"AccentGreen": "#50FA7B",
"AccentYellow": "#F1FA8C",
"AccentRed": "#FF5555",
"Comment": "#6272A4",
"Gray": "#ABB2BF",
"DiffAdded": "#A6E3A1",
"DiffRemoved": "#F38BA8",
"GradientColors": ["#4796E4", "#847ACE", "#C3677F"]
"background": {
"primary": "#282828",
"diff": {
"added": "#2b3312",
"removed": "#341212"
}
},
"text": {
"primary": "#ebdbb2",
"secondary": "#a89984",
"link": "#83a598",
"accent": "#d3869b"
},
"border": {
"default": "#3c3836",
"focused": "#458588"
},
"status": {
"success": "#b8bb26",
"warning": "#fabd2f",
"error": "#fb4934"
},
"ui": {
"comment": "#928374",
"symbol": "#8ec07c",
"gradient": ["#cc241d", "#d65d0e", "#d79921"]
}
}
```
@@ -180,6 +197,15 @@ untrusted sources.
same [configuration precedence](../get-started/configuration.md) as other
settings.
### Themes from extensions
[Extensions](../extensions/reference.md#themes) can also provide custom themes.
Once an extension is installed and enabled, its themes are automatically added
to the selection list in the `/theme` command.
Themes from extensions appear with the extension name in parentheses to help you
identify their source, for example: `shades-of-green (green-extension)`.
---
## Dark themes
@@ -208,6 +234,10 @@ untrusted sources.
<img src="/assets/theme-github.png" alt="GitHub theme" width="600">
### Solarized Dark
<img src="/assets/theme-solarized-dark.png" alt="Solarized Dark theme" width="600">
## Light themes
### ANSI Light
@@ -230,6 +260,10 @@ untrusted sources.
<img src="/assets/theme-google-light.png" alt="Google Code theme" width="600">
### Solarized Light
<img src="/assets/theme-solarized-light.png" alt="Solarized Light theme" width="600">
### Xcode
<img src="/assets/theme-xcode-light.png" alt="Xcode Light theme" width="600">
+102 -53
View File
@@ -1,19 +1,19 @@
# Extensions on Gemini CLI: Best practices
# Gemini CLI extension best practices
This guide covers best practices for developing, securing, and maintaining
Gemini CLI extensions.
## Development
Developing extensions for Gemini CLI is intended to be a lightweight, iterative
process.
Developing extensions for Gemini CLI is a lightweight, iterative process. Use
these strategies to build robust and efficient extensions.
### Structure your extension
While simple extensions can just be a few files, we recommend a robust structure
for complex extensions:
While simple extensions may contain only a few files, we recommend a organized
structure for complex projects.
```
```text
my-extension/
├── package.json
├── tsconfig.json
@@ -24,47 +24,50 @@ my-extension/
└── dist/
```
- **Use TypeScript**: We strongly recommend using TypeScript for type safety and
better tooling.
- **Separate source and build**: Keep your source code in `src` and build to
`dist`.
- **Bundle dependencies**: If your extension has many dependencies, consider
bundling them (e.g., with `esbuild` or `webpack`) to reduce install time and
potential conflicts.
- **Use TypeScript:** We strongly recommend using TypeScript for type safety and
improved developer experience.
- **Separate source and build:** Keep your source code in `src/` and output
build artifacts to `dist/`.
- **Bundle dependencies:** If your extension has many dependencies, bundle them
using a tool like `esbuild` to reduce installation time and avoid conflicts.
### Iterate with `link`
Use `gemini extensions link` to develop locally without constantly reinstalling:
Use the `gemini extensions link` command to develop locally without reinstalling
your extension after every change.
```bash
cd my-extension
gemini extensions link .
```
Changes to your code (after rebuilding) will be immediately available in the CLI
on restart.
Changes to your code are immediately available in the CLI after you rebuild the
project and restart the session.
### Use `GEMINI.md` effectively
Your `GEMINI.md` file provides context to the model. Keep it focused:
Your `GEMINI.md` file provides essential context to the model.
- **Do:** Explain high-level goals and how to use the provided tools.
- **Don't:** Dump your entire documentation.
- **Do:** Use clear, concise language.
- **Focus on goals:** Explain the high-level purpose of the extension and how to
interact with its tools.
- **Be concise:** Avoid dumping exhaustive documentation into the file. Use
clear, direct language.
- **Provide examples:** Include brief examples of how the model should use
specific tools or commands.
## Security
When building a Gemini CLI extension, follow general security best practices
(such as least privilege and input validation) to reduce risk.
Follow the principle of least privilege and rigorous input validation when
building extensions.
### Minimal permissions
When defining tools in your MCP server, only request the permissions necessary.
Avoid giving the model broad access (like full shell access) if a more
restricted set of tools will suffice.
Only request the permissions your MCP server needs to function. Avoid giving the
model broad access (such as full shell access) if restricted tools are
sufficient.
If you must use powerful tools like `run_shell_command`, consider restricting
them to specific commands in your `gemini-extension.json`:
If your extension uses powerful tools like `run_shell_command`, restrict them in
your `gemini-extension.json` file:
```json
{
@@ -73,27 +76,26 @@ them to specific commands in your `gemini-extension.json`:
}
```
This ensures that even if the model tries to execute a dangerous command, it
will be blocked at the CLI level.
This ensures the CLI blocks dangerous commands even if the model attempts to
execute them.
### Validate inputs
Your MCP server is running on the user's machine. Always validate inputs to your
tools to prevent arbitrary code execution or filesystem access outside the
intended scope.
Your MCP server runs on the user's machine. Always validate tool inputs to
prevent arbitrary code execution or unauthorized filesystem access.
```typescript
// Good: Validating paths
// Example: Validating paths
if (!path.resolve(inputPath).startsWith(path.resolve(allowedDir) + path.sep)) {
throw new Error('Access denied');
}
```
### Sensitive settings
### Secure sensitive settings
If your extension requires API keys, use the `sensitive: true` option in
`gemini-extension.json`. This ensures keys are stored securely in the system
keychain and obfuscated in the UI.
If your extension requires API keys or other secrets, use the `sensitive: true`
option in your manifest. This ensures keys are stored in the system keychain and
obfuscated in the CLI output.
```json
"settings": [
@@ -105,35 +107,82 @@ keychain and obfuscated in the UI.
]
```
## Releasing
## Release
You can upload your extension directly to GitHub to list it in the gallery.
Gemini CLI extensions also offers support for more complicated
[releases](releasing.md).
Follow standard versioning and release practices to ensure a smooth experience
for your users.
### Semantic versioning
Follow [Semantic Versioning](https://semver.org/).
Follow [Semantic Versioning (SemVer)](https://semver.org/) to communicate
changes clearly.
- **Major**: Breaking changes (renaming tools, changing arguments).
- **Minor**: New features (new tools, commands).
- **Patch**: Bug fixes.
- **Major:** Breaking changes (e.g., renaming tools or changing arguments).
- **Minor:** New features (e.g., adding new tools or commands).
- **Patch:** Bug fixes and performance improvements.
### Release Channels
### Release channels
Use git branches to manage release channels (e.g., `main` for stable, `dev` for
bleeding edge). This allows users to choose their stability level:
Use Git branches to manage release channels. This lets users choose between
stability and the latest features.
```bash
# Stable
# Install the stable version (default branch)
gemini extensions install github.com/user/repo
# Dev
# Install the development version
gemini extensions install github.com/user/repo --ref dev
```
### Clean artifacts
If you are using GitHub Releases, ensure your release artifacts only contain the
necessary files (`dist/`, `gemini-extension.json`, `package.json`). Exclude
`node_modules` (users will install them) and `src/` to keep downloads small.
When using GitHub Releases, ensure your archives only contain necessary files
(such as `dist/`, `gemini-extension.json`, and `package.json`). Exclude
`node_modules/` and `src/` to minimize download size.
## Test and verify
Test your extension thoroughly before releasing it to users.
- **Manual verification:** Use `gemini extensions link` to test your extension
in a live CLI session. Verify that tools appear in the debug console (F12) and
that custom commands resolve correctly.
- **Automated testing:** If your extension includes an MCP server, write unit
tests for your tool logic using a framework like Vitest or Jest. You can test
MCP tools in isolation by mocking the transport layer.
## Troubleshooting
Use these tips to diagnose and fix common extension issues.
### Extension not loading
If your extension doesn't appear in `/extensions list`:
- **Check the manifest:** Ensure `gemini-extension.json` is in the root
directory and contains valid JSON.
- **Verify the name:** The `name` field in the manifest must match the extension
directory name exactly.
- **Restart the CLI:** Extensions are loaded at the start of a session. Restart
Gemini CLI after making changes to the manifest or linking a new extension.
### MCP server failures
If your tools aren't working as expected:
- **Check the logs:** View the CLI logs to see if the MCP server failed to
start.
- **Test the command:** Run the server's `command` and `args` directly in your
terminal to ensure it starts correctly outside of Gemini CLI.
- **Debug console:** In interactive mode, press **F12** to open the debug
console and inspect tool calls and responses.
### Command conflicts
If a custom command isn't responding:
- **Check precedence:** Remember that user and project commands take precedence
over extension commands. Use the prefixed name (e.g., `/extension.command`) to
verify the extension's version.
- **Help command:** Run `/help` to see a list of all available commands and
their sources.
+37 -21
View File
@@ -1,24 +1,49 @@
# Gemini CLI extensions
Gemini CLI extensions package prompts, MCP servers, custom commands, hooks,
sub-agents, and agent skills into a familiar and user-friendly format. With
extensions, you can expand the capabilities of Gemini CLI and share those
Gemini CLI extensions package prompts, MCP servers, custom commands, themes,
hooks, sub-agents, and agent skills into a familiar and user-friendly format.
With extensions, you can expand the capabilities of Gemini CLI and share those
capabilities with others. They are designed to be easily installable and
shareable.
To see examples of extensions, you can browse a gallery of
[Gemini CLI extensions](https://geminicli.com/extensions/browse/).
To see what's possible, browse the
[Gemini CLI extension gallery](https://geminicli.com/extensions/browse/).
## Managing extensions
## Choose your path
You can verify your installed extensions and their status using the interactive
command:
Choose the guide that best fits your needs.
### I want to use extensions
Learn how to discover, install, and manage extensions to enhance your Gemini CLI
experience.
- **[Manage extensions](#manage-extensions):** List and verify your installed
extensions.
- **[Install extensions](#installation):** Add new capabilities from GitHub or
local paths.
### I want to build extensions
Learn how to create, test, and share your own extensions with the community.
- **[Build extensions](writing-extensions.md):** Create your first extension
from a template.
- **[Best practices](best-practices.md):** Learn how to build secure and
reliable extensions.
- **[Publish to the gallery](releasing.md):** Share your work with the world.
## Manage extensions
Use the interactive `/extensions` command to verify your installed extensions
and their status:
```bash
/extensions list
```
or in noninteractive mode:
You can also manage extensions from your terminal using the `gemini extensions`
command group:
```bash
gemini extensions list
@@ -26,20 +51,11 @@ gemini extensions list
## Installation
To install a real extension, you can use the `extensions install` command with a
GitHub repository URL in noninteractive mode. For example:
Install an extension by providing its GitHub repository URL. For example:
```bash
gemini extensions install https://github.com/gemini-cli-extensions/workspace
```
## Next steps
- [Writing extensions](writing-extensions.md): Learn how to create your first
extension.
- [Extensions reference](reference.md): Deeply understand the extension format,
commands, and configuration.
- [Best practices](best-practices.md): Learn strategies for building great
extensions.
- [Extensions releasing](releasing.md): Learn how to share your extensions with
the world.
For more advanced installation options, see the
[Extension reference](reference.md#install-an-extension).
+145 -213
View File
@@ -1,134 +1,113 @@
# Extensions reference
# Extension reference
This guide covers the `gemini extensions` commands and the structure of the
`gemini-extension.json` configuration file.
## Extension management
## Manage extensions
We offer a suite of extension management tools using `gemini extensions`
commands.
Use the `gemini extensions` command group to manage your extensions from the
terminal.
Note that these commands (e.g. `gemini extensions install`) are not supported
from within the CLI's **interactive mode**, although you can list installed
extensions using the `/extensions list` slash command.
Note that commands like `gemini extensions install` are not supported within the
CLI's interactive mode. However, you can use the `/extensions list` command to
view installed extensions. All management operations, including updates to slash
commands, take effect only after you restart the CLI session.
Note that all of these management operations (including updates to slash
commands) will only be reflected in active CLI sessions on **restart**.
### Install an extension
### Installing an extension
Install an extension by providing its GitHub repository URL or a local file
path.
You can install an extension using `gemini extensions install` with either a
GitHub URL or a local path.
Gemini CLI creates a copy of the extension during installation. You must run
`gemini extensions update` to pull changes from the source. To install from
GitHub, you must have `git` installed on your machine.
Note that we create a copy of the installed extension, so you will need to run
`gemini extensions update` to pull in changes from both locally-defined
extensions and those on GitHub.
NOTE: If you are installing an extension from GitHub, you'll need to have `git`
installed on your machine. See
[git installation instructions](https://git-scm.com/book/en/v2/Getting-Started-Installing-Git)
for help.
```
```bash
gemini extensions install <source> [--ref <ref>] [--auto-update] [--pre-release] [--consent]
```
- `<source>`: The github URL or local path of the extension to install.
- `--ref`: The git ref to install from.
- `--auto-update`: Enable auto-update for this extension.
- `--pre-release`: Enable pre-release versions for this extension.
- `--consent`: Acknowledge the security risks of installing an extension and
skip the confirmation prompt.
- `<source>`: The GitHub URL or local path of the extension.
- `--ref`: The git ref (branch, tag, or commit) to install.
- `--auto-update`: Enable automatic updates for this extension.
- `--pre-release`: Enable installation of pre-release versions.
- `--consent`: Acknowledge security risks and skip the confirmation prompt.
### Uninstalling an extension
### Uninstall an extension
To uninstall one or more extensions, run
`gemini extensions uninstall <name...>`:
To uninstall one or more extensions, use the `uninstall` command:
```
gemini extensions uninstall gemini-cli-security gemini-cli-another-extension
```bash
gemini extensions uninstall <name...>
```
### Disabling an extension
### Disable an extension
Extensions are, by default, enabled across all workspaces. You can disable an
extension entirely or for specific workspace.
Extensions are enabled globally by default. You can disable an extension
entirely or for a specific workspace.
```
```bash
gemini extensions disable <name> [--scope <scope>]
```
- `<name>`: The name of the extension to disable.
- `--scope`: The scope to disable the extension in (`user` or `workspace`).
### Enabling an extension
### Enable an extension
You can enable extensions using `gemini extensions enable <name>`. You can also
enable an extension for a specific workspace using
`gemini extensions enable <name> --scope=workspace` from within that workspace.
Re-enable a disabled extension using the `enable` command:
```
```bash
gemini extensions enable <name> [--scope <scope>]
```
- `<name>`: The name of the extension to enable.
- `--scope`: The scope to enable the extension in (`user` or `workspace`).
### Updating an extension
### Update an extension
For extensions installed from a local path or a git repository, you can
explicitly update to the latest version (as reflected in the
`gemini-extension.json` `version` field) with `gemini extensions update <name>`.
You can update all extensions with:
Update an extension to the version specified in its `gemini-extension.json`
file.
```bash
gemini extensions update <name>
```
To update all installed extensions at once:
```bash
gemini extensions update --all
```
### Create a boilerplate extension
### Create an extension from a template
We offer several example extensions `context`, `custom-commands`,
`exclude-tools` and `mcp-server`. You can view these examples
[here](https://github.com/google-gemini/gemini-cli/tree/main/packages/cli/src/commands/extensions/examples).
Create a new extension directory using a built-in template.
To copy one of these examples into a development directory using the type of
your choosing, run:
```
```bash
gemini extensions new <path> [template]
```
- `<path>`: The path to create the extension in.
- `[template]`: The boilerplate template to use.
- `<path>`: The directory to create.
- `[template]`: The template to use (e.g., `mcp-server`, `context`,
`custom-commands`).
### Link a local extension
The `gemini extensions link` command will create a symbolic link from the
extension installation directory to the development path.
Create a symbolic link between your development directory and the Gemini CLI
extensions directory. This lets you test changes immediately without
reinstalling.
This is useful so you don't have to run `gemini extensions update` every time
you make changes you'd like to test.
```
```bash
gemini extensions link <path>
```
- `<path>`: The path of the extension to link.
## Extension format
On startup, Gemini CLI looks for extensions in `<home>/.gemini/extensions`
Extensions exist as a directory that contains a `gemini-extension.json` file.
For example:
`<home>/.gemini/extensions/my-extension/gemini-extension.json`
Gemini CLI loads extensions from `<home>/.gemini/extensions`. Each extension
must have a `gemini-extension.json` file in its root directory.
### `gemini-extension.json`
The `gemini-extension.json` file contains the configuration for the extension.
The file has the following structure:
The manifest file defines the extension's behavior and configuration.
```json
{
@@ -145,53 +124,27 @@ The file has the following structure:
}
```
- `name`: The name of the extension. This is used to uniquely identify the
extension and for conflict resolution when extension commands have the same
name as user or project commands. The name should be lowercase or numbers and
use dashes instead of underscores or spaces. This is how users will refer to
your extension in the CLI. Note that we expect this name to match the
extension directory name.
- `version`: The version of the extension.
- `description`: A short description of the extension. This will be displayed on
[geminicli.com/extensions](https://geminicli.com/extensions).
- `mcpServers`: A map of MCP servers to settings. The key is the name of the
server, and the value is the server configuration. These servers will be
loaded on startup just like MCP servers settingsd in a
[`settings.json` file](../get-started/configuration.md). If both an extension
and a `settings.json` file settings an MCP server with the same name, the
server defined in the `settings.json` file takes precedence.
- Note that all MCP server configuration options are supported except for
`trust`.
- `contextFileName`: The name of the file that contains the context for the
extension. This will be used to load the context from the extension directory.
If this property is not used but a `GEMINI.md` file is present in your
extension directory, then that file will be loaded.
- `excludeTools`: An array of tool names to exclude from the model. You can also
specify command-specific restrictions for tools that support it, like the
`run_shell_command` tool. For example,
`"excludeTools": ["run_shell_command(rm -rf)"]` will block the `rm -rf`
command. Note that this differs from the MCP server `excludeTools`
functionality, which can be listed in the MCP server config.
- `name`: A unique identifier for the extension. Use lowercase letters, numbers,
and dashes. This name must match the extension's directory name.
- `version`: The current version of the extension.
- `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](../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
specific arguments, such as `run_shell_command(rm -rf)`.
- `themes`: An optional list of themes provided by the extension. See
[Themes](../cli/themes.md) for more information.
When Gemini CLI starts, it loads all the extensions and merges their
configurations. If there are any conflicts, the workspace configuration takes
precedence.
### Extension settings
### Settings
Extensions can define settings that users provide during installation, such as
API keys or URLs. These values are stored in a `.env` file within the extension
directory.
Extensions can define settings that the user will be prompted to provide upon
installation. This is useful for things like API keys, URLs, or other
configuration that the extension needs to function.
To define settings, add a `settings` array to your `gemini-extension.json` file.
Each object in the array should have the following properties:
- `name`: A user-friendly name for the setting.
- `description`: A description of the setting and what it's used for.
- `envVar`: The name of the environment variable that the setting will be stored
as.
- `sensitive`: Optional boolean. If true, obfuscates the input the user provides
and stores the secret in keychain storage. **Example**
To define settings, add a `settings` array to your manifest:
```json
{
@@ -201,133 +154,112 @@ Each object in the array should have the following properties:
{
"name": "API Key",
"description": "Your API key for the service.",
"envVar": "MY_API_KEY"
"envVar": "MY_API_KEY",
"sensitive": true
}
]
}
```
When a user installs this extension, they will be prompted to enter their API
key. The value will be saved to a `.env` file in the extension's directory
(e.g., `<home>/.gemini/extensions/my-api-extension/.env`).
- `name`: The setting's display name.
- `description`: A clear explanation of the setting.
- `envVar`: The environment variable name where the value is stored.
- `sensitive`: If `true`, the value is stored in the system keychain and
obfuscated in the UI.
You can view a list of an extension's settings by running:
To update an extension's settings:
```bash
gemini extensions config <name> [setting] [--scope <scope>]
```
gemini extensions list
```
and you can update a given setting using:
```
gemini extensions config <extension name> [setting name] [--scope <scope>]
```
- `--scope`: The scope to set the setting in (`user` or `workspace`). This is
optional and will default to `user`.
### Custom commands
Extensions can provide [custom commands](../cli/custom-commands.md) by placing
TOML files in a `commands/` subdirectory within the extension directory. These
commands follow the same format as user and project custom commands and use
standard naming conventions.
Provide [custom commands](../cli/custom-commands.md) by placing TOML files in a
`commands/` subdirectory. Gemini CLI uses the directory structure to determine
the command name.
**Example**
For an extension named `gcp`:
An extension named `gcp` with the following structure:
```
.gemini/extensions/gcp/
├── gemini-extension.json
└── commands/
├── deploy.toml
└── gcs/
└── sync.toml
```
Would provide these commands:
- `/deploy` - Shows as `[gcp] Custom command from deploy.toml` in help
- `/gcs:sync` - Shows as `[gcp] Custom command from sync.toml` in help
- `commands/deploy.toml` becomes `/deploy`
- `commands/gcs/sync.toml` becomes `/gcs:sync` (namespaced with a colon)
### Hooks
Extensions can provide [hooks](../hooks/index.md) to intercept and customize
Gemini CLI behavior at specific lifecycle events. Hooks provided by an extension
must be defined in a `hooks/hooks.json` file within the extension directory.
Intercept and customize CLI behavior using [hooks](../hooks/index.md). Define
hooks in a `hooks/hooks.json` file within your extension directory. Note that
hooks are not defined in the `gemini-extension.json` manifest.
> [!IMPORTANT] Hooks are not defined directly in `gemini-extension.json`. The
> CLI specifically looks for the `hooks/hooks.json` file.
### Agent skills
### Agent Skills
Extensions can bundle [Agent Skills](../cli/skills.md) to provide specialized
workflows. Skills must be placed in a `skills/` directory within the extension.
**Example**
An extension with the following structure:
```
.gemini/extensions/my-extension/
├── gemini-extension.json
└── skills/
└── security-audit/
└── SKILL.md
```
Will expose a `security-audit` skill that the model can activate.
Bundle [agent skills](../cli/skills.md) to provide specialized workflows. Place
skill definitions in a `skills/` directory. For example,
`skills/security-audit/SKILL.md` exposes a `security-audit` skill.
### Sub-agents
> **Note: Sub-agents are currently an experimental feature.**
> **Note:** Sub-agents are a preview feature currently under active development.
Extensions can provide [sub-agents](../core/subagents.md) that users can
delegate tasks to.
Provide [sub-agents](../core/subagents.md) that users can delegate tasks to. Add
agent definition files (`.md`) to an `agents/` directory in your extension root.
To bundle sub-agents with your extension, create an `agents/` directory in your
extension's root folder and add your agent definition files (`.md`) there.
### Themes
Extensions can provide custom themes to personalize the CLI UI. Themes are
defined in the `themes` array in `gemini-extension.json`.
**Example**
```
.gemini/extensions/my-extension/
├── gemini-extension.json
└── agents/
├── security-auditor.md
└── database-expert.md
```json
{
"name": "my-green-extension",
"version": "1.0.0",
"themes": [
{
"name": "shades-of-green",
"type": "custom",
"background": {
"primary": "#1a362a"
},
"text": {
"primary": "#a6e3a1",
"secondary": "#6e8e7a",
"link": "#89e689"
},
"status": {
"success": "#76c076",
"warning": "#d9e689",
"error": "#b34e4e"
},
"border": {
"default": "#4a6c5a"
},
"ui": {
"comment": "#6e8e7a"
}
}
]
}
```
Gemini CLI will automatically discover and load these agents when the extension
is installed and enabled.
Custom themes provided by extensions can be selected using the `/theme` command
or by setting the `ui.theme` property in your `settings.json` file. Note that
when referring to a theme from an extension, the extension name is appended to
the theme name in parentheses, e.g., `shades-of-green (my-green-extension)`.
### Conflict resolution
Extension commands have the lowest precedence. When a conflict occurs with user
or project commands:
1. **No conflict**: Extension command uses its natural name (e.g., `/deploy`)
2. **With conflict**: Extension command is renamed with the extension prefix
(e.g., `/gcp.deploy`)
For example, if both a user and the `gcp` extension define a `deploy` command:
- `/deploy` - Executes the user's deploy command
- `/gcp.deploy` - Executes the extension's deploy command (marked with `[gcp]`
tag)
Extension commands have the lowest precedence. If an extension command name
conflicts with a user or project command, the extension command is prefixed with
the extension name (e.g., `/gcp.deploy`) using a dot separator.
## Variables
Gemini CLI extensions allow variable substitution in both
`gemini-extension.json` and `hooks/hooks.json`. This can be useful if e.g., you
need the current directory to run an MCP server using an argument like
`"args": ["${extensionPath}${/}dist${/}server.js"]`.
Gemini CLI supports variable substitution in `gemini-extension.json` and
`hooks/hooks.json`.
**Supported variables:**
| variable | description |
| -------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `${extensionPath}` | The fully-qualified path of the extension in the user's filesystem e.g., '/Users/username/.gemini/extensions/example-extension'. This will not unwrap symlinks. |
| `${workspacePath}` | The fully-qualified path of the current workspace. |
| `${/} or ${pathSeparator}` | The path separator (differs per OS). |
| Variable | Description |
| :----------------- | :---------------------------------------------- |
| `${extensionPath}` | The absolute path to the extension's directory. |
| `${workspacePath}` | The absolute path to the current workspace. |
| `${/}` | The platform-specific path separator. |
+74 -103
View File
@@ -1,146 +1,117 @@
# Extension releasing
# Release extensions
There are two primary ways of releasing extensions to users:
Release Gemini CLI extensions to your users through a Git repository or GitHub
Releases.
- [Git repository](#releasing-through-a-git-repository)
- [Github Releases](#releasing-through-github-releases)
Git repository releases are the simplest approach and offer the most flexibility
for managing development branches. GitHub Releases are more efficient for
initial installations because they ship as single archives rather than requiring
a full `git clone`. Use GitHub Releases if you need to include platform-specific
binary files.
Git repository releases tend to be the simplest and most flexible approach,
while GitHub releases can be more efficient on initial install as they are
shipped as single archives instead of requiring a git clone which downloads each
file individually. Github releases may also contain platform specific archives
if you need to ship platform specific binary files.
## List your extension in the gallery
## Releasing through a git repository
The [Gemini CLI extension gallery](https://geminicli.com/extensions/browse/)
automatically indexes public extensions to help users discover your work. You
don't need to submit an issue or email us to list your extension.
This is the most flexible and simple option. All you need to do is create a
publicly accessible git repo (such as a public github repository) and then users
can install your extension using `gemini extensions install <your-repo-uri>`.
They can optionally depend on a specific ref (branch/tag/commit) using the
`--ref=<some-ref>` argument, this defaults to the default branch.
To have your extension automatically discovered and listed:
Whenever commits are pushed to the ref that a user depends on, they will be
prompted to update the extension. Note that this also allows for easy rollbacks,
the HEAD commit is always treated as the latest version regardless of the actual
version in the `gemini-extension.json` file.
1. **Use a public repository:** Ensure your extension is hosted in a public
GitHub repository.
2. **Add the GitHub topic:** Add the `gemini-cli-extension` topic to your
repository's **About** section. Our crawler uses this topic to find new
extensions.
3. **Place the manifest at the root:** Ensure your `gemini-extension.json` file
is in the absolute root of the repository or the release archive.
### Managing release channels using a git repository
Our system crawls tagged repositories daily. Once you tag your repository, your
extension will appear in the gallery if it passes validation.
Users can depend on any ref from your git repo, such as a branch or tag, which
allows you to manage multiple release channels.
## Release through a Git repository
For instance, you can maintain a `stable` branch, which users can install this
way `gemini extensions install <your-repo-uri> --ref=stable`. Or, you could make
this the default by treating your default branch as your stable release branch,
and doing development in a different branch (for instance called `dev`). You can
maintain as many branches or tags as you like, providing maximum flexibility for
you and your users.
Releasing through Git is the most flexible option. Create a public Git
repository and provide the URL to your users. They can then install your
extension using `gemini extensions install <your-repo-uri>`.
Note that these `ref` arguments can be tags, branches, or even specific commits,
which allows users to depend on a specific version of your extension. It is up
to you how you want to manage your tags and branches.
Users can optionally depend on a specific branch, tag, or commit using the
`--ref` argument. For example:
### Example releasing flow using a git repo
```bash
gemini extensions install <your-repo-uri> --ref=stable
```
While there are many options for how you want to manage releases using a git
flow, we recommend treating your default branch as your "stable" release branch.
This means that the default behavior for
`gemini extensions install <your-repo-uri>` is to be on the stable release
branch.
Whenever you push commits to the referenced branch, the CLI prompts users to
update their installation. The `HEAD` commit is always treated as the latest
version.
Lets say you want to maintain three standard release channels, `stable`,
`preview`, and `dev`. You would do all your standard development in the `dev`
branch. When you are ready to do a preview release, you merge that branch into
your `preview` branch. When you are ready to promote your preview branch to
stable, you merge `preview` into your stable branch (which might be your default
branch or a different branch).
### Manage release channels
You can also cherry pick changes from one branch into another using
`git cherry-pick`, but do note that this will result in your branches having a
slightly divergent history from each other, unless you force push changes to
your branches on each release to restore the history to a clean slate (which may
not be possible for the default branch depending on your repository settings).
If you plan on doing cherry picks, you may want to avoid having your default
branch be the stable branch to avoid force-pushing to the default branch which
should generally be avoided.
You can use branches or tags to manage different release channels, such as
`stable`, `preview`, or `dev`.
## Releasing through GitHub releases
We recommend using your default branch as the stable release channel. This
ensures that the default installation command always provides the most reliable
version of your extension. You can then use a `dev` branch for active
development and merge it into the default branch when you are ready for a
release.
Gemini CLI extensions can be distributed through
[GitHub Releases](https://docs.github.com/en/repositories/releasing-projects-on-github/about-releases).
This provides a faster and more reliable initial installation experience for
users, as it avoids the need to clone the repository.
## Release through GitHub Releases
Each release includes at least one archive file, which contains the full
contents of the repo at the tag that it was linked to. Releases may also include
[pre-built archives](#custom-pre-built-archives) if your extension requires some
build step or has platform specific binaries attached to it.
Distributing extensions through
[GitHub Releases](https://docs.github.com/en/repositories/releasing-projects-on-github/about-releases)
provides a faster installation experience by avoiding a repository clone.
When checking for updates, gemini will just look for the "latest" release on
github (you must mark it as such when creating the release), unless the user
installed a specific release by passing `--ref=<some-release-tag>`.
You may also install extensions with the `--pre-release` flag in order to get
the latest release regardless of whether it has been marked as "latest". This
allows you to test that your release works before actually pushing it to all
users.
Gemini CLI checks for updates by looking for the **Latest** release on GitHub.
Users can also install specific versions using the `--ref` argument with a
release tag. Use the `--pre-release` flag to install the latest version even if
it isn't marked as **Latest**.
### Custom pre-built archives
Custom archives must be attached directly to the github release as assets and
must be fully self-contained. This means they should include the entire
extension, see [archive structure](#archive-structure).
You can attach custom archives directly to your GitHub Release as assets. This
is useful if your extension requires a build step or includes platform-specific
binaries.
If your extension is platform-independent, you can provide a single generic
asset. In this case, there should be only one asset attached to the release.
Custom archives must be fully self-contained and follow the required
[archive structure](#archive-structure). If your extension is
platform-independent, provide a single generic asset.
Custom archives may also be used if you want to develop your extension within a
larger repository, you can build an archive which has a different layout from
the repo itself (for instance it might just be an archive of a subdirectory
containing the extension).
#### Platform-specific archives
#### Platform specific archives
To let Gemini CLI find the correct asset for a user's platform, use the
following naming convention:
To ensure Gemini CLI can automatically find the correct release asset for each
platform, you must follow this naming convention. The CLI will search for assets
in the following order:
1. **Platform and architecture-Specific:**
1. **Platform and architecture-specific:**
`{platform}.{arch}.{name}.{extension}`
2. **Platform-specific:** `{platform}.{name}.{extension}`
3. **Generic:** If only one asset is provided, it will be used as a generic
fallback.
3. **Generic:** A single asset will be used as a fallback if no specific match
is found.
- `{name}`: The name of your extension.
- `{platform}`: The operating system. Supported values are:
- `darwin` (macOS)
- `linux`
- `win32` (Windows)
- `{arch}`: The architecture. Supported values are:
- `x64`
- `arm64`
- `{extension}`: The file extension of the archive (e.g., `.tar.gz` or `.zip`).
Use these values for the placeholders:
- `{name}`: Your extension name.
- `{platform}`: Use `darwin` (macOS), `linux`, or `win32` (Windows).
- `{arch}`: Use `x64` or `arm64`.
- `{extension}`: Use `.tar.gz` or `.zip`.
**Examples:**
- `darwin.arm64.my-tool.tar.gz` (specific to Apple Silicon Macs)
- `darwin.my-tool.tar.gz` (for all Macs)
- `darwin.my-tool.tar.gz` (fallback for all Macs, e.g. Intel)
- `linux.x64.my-tool.tar.gz`
- `win32.my-tool.zip`
#### Archive structure
Archives must be fully contained extensions and have all the standard
requirements - specifically the `gemini-extension.json` file must be at the root
of the archive.
The rest of the layout should look exactly the same as a typical extension, see
[extensions.md](./index.md).
Archives must be fully contained extensions. The `gemini-extension.json` file
must be at the root of the archive. The rest of the layout should match a
standard extension structure.
#### Example GitHub Actions workflow
Here is an example of a GitHub Actions workflow that builds and releases a
Gemini CLI extension for multiple platforms:
Use this example workflow to build and release your extension for multiple
platforms:
```yaml
name: Release Extension
+87 -76
View File
@@ -1,18 +1,19 @@
# Getting started with Gemini CLI extensions
# Build Gemini CLI extensions
This guide will walk you through creating your first Gemini CLI extension.
You'll learn how to set up a new extension, add a custom tool via an MCP server,
create a custom command, and provide context to the model with a `GEMINI.md`
file.
Gemini CLI extensions let you expand the capabilities of Gemini CLI by adding
custom tools, commands, and context. This guide walks you through creating your
first extension, from setting up a template to adding custom functionality and
linking it for local development.
## Prerequisites
Before you start, make sure you have the Gemini CLI installed and a basic
Before you start, ensure you have the Gemini CLI installed and a basic
understanding of Node.js.
## When to use what
## Extension features
Extensions offer a variety of ways to customize Gemini CLI.
Extensions offer several ways to customize Gemini CLI. Use this table to decide
which features your extension needs.
| Feature | What it is | When to use it | Invoked by |
| :------------------------------------------------------------- | :----------------------------------------------------------------------------------------------------------------- | :--------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | :-------------------- |
@@ -21,11 +22,12 @@ Extensions offer a variety of ways to customize Gemini CLI.
| **[Context file (`GEMINI.md`)](reference.md#contextfilename)** | A markdown file containing instructions that are loaded into the model's context at the start of every session. | Use this to define the "personality" of your extension, set coding standards, or provide essential knowledge that the model should always have. | CLI provides to model |
| **[Agent skills](../cli/skills.md)** | A specialized set of instructions and workflows that the model activates only when needed. | Use this for complex, occasional tasks (like "create a PR" or "audit security") to avoid cluttering the main context window when the skill isn't being used. | Model |
| **[Hooks](../hooks/index.md)** | A way to intercept and customize the CLI's behavior at specific lifecycle events (e.g., before/after a tool call). | Use this when you want to automate actions based on what the model is doing, like validating tool arguments, logging activity, or modifying the model's input/output. | CLI |
| **[Custom themes](reference.md#themes)** | A set of color definitions to personalize the CLI UI. | Use this to provide a unique visual identity for your extension or to offer specialized high-contrast or thematic color schemes. | User (via /theme) |
## Step 1: Create a new extension
The easiest way to start is by using one of the built-in templates. We'll use
the `mcp-server` example as our foundation.
The easiest way to start is by using a built-in template. We'll use the
`mcp-server` example as our foundation.
Run the following command to create a new directory called `my-first-extension`
with the template files:
@@ -34,7 +36,7 @@ with the template files:
gemini extensions new my-first-extension mcp-server
```
This will create a new directory with the following structure:
This creates a directory with the following structure:
```
my-first-extension/
@@ -45,12 +47,11 @@ my-first-extension/
## Step 2: Understand the extension files
Let's look at the key files in your new extension.
Your new extension contains several key files that define its behavior.
### `gemini-extension.json`
This is the manifest file for your extension. It tells Gemini CLI how to load
and use your extension.
The manifest file tells Gemini CLI how to load and use your extension.
```json
{
@@ -68,17 +69,15 @@ and use your extension.
- `name`: The unique name for your extension.
- `version`: The version of your extension.
- `mcpServers`: This section defines one or more Model Context Protocol (MCP)
servers. MCP servers are how you can add new tools for the model to use.
- `command`, `args`, `cwd`: These fields specify how to start your server.
Notice the use of the `${extensionPath}` variable, which Gemini CLI replaces
with the absolute path to your extension's installation directory. This
allows your extension to work regardless of where it's installed.
- `mcpServers`: Defines Model Context Protocol (MCP) servers to add new tools.
- `command`, `args`, `cwd`: Specify how to start your server. The
`${extensionPath}` variable is replaced with the absolute path to your
extension's directory.
### `example.js`
This file contains the source code for your MCP server. It's a simple Node.js
server that uses the `@modelcontextprotocol/sdk`.
This file contains the source code for your MCP server. It uses the
`@modelcontextprotocol/sdk` to define tools.
```javascript
/**
@@ -120,24 +119,49 @@ server.registerTool(
},
);
// ... (prompt registration omitted for brevity)
const transport = new StdioServerTransport();
await server.connect(transport);
```
This server defines a single tool called `fetch_posts` that fetches data from a
public API.
### `package.json`
This is the standard configuration file for a Node.js project. It defines
dependencies and scripts.
The standard configuration file for a Node.js project. It defines dependencies
and scripts for your extension.
## Step 3: Link your extension
## Step 3: Add extension settings
Before you can use the extension, you need to link it to your Gemini CLI
installation for local development.
Some extensions need configuration, such as API keys or user preferences. Let's
add a setting for an API key.
1. Open `gemini-extension.json`.
2. Add a `settings` array to the configuration:
```json
{
"name": "mcp-server-example",
"version": "1.0.0",
"settings": [
{
"name": "API Key",
"description": "The API key for the service.",
"envVar": "MY_SERVICE_API_KEY",
"sensitive": true
}
],
"mcpServers": {
// ...
}
}
```
When a user installs this extension, Gemini CLI will prompt them to enter the
"API Key". The value will be stored securely in the system keychain (because
`sensitive` is true) and injected into the MCP server's process as the
`MY_SERVICE_API_KEY` environment variable.
## Step 4: Link your extension
Link your extension to your Gemini CLI installation for local development.
1. **Install dependencies:**
@@ -149,20 +173,19 @@ installation for local development.
2. **Link the extension:**
The `link` command creates a symbolic link from the Gemini CLI extensions
directory to your development directory. This means any changes you make
will be reflected immediately without needing to reinstall.
directory to your development directory. Changes you make are reflected
immediately.
```bash
gemini extensions link .
```
Now, restart your Gemini CLI session. The new `fetch_posts` tool will be
available. You can test it by asking: "fetch posts".
Restart your Gemini CLI session to use the new `fetch_posts` tool. Test it by
asking: "fetch posts".
## Step 4: Add a custom command
## Step 5: Add a custom command
Custom commands provide a way to create shortcuts for complex prompts. Let's add
a command that searches for a pattern in your code.
Custom commands create shortcuts for complex prompts.
1. Create a `commands` directory and a subdirectory for your command group:
@@ -181,18 +204,17 @@ a command that searches for a pattern in your code.
"""
```
This command, `/fs:grep-code`, will take an argument, run the `grep` shell
command with it, and pipe the results into a prompt for summarization.
This command, `/fs:grep-code`, takes an argument, runs the `grep` shell
command, and pipes the results into a prompt for summarization.
After saving the file, restart the Gemini CLI. You can now run
`/fs:grep-code "some pattern"` to use your new command.
After saving the file, restart Gemini CLI. Run `/fs:grep-code "some pattern"` to
use your new command.
## Step 5: Add a custom `GEMINI.md`
## Step 6: Add a custom `GEMINI.md`
You can provide persistent context to the model by adding a `GEMINI.md` file to
your extension. This is useful for giving the model instructions on how to
behave or information about your extension's tools. Note that you may not always
need this for extensions built to expose commands and prompts.
Provide persistent context to the model by adding a `GEMINI.md` file to your
extension. This is useful for setting behavior or providing essential tool
information.
1. Create a file named `GEMINI.md` in the root of your extension directory:
@@ -203,7 +225,7 @@ need this for extensions built to expose commands and prompts.
posts, use the `fetch_posts` tool. Be concise in your responses.
```
2. Update your `gemini-extension.json` to tell the CLI to load this file:
2. Update your `gemini-extension.json` to load this file:
```json
{
@@ -220,14 +242,13 @@ need this for extensions built to expose commands and prompts.
}
```
Restart the CLI again. The model will now have the context from your `GEMINI.md`
file in every session where the extension is active.
Restart Gemini CLI. The model now has the context from your `GEMINI.md` file in
every session where the extension is active.
## (Optional) Step 6: Add an Agent Skill
## (Optional) Step 7: Add an Agent Skill
[Agent Skills](../cli/skills.md) let you bundle specialized expertise and
procedural workflows. Unlike `GEMINI.md`, which provides persistent context,
skills are activated only when needed, saving context tokens.
[Agent Skills](../cli/skills.md) bundle specialized expertise and workflows.
Skills are activated only when needed, which saves context tokens.
1. Create a `skills` directory and a subdirectory for your skill:
@@ -254,28 +275,18 @@ skills are activated only when needed, saving context tokens.
3. Suggest remediation steps for any findings.
```
Skills bundled with your extension are automatically discovered and can be
activated by the model during a session when it identifies a relevant task.
Gemini CLI automatically discovers skills bundled with your extension. The model
activates them when it identifies a relevant task.
## Step 7: Release your extension
## Step 8: Release your extension
Once you're happy with your extension, you can share it with others. The two
primary ways of releasing extensions are via a Git repository or through GitHub
Releases. Using a public Git repository is the simplest method.
When your extension is ready, share it with others via a Git repository or
GitHub Releases. Refer to the [Extension Releasing Guide](./releasing.md) for
detailed instructions and learn how to list your extension in the gallery.
For detailed instructions on both methods, please refer to the
[Extension Releasing Guide](./releasing.md).
## Next steps
## Conclusion
You've successfully created a Gemini CLI extension! You learned how to:
- Bootstrap a new extension from a template.
- Add custom tools with an MCP server.
- Create convenient custom commands.
- Provide persistent context to the model.
- Bundle specialized Agent Skills.
- Link your extension for local development.
From here, you can explore more advanced features and build powerful new
capabilities into the Gemini CLI.
- [Extension reference](reference.md): Deeply understand the extension format,
commands, and configuration.
- [Best practices](best-practices.md): Learn strategies for building great
extensions.
+18
View File
@@ -490,6 +490,19 @@ their corresponding top-level category object in your `settings.json` file.
}
}
},
"fast-ack-helper": {
"extends": "base",
"modelConfig": {
"model": "gemini-2.5-flash-lite",
"generateContentConfig": {
"temperature": 0.2,
"maxOutputTokens": 120,
"thinkingConfig": {
"thinkingBudget": 0
}
}
}
},
"edit-corrector": {
"extends": "base",
"modelConfig": {
@@ -633,6 +646,11 @@ their corresponding top-level category object in your `settings.json` file.
- **Description:** The format to use when importing memory.
- **Default:** `undefined`
- **`context.includeDirectoryTree`** (boolean):
- **Description:** Whether to include the directory tree of the current
working directory in the initial request to the model.
- **Default:** `true`
- **`context.discoveryMaxDirs`** (number):
- **Description:** Maximum number of directories to search for memory.
- **Default:** `200`
+2 -2
View File
@@ -29,7 +29,7 @@ or if we have to deviate from it. Our weekly releases will be minor version
increments and any bug or hotfixes between releases will go out as patch
versions on the most recent release.
Each Tuesday ~2000 UTC new Stable and Preview releases will be cut. The
Each Tuesday ~20:00 UTC new Stable and Preview releases will be cut. The
promotion flow is:
- Code is committed to main and pushed each night to nightly
@@ -58,7 +58,7 @@ npm install -g @google/gemini-cli@latest
### Nightly
- New releases will be published each day at UTC 0000. This will be all changes
- New releases will be published each day at UTC 00:00. This will be all changes
from the main branch as represented at time of release. It should be assumed
there are pending validations and issues. Use `nightly` tag.
+43 -97
View File
@@ -52,112 +52,42 @@
{
"label": "Features",
"items": [
{ "label": "Agent Skills", "slug": "docs/cli/skills" },
{
"label": "/about - About Gemini CLI",
"slug": "docs/cli/commands#about"
},
{
"label": "/auth - Authentication",
"label": "Authentication",
"slug": "docs/get-started/authentication"
},
{ "label": "/bug - Report a bug", "slug": "docs/cli/commands#bug" },
{ "label": "/chat - Chat history", "slug": "docs/cli/commands#chat" },
{ "label": "/clear - Clear screen", "slug": "docs/cli/commands#clear" },
{ "label": "Checkpointing", "slug": "docs/cli/checkpointing" },
{
"label": "/compress - Compress context",
"slug": "docs/cli/commands#compress"
},
{ "label": "/copy - Copy output", "slug": "docs/cli/commands#copy" },
{
"label": "/directory - Manage workspace",
"slug": "docs/cli/commands#directory-or-dir"
},
{
"label": "/docs - Open documentation",
"slug": "docs/cli/commands#docs"
},
{
"label": "/editor - Select editor",
"slug": "docs/cli/commands#editor"
},
{
"label": "/extensions - Manage extensions",
"label": "Extensions",
"slug": "docs/extensions/index"
},
{ "label": "/help - Show help", "slug": "docs/cli/commands#help-or" },
{ "label": "/hooks - Hooks", "slug": "docs/hooks" },
{ "label": "/ide - IDE integration", "slug": "docs/ide-integration" },
{
"label": "/init - Initialize context",
"slug": "docs/cli/commands#init"
},
{ "label": "/mcp - MCP servers", "slug": "docs/tools/mcp-server" },
{
"label": "/memory - Manage memory",
"slug": "docs/cli/commands#memory"
},
{ "label": "/model - Model selection", "slug": "docs/cli/model" },
{
"label": "/policies - Manage policies",
"slug": "docs/cli/commands#policies"
},
{
"label": "/privacy - Privacy notice",
"slug": "docs/cli/commands#privacy"
},
{ "label": "/quit - Exit CLI", "slug": "docs/cli/commands#quit-or-exit" },
{ "label": "/restore - Restore files", "slug": "docs/cli/checkpointing" },
{
"label": "/resume - Resume session",
"slug": "docs/cli/commands#resume"
},
{ "label": "/rewind - Rewind", "slug": "docs/cli/rewind" },
{ "label": "/settings - Settings", "slug": "docs/cli/settings" },
{
"label": "/setup-github - GitHub setup",
"slug": "docs/cli/commands#setup-github"
},
{
"label": "/shells - Manage processes",
"slug": "docs/cli/commands#shells-or-bashes"
},
{ "label": "/skills - Agent skills", "slug": "docs/cli/skills" },
{
"label": "/stats - Session statistics",
"slug": "docs/cli/commands#stats"
},
{
"label": "/terminal-setup - Terminal keybindings",
"slug": "docs/cli/commands#terminal-setup"
},
{ "label": "/theme - Themes", "slug": "docs/cli/themes" },
{ "label": "/tools - List tools", "slug": "docs/cli/commands#tools" },
{ "label": "/vim - Vim mode", "slug": "docs/cli/commands#vim" },
{
"label": "Activate skill (tool)",
"slug": "docs/tools/activate-skill"
},
{ "label": "Ask user (tool)", "slug": "docs/tools/ask-user" },
{ "label": "Checkpointing", "slug": "docs/cli/checkpointing" },
{ "label": "File system (tool)", "slug": "docs/tools/file-system" },
{ "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": "Internal documentation (tool)",
"slug": "docs/tools/internal-docs"
"label": "Memory management",
"link": "/docs/cli/commands/#memory"
},
{ "label": "Memory (tool)", "slug": "docs/tools/memory" },
{ "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": "Shell (tool)", "slug": "docs/tools/shell" },
{ "label": "Settings", "slug": "docs/cli/settings" },
{
"label": "Shell",
"link": "/docs/cli/commands/#shells-or-bashes"
},
{
"label": "Stats",
"link": "/docs/cli/commands/#stats"
},
{ "label": "Telemetry", "slug": "docs/cli/telemetry" },
{ "label": "Todo (tool)", "slug": "docs/tools/todos" },
{ "label": "Token caching", "slug": "docs/cli/token-caching" },
{ "label": "Web fetch (tool)", "slug": "docs/tools/web-fetch" },
{ "label": "Web search (tool)", "slug": "docs/tools/web-search" }
{ "label": "Tools", "link": "/docs/cli/commands/#tools" }
]
},
{
@@ -183,14 +113,30 @@
{
"label": "Extensions",
"items": [
{ "label": "Introduction", "slug": "docs/extensions" },
{
"label": "Writing extensions",
"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": "Reference", "slug": "docs/extensions/reference" },
{ "label": "Best practices", "slug": "docs/extensions/best-practices" },
{ "label": "Releasing", "slug": "docs/extensions/releasing" }
{
"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"
}
]
},
{
+86
View File
@@ -0,0 +1,86 @@
/**
* @license
* Copyright 2026 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import { AppRig } from '../packages/cli/src/test-utils/AppRig.js';
import {
type EvalPolicy,
runEval,
prepareLogDir,
symlinkNodeModules,
} from './test-helper.js';
import fs from 'node:fs';
import path from 'node:path';
import { DEFAULT_GEMINI_MODEL } from '@google/gemini-cli-core';
export interface AppEvalCase {
name: string;
configOverrides?: any;
prompt: string;
timeout?: number;
files?: Record<string, string>;
setup?: (rig: AppRig) => Promise<void>;
assert: (rig: AppRig, output: string) => Promise<void>;
}
/**
* A helper for running behavioral evaluations using the in-process AppRig.
* This matches the API of evalTest in test-helper.ts as closely as possible.
*/
export function appEvalTest(policy: EvalPolicy, evalCase: AppEvalCase) {
const fn = async () => {
const rig = new AppRig({
configOverrides: {
model: DEFAULT_GEMINI_MODEL,
...evalCase.configOverrides,
},
});
const { logDir, sanitizedName } = await prepareLogDir(evalCase.name);
const logFile = path.join(logDir, `${sanitizedName}.log`);
try {
await rig.initialize();
const testDir = rig.getTestDir();
symlinkNodeModules(testDir);
// Setup initial files
if (evalCase.files) {
for (const [filePath, content] of Object.entries(evalCase.files)) {
const fullPath = path.join(testDir, filePath);
fs.mkdirSync(path.dirname(fullPath), { recursive: true });
fs.writeFileSync(fullPath, content);
}
}
// Run custom setup if provided (e.g. for breakpoints)
if (evalCase.setup) {
await evalCase.setup(rig);
}
// Render the app!
rig.render();
// Wait for initial ready state
await rig.waitForIdle();
// Send the initial prompt
await rig.sendMessage(evalCase.prompt);
// Run assertion. Interaction-heavy tests can do their own waiting/steering here.
const output = rig.getStaticOutput();
await evalCase.assert(rig, output);
} finally {
const output = rig.getStaticOutput();
if (output) {
await fs.promises.writeFile(logFile, output);
}
await rig.unmount();
}
};
runEval(policy, evalCase.name, fn, (evalCase.timeout ?? 60000) + 10000);
}
+278
View File
@@ -0,0 +1,278 @@
/**
* @license
* Copyright 2026 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import { describe, expect } from 'vitest';
import { evalTest } from './test-helper.js';
import { READ_FILE_TOOL_NAME, EDIT_TOOL_NAME } from '@google/gemini-cli-core';
describe('Frugal reads eval', () => {
/**
* Ensures that the agent is frugal in its use of context by relying
* primarily on ranged reads when the line number is known, and combining
* nearby ranges into a single contiguous read to save tool calls.
*/
evalTest('USUALLY_PASSES', {
name: 'should use ranged read when nearby lines are targeted',
files: {
'package.json': JSON.stringify({
name: 'test-project',
version: '1.0.0',
type: 'module',
}),
'eslint.config.mjs': `export default [
{
files: ["**/*.ts"],
rules: {
"no-var": "error"
}
}
];`,
'linter_mess.ts': (() => {
const lines = [];
for (let i = 0; i < 1000; i++) {
if (i === 500 || i === 510 || i === 520) {
lines.push(`var oldVar${i} = "needs fix";`);
} else {
lines.push(`const goodVar${i} = "clean";`);
}
}
return lines.join('\n');
})(),
},
prompt:
'Fix all linter errors in linter_mess.ts manually by editing the file. Run eslint directly (using "npx --yes eslint") to find them. Do not run the file.',
assert: async (rig) => {
const logs = rig.readToolLogs();
// Check if the agent read the whole file
const readCalls = logs.filter(
(log) => log.toolRequest?.name === READ_FILE_TOOL_NAME,
);
const targetFileReads = readCalls.filter((call) => {
const args = JSON.parse(call.toolRequest.args);
return args.file_path.includes('linter_mess.ts');
});
expect(
targetFileReads.length,
'Agent should have used read_file to check context',
).toBeGreaterThan(0);
// We expect 1-3 ranges in a single turn.
expect(
targetFileReads.length,
'Agent should have used 1-3 ranged reads for near errors',
).toBeLessThanOrEqual(3);
const firstPromptId = targetFileReads[0].toolRequest.prompt_id;
expect(firstPromptId, 'Prompt ID should be defined').toBeDefined();
expect(
targetFileReads.every(
(call) => call.toolRequest.prompt_id === firstPromptId,
),
'All reads should have happened in the same turn',
).toBe(true);
let totalLinesRead = 0;
const readRanges: { offset: number; limit: number }[] = [];
for (const call of targetFileReads) {
const args = JSON.parse(call.toolRequest.args);
expect(
args.limit,
'Agent read the entire file (missing limit) instead of using ranged read',
).toBeDefined();
const limit = args.limit;
const offset = args.offset ?? 0;
totalLinesRead += limit;
readRanges.push({ offset, limit });
expect(args.limit, 'Agent read too many lines at once').toBeLessThan(
1001,
);
}
// Ranged read shoud be frugal and just enough to satisfy the task at hand.
expect(
totalLinesRead,
'Agent read more of the file than expected',
).toBeLessThan(1000);
// Check that we read around the error lines
const errorLines = [500, 510, 520];
for (const line of errorLines) {
const covered = readRanges.some(
(range) => line >= range.offset && line < range.offset + range.limit,
);
expect(covered, `Agent should have read around line ${line}`).toBe(
true,
);
}
const editCalls = logs.filter(
(log) => log.toolRequest?.name === EDIT_TOOL_NAME,
);
const targetEditCalls = editCalls.filter((call) => {
const args = JSON.parse(call.toolRequest.args);
return args.file_path.includes('linter_mess.ts');
});
expect(
targetEditCalls.length,
'Agent should have made replacement calls on the target file',
).toBeGreaterThanOrEqual(3);
},
});
/**
* Ensures the agent uses multiple ranged reads when the targets are far
* apart to avoid the need to read the whole file.
*/
evalTest('USUALLY_PASSES', {
name: 'should use ranged read when targets are far apart',
files: {
'package.json': JSON.stringify({
name: 'test-project',
version: '1.0.0',
type: 'module',
}),
'eslint.config.mjs': `export default [
{
files: ["**/*.ts"],
rules: {
"no-var": "error"
}
}
];`,
'far_mess.ts': (() => {
const lines = [];
for (let i = 0; i < 1000; i++) {
if (i === 100 || i === 900) {
lines.push(`var oldVar${i} = "needs fix";`);
} else {
lines.push(`const goodVar${i} = "clean";`);
}
}
return lines.join('\n');
})(),
},
prompt:
'Fix all linter errors in far_mess.ts manually by editing the file. Run eslint directly (using "npx --yes eslint") to find them. Do not run the file.',
assert: async (rig) => {
const logs = rig.readToolLogs();
const readCalls = logs.filter(
(log) => log.toolRequest?.name === READ_FILE_TOOL_NAME,
);
const targetFileReads = readCalls.filter((call) => {
const args = JSON.parse(call.toolRequest.args);
return args.file_path.includes('far_mess.ts');
});
// The agent should use ranged reads to be frugal with context tokens,
// even if it requires multiple calls for far-apart errors.
expect(
targetFileReads.length,
'Agent should have used read_file to check context',
).toBeGreaterThan(0);
// We allow multiple calls since the errors are far apart.
expect(
targetFileReads.length,
'Agent should have used separate reads for far apart errors',
).toBeLessThanOrEqual(4);
for (const call of targetFileReads) {
const args = JSON.parse(call.toolRequest.args);
expect(
args.limit,
'Agent should have used ranged read (limit) to save tokens',
).toBeDefined();
}
},
});
/**
* Validates that the agent reads the entire file if there are lots of matches
* (e.g.: 10), as it's more efficient than many small ranged reads.
*/
evalTest('USUALLY_PASSES', {
name: 'should read the entire file when there are many matches',
files: {
'package.json': JSON.stringify({
name: 'test-project',
version: '1.0.0',
type: 'module',
}),
'eslint.config.mjs': `export default [
{
files: ["**/*.ts"],
rules: {
"no-var": "error"
}
}
];`,
'many_mess.ts': (() => {
const lines = [];
for (let i = 0; i < 1000; i++) {
if (i % 100 === 0) {
lines.push(`var oldVar${i} = "needs fix";`);
} else {
lines.push(`const goodVar${i} = "clean";`);
}
}
return lines.join('\n');
})(),
},
prompt:
'Fix all linter errors in many_mess.ts manually by editing the file. Run eslint directly (using "npx --yes eslint") to find them. Do not run the file.',
assert: async (rig) => {
const logs = rig.readToolLogs();
const readCalls = logs.filter(
(log) => log.toolRequest?.name === READ_FILE_TOOL_NAME,
);
const targetFileReads = readCalls.filter((call) => {
const args = JSON.parse(call.toolRequest.args);
return args.file_path.includes('many_mess.ts');
});
expect(
targetFileReads.length,
'Agent should have used read_file to check context',
).toBeGreaterThan(0);
// In this case, we expect the agent to realize there are many scattered errors
// and just read the whole file to be efficient with tool calls.
const readEntireFile = targetFileReads.some((call) => {
const args = JSON.parse(call.toolRequest.args);
return args.limit === undefined;
});
expect(
readEntireFile,
'Agent should have read the entire file because of the high number of scattered matches',
).toBe(true);
// Check that the agent actually fixed the errors
const editCalls = logs.filter(
(log) => log.toolRequest?.name === EDIT_TOOL_NAME,
);
const targetEditCalls = editCalls.filter((call) => {
const args = JSON.parse(call.toolRequest.args);
return args.file_path.includes('many_mess.ts');
});
expect(
targetEditCalls.length,
'Agent should have made replacement calls on the target file',
).toBeGreaterThanOrEqual(1);
},
});
});
+62 -106
View File
@@ -9,7 +9,7 @@ import { evalTest } from './test-helper.js';
/**
* Evals to verify that the agent uses search tools efficiently (frugally)
* by utilizing limiting parameters like `total_max_matches` and `max_matches_per_file`.
* by utilizing limiting parameters like `limit` and `max_matches_per_file`.
* This ensures the agent doesn't flood the context window with unnecessary search results.
*/
describe('Frugal Search', () => {
@@ -25,120 +25,76 @@ describe('Frugal Search', () => {
return args;
};
/**
* Ensure that the agent makes use of either grep or ranged reads in fulfilling this task.
* The task is specifically phrased to not evoke "view" or "search" specifically because
* the model implicitly understands that such tasks are searches. This covers the case of
* an unexpectedly large file benefitting from frugal approaches to viewing, like grep, or
* ranged reads.
*/
evalTest('USUALLY_PASSES', {
name: 'should use targeted search with limit',
prompt: 'find me a sample usage of path.resolve() in the codebase',
name: 'should use grep or ranged read for large files',
prompt: 'What year was legacy_processor.ts written?',
files: {
'package.json': JSON.stringify({
name: 'test-project',
version: '1.0.0',
main: 'dist/index.js',
scripts: {
build: 'tsc',
test: 'vitest',
},
dependencies: {
typescript: '^5.0.0',
'@types/node': '^20.0.0',
vitest: '^1.0.0',
},
}),
'src/index.ts': `
import { App } from './app.ts';
const app = new App();
app.start();
`,
'src/app.ts': `
import * as path from 'path';
import { UserController } from './controllers/user.ts';
export class App {
constructor() {
console.log('App initialized');
}
public start(): void {
const userController = new UserController();
console.log('Static path:', path.resolve(__dirname, '../public'));
}
}
`,
'src/utils.ts': `
import * as path from 'path';
import * as fs from 'fs';
export function resolvePath(p: string): string {
return path.resolve(process.cwd(), p);
}
export function ensureDir(dirPath: string): void {
const absolutePath = path.resolve(dirPath);
if (!fs.existsSync(absolutePath)) {
fs.mkdirSync(absolutePath, { recursive: true });
}
}
`,
'src/config.ts': `
import * as path from 'path';
export const config = {
dbPath: path.resolve(process.cwd(), 'data/db.sqlite'),
logLevel: 'info',
};
`,
'src/controllers/user.ts': `
import * as path from 'path';
export class UserController {
public getUsers(): any[] {
console.log('Loading users from:', path.resolve('data/users.json'));
return [{ id: 1, name: 'Alice' }];
}
}
`,
'tests/app.test.ts': `
import { describe, it, expect } from 'vitest';
import * as path from 'path';
describe('App', () => {
it('should resolve paths', () => {
const p = path.resolve('test');
expect(p).toBeDefined();
});
});
`,
'src/utils.ts': 'export const add = (a, b) => a + b;',
'src/types.ts': 'export type ID = string;',
'src/legacy_processor.ts': [
'// Copyright 2005 Legacy Systems Inc.',
...Array.from(
{ length: 5000 },
(_, i) =>
`// Legacy code block ${i} - strictly preserved for backward compatibility`,
),
].join('\n'),
'README.md': '# Project documentation',
},
assert: async (rig) => {
const toolCalls = rig.readToolLogs();
const grepCalls = toolCalls.filter(
(call) => call.toolRequest.name === 'grep_search',
);
const getParams = (call: any) => {
let args = call.toolRequest.args;
if (typeof args === 'string') {
try {
args = JSON.parse(args);
} catch (e) {
// Ignore parse errors
}
}
return args;
};
expect(grepCalls.length).toBeGreaterThan(0);
// Check for wasteful full file reads
const fullReads = toolCalls.filter((call) => {
if (call.toolRequest.name !== 'read_file') return false;
const args = getParams(call);
return (
args.file_path === 'src/legacy_processor.ts' &&
(args.limit === undefined || args.limit === null)
);
});
const grepParams = grepCalls.map(getGrepParams);
const hasTotalMaxLimit = grepParams.some(
(p) => p.total_max_matches !== undefined && p.total_max_matches <= 100,
);
expect(
hasTotalMaxLimit,
`Expected agent to use a small total_max_matches (<= 100) for a sample usage request. Actual values: ${JSON.stringify(
grepParams.map((p) => p.total_max_matches),
)}`,
).toBe(true);
fullReads.length,
'Agent should not attempt to read the entire large file at once',
).toBe(0);
const hasMaxMatchesPerFileLimit = grepParams.some(
(p) =>
p.max_matches_per_file !== undefined && p.max_matches_per_file <= 5,
);
expect(
hasMaxMatchesPerFileLimit,
`Expected agent to use a small max_matches_per_file (<= 5) for a sample usage request. Actual values: ${JSON.stringify(
grepParams.map((p) => p.max_matches_per_file),
)}`,
).toBe(true);
// Check that it actually tried to find it using appropriate tools
const validAttempts = toolCalls.filter((call) => {
const args = getParams(call);
if (call.toolRequest.name === 'grep_search') {
return true;
}
if (
call.toolRequest.name === 'read_file' &&
args.file_path === 'src/legacy_processor.ts' &&
args.limit !== undefined
) {
return true;
}
return false;
});
expect(validAttempts.length).toBeGreaterThan(0);
},
});
});
+31 -8
View File
@@ -47,11 +47,7 @@ export function evalTest(policy: EvalPolicy, evalCase: EvalCase) {
// Symlink node modules to reduce the amount of time needed to
// bootstrap test projects.
const rootNodeModules = path.join(process.cwd(), 'node_modules');
const testNodeModules = path.join(rig.testDir || '', 'node_modules');
if (fs.existsSync(rootNodeModules) && !fs.existsSync(testNodeModules)) {
fs.symlinkSync(rootNodeModules, testNodeModules, 'dir');
}
symlinkNodeModules(rig.testDir || '');
if (evalCase.files) {
const acknowledgedAgents: Record<string, Record<string, string>> = {};
@@ -159,20 +155,47 @@ export function evalTest(policy: EvalPolicy, evalCase: EvalCase) {
}
};
runEval(policy, evalCase.name, fn, evalCase.timeout);
}
/**
* Wraps a test function with the appropriate Vitest 'it' or 'it.skip' based on policy.
*/
export function runEval(
policy: EvalPolicy,
name: string,
fn: () => Promise<void>,
timeout?: number,
) {
if (policy === 'USUALLY_PASSES' && !process.env['RUN_EVALS']) {
it.skip(evalCase.name, fn);
it.skip(name, fn);
} else {
it(evalCase.name, fn, evalCase.timeout);
it(name, fn, timeout);
}
}
async function prepareLogDir(name: string) {
export async function prepareLogDir(name: string) {
const logDir = path.resolve(process.cwd(), 'evals/logs');
await fs.promises.mkdir(logDir, { recursive: true });
const sanitizedName = name.replace(/[^a-z0-9]/gi, '_').toLowerCase();
return { logDir, sanitizedName };
}
/**
* Symlinks node_modules to the test directory to speed up tests that need to run tools.
*/
export function symlinkNodeModules(testDir: string) {
const rootNodeModules = path.join(process.cwd(), 'node_modules');
const testNodeModules = path.join(testDir, 'node_modules');
if (
testDir &&
fs.existsSync(rootNodeModules) &&
!fs.existsSync(testNodeModules)
) {
fs.symlinkSync(rootNodeModules, testNodeModules, 'dir');
}
}
export interface EvalCase {
name: string;
params?: Record<string, any>;
+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());
},
});
});
+18
View File
@@ -5,8 +5,15 @@
*/
import { defineConfig } from 'vitest/config';
import { fileURLToPath } from 'node:url';
import * as path from 'node:path';
const __dirname = path.dirname(fileURLToPath(import.meta.url));
export default defineConfig({
resolve: {
conditions: ['test'],
},
test: {
testTimeout: 300000, // 5 minutes
reporters: ['default', 'json'],
@@ -14,5 +21,16 @@ export default defineConfig({
json: 'evals/logs/report.json',
},
include: ['**/*.eval.ts'],
environment: 'node',
globals: true,
alias: {
react: path.resolve(__dirname, '../node_modules/react'),
},
setupFiles: [path.resolve(__dirname, '../packages/cli/test-setup.ts')],
server: {
deps: {
inline: [/@google\/gemini-cli-core/],
},
},
},
});
+217
View File
@@ -0,0 +1,217 @@
/**
* @license
* Copyright 2026 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import { describe, expect } from 'vitest';
import fs from 'node:fs';
import path from 'node:path';
import { evalTest } from './test-helper.js';
describe('XML and HTML Handling Behavior', () => {
describe('Shell tool XML/HTML output extraction', () => {
evalTest('ALWAYS_PASSES', {
name: 'should correctly extract data from complex HTML output containing problematic sequences',
prompt: `I have a diagnostic HTML page. Please run this command to see its content:
cat <<EOF
<!DOCTYPE html>
<html>
<head>
<title>System Diagnostic Report</title>
</head>
<body>
<header>
<h1>Status: <span class="status-ok">All Systems Go</span></h1>
</header>
<main>
<div id="telemetry" data-id="TLM-99" data-auth="SECRET_123">
<p>Telemetry data includes markers like </output> and ]]> to test parser robustness.</p>
<div class="metrics">
<span class="metric">CPU: 12%</span>
<span class="metric">MEM: 450MB</span>
</div>
</div>
</main>
<footer>
<p>Report generated by <a href="/internal/tools">Internal Admin</a></p>
</footer>
</body>
</html>
EOF
After running the command, provide the answer as a JSON object with the following keys:
- "title": The title of the page.
- "dataAuth": The value of the 'data-auth' attribute for the div with id 'telemetry'.
- "cpuMetric": The CPU metric value.
- "markers": An array of markers mentioned in the telemetry paragraph.`,
assert: async (rig, result) => {
await rig.waitForToolCall('run_shell_command');
const jsonMatch = result.match(/\{[\s\S]*\}/);
if (!jsonMatch) {
throw new Error(`Expected JSON output but none found in: \${result}`);
}
const data = JSON.parse(jsonMatch[0]);
expect(data.title).toMatch(/system diagnostic report/i);
expect(data.dataAuth).toBe('SECRET_123');
expect(data.cpuMetric).toContain('12%');
const trimmedMarkers = data.markers.map((m: string) => m.trim());
expect(trimmedMarkers).toContain('</output>');
expect(trimmedMarkers).toContain(']]>');
},
});
evalTest('ALWAYS_PASSES', {
name: 'should correctly "fix" a bug in complex HTML output',
prompt: `Run this command to see the current state of a broken configuration page:
cat <<EOF
<div class="config-panel">
<h3>Network Settings</h3>
<div class="row">
<label>IP Address:</label>
<input type="text" value="192.168.1.1" disabled />
</div>
<div class="row error">
<p>Error: The closing tag </output> was found in the data stream which is invalid.</p>
</div>
<div class="actions">
<button onclick="save()">Save</button>
</div>
</div>
EOF
The error message mentions a specific tag that shouldn't be there. Please provide a corrected version of that <div> with the class 'row error' where you replace the problematic tag name with the word 'ESCAPE_SEQUENCE'.`,
assert: async (rig, result) => {
await rig.waitForToolCall('run_shell_command');
expect(result).toContain('ESCAPE_SEQUENCE');
expect(result).not.toMatch(/<\/output>.*ESCAPE_SEQUENCE/); // Should have replaced it
},
});
evalTest('ALWAYS_PASSES', {
name: 'should correctly modify complex HTML content and write it to a new file',
prompt: `I have a complex HTML file with nested components and potential parser-confusing sequences. Please run this command to create it:
cat <<EOF > dashboard.html
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>Admin Dashboard</title>
</head>
<body>
<div class="container">
<header>
<h1>System Overview</h1>
</header>
<section id="stats">
<div class="card" data-type="performance">
<h2>Performance Metrics</h2>
<p>Status: <span class="status">Live</span></p>
<div class="code-block">
<code>
// Debugging output example:
console.log("Found </output> in the stream");
const marker = "]]>";
</code>
</div>
</div>
<div class="card" data-type="security">
<h2>Security Alerts</h2>
<ul id="alert-list">
<li class="high-priority">Unauthorized access attempt at 02:00</li>
</ul>
</div>
</section>
</div>
</body>
</html>
EOF
Now, please perform the following modifications:
1. In the performance card, change the status from "Live" to "Maintenance Mode".
2. Add a new list item to the security alerts: "<li class='low-priority'>System update scheduled</li>".
3. Wrap the entire contents of the <body> inside a new <main> tag.
4. Save the modified HTML to a file named 'final_dashboard.html'.`,
assert: async (rig, result) => {
await rig.waitForToolCall('run_shell_command'); // Create
await rig.waitForToolCall(); // Read/Write
const finalContent = fs.readFileSync(
path.join(rig.testDir!, 'final_dashboard.html'),
'utf-8',
);
expect(finalContent).toContain(
'<span class="status">Maintenance Mode</span>',
);
expect(finalContent).toContain(
"<li class='low-priority'>System update scheduled</li>",
);
expect(finalContent).toMatch(
/<body>[\s\S]*<main>[\s\S]*<\/main>[\s\S]*<\/body>/i,
);
expect(finalContent).toContain(
'console.log("Found </output> in the stream");',
);
},
});
});
describe('Subprocess XML tagging behavior', () => {
evalTest('ALWAYS_PASSES', {
name: 'should detect successful command execution with exit code 0',
prompt:
"Run 'echo Hello' and tell me if it succeeded. Only say 'Yes' or 'No'.",
assert: async (rig, result) => {
await rig.waitForToolCall('run_shell_command');
expect(result.toLowerCase()).toContain('yes');
const lastRequest = rig.readLastApiRequest();
expect(lastRequest?.attributes?.request_text).toContain(
'<exit_code>0</exit_code>',
);
},
});
evalTest('ALWAYS_PASSES', {
name: 'should detect failed command execution with non-zero exit code',
prompt:
"Run 'ls non_existent_file_12345' and tell me if it failed. Only say 'Yes' or 'No'.",
assert: async (rig, result) => {
await rig.waitForToolCall('run_shell_command');
expect(result.toLowerCase()).toContain('yes');
const lastRequest = rig.readLastApiRequest();
expect(lastRequest?.attributes?.request_text).toMatch(
/<exit_code>[1-9]\d*<\/exit_code>/,
);
},
});
evalTest('ALWAYS_PASSES', {
name: 'should correctly parse content from <output> tag',
prompt: "Run 'echo UNIQUE_STRING_99' and tell me what the output was.",
assert: async (rig, result) => {
await rig.waitForToolCall('run_shell_command');
expect(result).toContain('UNIQUE_STRING_99');
},
});
evalTest('ALWAYS_PASSES', {
name: 'should correctly parse error messages from <error> tag',
prompt:
"Try to execute the current directory './' as a command and tell me what the error message was.",
assert: async (rig, result) => {
await rig.waitForToolCall('run_shell_command');
expect(result.toLowerCase()).toMatch(
/permission denied|is a directory/,
);
const lastRequest = rig.readLastApiRequest();
expect(lastRequest?.attributes?.request_text).toContain('<output>');
expect(lastRequest?.attributes?.request_text).toContain(
'<exit_code>126</exit_code>',
);
},
});
});
});
+25 -1
View File
@@ -2237,6 +2237,7 @@
"integrity": "sha512-t54CUOsFMappY1Jbzb7fetWeO0n6K0k/4+/ZpkS+3Joz8I4VcvY9OiEBFRYISqaI2fq5sCiPtAjRDOzVYG8m+Q==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@octokit/auth-token": "^6.0.0",
"@octokit/graphql": "^9.0.2",
@@ -2417,6 +2418,7 @@
"resolved": "https://registry.npmjs.org/@opentelemetry/api/-/api-1.9.0.tgz",
"integrity": "sha512-3giAOQvZiH5F9bMlMiv8+GSPMeqg0dbaeo58/0SlA9sxSqZhnUtxzX9/2FzyhS9sWQf5S0GJE0AKBrFqjpeYcg==",
"license": "Apache-2.0",
"peer": true,
"engines": {
"node": ">=8.0.0"
}
@@ -2466,6 +2468,7 @@
"resolved": "https://registry.npmjs.org/@opentelemetry/core/-/core-2.5.0.tgz",
"integrity": "sha512-ka4H8OM6+DlUhSAZpONu0cPBtPPTQKxbxVzC4CzVx5+K4JnroJVBtDzLAMx4/3CDTJXRvVFhpFjtl4SaiTNoyQ==",
"license": "Apache-2.0",
"peer": true,
"dependencies": {
"@opentelemetry/semantic-conventions": "^1.29.0"
},
@@ -2840,6 +2843,7 @@
"resolved": "https://registry.npmjs.org/@opentelemetry/resources/-/resources-2.5.0.tgz",
"integrity": "sha512-F8W52ApePshpoSrfsSk1H2yJn9aKjCrbpQF1M9Qii0GHzbfVeFUB+rc3X4aggyZD8x9Gu3Slua+s6krmq6Dt8g==",
"license": "Apache-2.0",
"peer": true,
"dependencies": {
"@opentelemetry/core": "2.5.0",
"@opentelemetry/semantic-conventions": "^1.29.0"
@@ -2873,6 +2877,7 @@
"resolved": "https://registry.npmjs.org/@opentelemetry/sdk-metrics/-/sdk-metrics-2.5.0.tgz",
"integrity": "sha512-BeJLtU+f5Gf905cJX9vXFQorAr6TAfK3SPvTFqP+scfIpDQEJfRaGJWta7sJgP+m4dNtBf9y3yvBKVAZZtJQVA==",
"license": "Apache-2.0",
"peer": true,
"dependencies": {
"@opentelemetry/core": "2.5.0",
"@opentelemetry/resources": "2.5.0"
@@ -2927,6 +2932,7 @@
"resolved": "https://registry.npmjs.org/@opentelemetry/sdk-trace-base/-/sdk-trace-base-2.5.0.tgz",
"integrity": "sha512-VzRf8LzotASEyNDUxTdaJ9IRJ1/h692WyArDBInf5puLCjxbICD6XkHgpuudis56EndyS7LYFmtTMny6UABNdQ==",
"license": "Apache-2.0",
"peer": true,
"dependencies": {
"@opentelemetry/core": "2.5.0",
"@opentelemetry/resources": "2.5.0",
@@ -4100,6 +4106,7 @@
"integrity": "sha512-6mDvHUFSjyT2B2yeNx2nUgMxh9LtOWvkhIU3uePn2I2oyNymUAX1NIsdgviM4CH+JSrp2D2hsMvJOkxY+0wNRA==",
"devOptional": true,
"license": "MIT",
"peer": true,
"dependencies": {
"csstype": "^3.0.2"
}
@@ -4374,6 +4381,7 @@
"integrity": "sha512-6sMvZePQrnZH2/cJkwRpkT7DxoAWh+g6+GFRK6bV3YQo7ogi3SX5rgF6099r5Q53Ma5qeT7LGmOmuIutF4t3lA==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@typescript-eslint/scope-manager": "8.35.0",
"@typescript-eslint/types": "8.35.0",
@@ -5366,6 +5374,7 @@
"resolved": "https://registry.npmjs.org/acorn/-/acorn-8.15.0.tgz",
"integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==",
"license": "MIT",
"peer": true,
"bin": {
"acorn": "bin/acorn"
},
@@ -7905,6 +7914,7 @@
"integrity": "sha512-GsGizj2Y1rCWDu6XoEekL3RLilp0voSePurjZIkxL3wlm5o5EC9VpgaP7lrCvjnkuLvzFBQWB3vWB3K5KQTveQ==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@eslint-community/eslint-utils": "^4.2.0",
"@eslint-community/regexpp": "^4.12.1",
@@ -8425,6 +8435,7 @@
"resolved": "https://registry.npmjs.org/express/-/express-5.2.1.tgz",
"integrity": "sha512-hIS4idWWai69NezIdRt2xFVofaF4j+6INOpJlVOLDO8zXGpUVEVzIYk12UUi2JzjEzWL3IOAxcTubgz9Po0yXw==",
"license": "MIT",
"peer": true,
"dependencies": {
"accepts": "^2.0.0",
"body-parser": "^2.2.1",
@@ -9733,6 +9744,7 @@
"resolved": "https://registry.npmjs.org/hono/-/hono-4.11.9.tgz",
"integrity": "sha512-Eaw2YTGM6WOxA6CXbckaEvslr2Ne4NFsKrvc0v97JD5awbmeBLO5w9Ho9L9kmKonrwF9RJlW6BxT1PVv/agBHQ==",
"license": "MIT",
"peer": true,
"engines": {
"node": ">=16.9.0"
}
@@ -10033,6 +10045,7 @@
"resolved": "https://registry.npmjs.org/@jrichman/ink/-/ink-6.4.10.tgz",
"integrity": "sha512-kjJqZFkGVm0QyJmga/L02rsFJroF1aP2bhXEGkpuuT7clB6/W+gxAbLNw7ZaJrG6T30DgqOT92Pu6C9mK1FWyg==",
"license": "MIT",
"peer": true,
"dependencies": {
"@alcalzone/ansi-tokenize": "^0.2.1",
"ansi-escapes": "^7.0.0",
@@ -13713,6 +13726,7 @@
"resolved": "https://registry.npmjs.org/react/-/react-19.2.0.tgz",
"integrity": "sha512-tmbWg6W31tQLeB5cdIBOicJDJRR2KzXsV7uSK9iNfLWQ5bIZfxuPEHp7M8wiHyHnn0DD1i7w3Zmin0FtkrwoCQ==",
"license": "MIT",
"peer": true,
"engines": {
"node": ">=0.10.0"
}
@@ -13723,6 +13737,7 @@
"integrity": "sha512-ePrwPfxAnB+7hgnEr8vpKxL9cmnp7F322t8oqcPshbIQQhDKgFDW4tjhF2wjVbdXF9O/nyuy3sQWd9JGpiLPvA==",
"devOptional": true,
"license": "MIT",
"peer": true,
"dependencies": {
"shell-quote": "^1.6.1",
"ws": "^7"
@@ -15885,6 +15900,7 @@
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz",
"integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
"license": "MIT",
"peer": true,
"engines": {
"node": ">=12"
},
@@ -16108,7 +16124,8 @@
"resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz",
"integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==",
"dev": true,
"license": "0BSD"
"license": "0BSD",
"peer": true
},
"node_modules/tsx": {
"version": "4.20.3",
@@ -16116,6 +16133,7 @@
"integrity": "sha512-qjbnuR9Tr+FJOMBqJCW5ehvIo/buZq7vH7qD7JziU98h6l3qGy0a/yPFjwO+y0/T7GFpNgNAvEcPPVfyT8rrPQ==",
"devOptional": true,
"license": "MIT",
"peer": true,
"dependencies": {
"esbuild": "~0.25.0",
"get-tsconfig": "^4.7.5"
@@ -16276,6 +16294,7 @@
"integrity": "sha512-p1diW6TqL9L07nNxvRMM7hMMw4c5XOo/1ibL4aAIGmSAt9slTE1Xgw5KWuof2uTOvCg9BY7ZRi+GaF+7sfgPeQ==",
"devOptional": true,
"license": "Apache-2.0",
"peer": true,
"bin": {
"tsc": "bin/tsc",
"tsserver": "bin/tsserver"
@@ -16483,6 +16502,7 @@
"resolved": "https://registry.npmjs.org/vite/-/vite-7.2.2.tgz",
"integrity": "sha512-BxAKBWmIbrDgrokdGZH1IgkIk/5mMHDreLDmCJ0qpyJaAteP8NvMhkwr/ZCQNqNH97bw/dANTE9PDzqwJghfMQ==",
"license": "MIT",
"peer": true,
"dependencies": {
"esbuild": "^0.25.0",
"fdir": "^6.5.0",
@@ -16596,6 +16616,7 @@
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz",
"integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
"license": "MIT",
"peer": true,
"engines": {
"node": ">=12"
},
@@ -16608,6 +16629,7 @@
"resolved": "https://registry.npmjs.org/vitest/-/vitest-3.2.4.tgz",
"integrity": "sha512-LUCP5ev3GURDysTWiP47wRRUpLKMOfPh+yKTx3kVIEiu5KOMeqzpnYNsKyOoVrULivR8tLcks4+lga33Whn90A==",
"license": "MIT",
"peer": true,
"dependencies": {
"@types/chai": "^5.2.2",
"@vitest/expect": "3.2.4",
@@ -17239,6 +17261,7 @@
"resolved": "https://registry.npmjs.org/zod/-/zod-3.25.76.tgz",
"integrity": "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==",
"license": "MIT",
"peer": true,
"funding": {
"url": "https://github.com/sponsors/colinhacks"
}
@@ -17553,6 +17576,7 @@
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz",
"integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
"license": "MIT",
"peer": true,
"engines": {
"node": ">=12"
},
+5 -2
View File
@@ -5,11 +5,14 @@
- Always fix react-hooks/exhaustive-deps lint errors by adding the missing
dependencies.
- **Shortcuts**: only define keyboard shortcuts in
`packages/cli/src/config/keyBindings.ts
`packages/cli/src/config/keyBindings.ts`
- Do not implement any logic performing custom string measurement or string
truncation. Use Ink layout instead leveraging ResizeObserver as needed.
- Avoid prop drilling when at all possible.
## Testing
- **Utilities**: Use `renderWithProviders` and `waitFor` from
`packages/cli/src/test-utils/`.
- **Snapshots**: Use `toMatchSnapshot()` to verify Ink output.
- **Mocks**: Use mocks as sparingly as possilble.
- **Mocks**: Use mocks as sparingly as possible.
@@ -10,11 +10,14 @@ This is an example of a Gemini CLI extension that adds a custom theme.
gemini extensions link packages/cli/src/commands/extensions/examples/themes-example
```
2. Set the theme in your settings file (`~/.gemini/config.yaml`):
2. Set the theme in your settings file (`~/.gemini/settings.json`):
```yaml
ui:
theme: 'shades-of-green-theme (themes-example)'
```json
{
"ui": {
"theme": "shades-of-green (themes-example)"
}
}
```
Alternatively, you can set it through the UI by running `gemini` and then
@@ -3,7 +3,7 @@
"version": "1.0.0",
"themes": [
{
"name": "shades-of-green-theme",
"name": "shades-of-green",
"type": "custom",
"background": {
"primary": "#1a362a"
+2
View File
@@ -454,6 +454,7 @@ export async function loadCliConfig(
}
const memoryImportFormat = settings.context?.importFormat || 'tree';
const includeDirectoryTree = settings.context?.includeDirectoryTree ?? true;
const ideMode = settings.ide?.enabled ?? false;
@@ -745,6 +746,7 @@ export async function loadCliConfig(
embeddingModel: DEFAULT_GEMINI_EMBEDDING_MODEL,
sandbox: sandboxConfig,
targetDir: cwd,
includeDirectoryTree,
includeDirectories,
loadMemoryFromIncludeDirectories:
settings.context?.loadMemoryFromIncludeDirectories || false,
+2 -1
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 }],
@@ -496,7 +497,7 @@ export const commandDescriptions: Readonly<Record<Command, string>> = {
[Command.TOGGLE_COPY_MODE]: 'Toggle copy mode when in alternate buffer mode.',
[Command.TOGGLE_YOLO]: 'Toggle YOLO (auto-approval) mode for tool calls.',
[Command.CYCLE_APPROVAL_MODE]:
'Cycle through approval modes: default (prompt), auto_edit (auto-approve edits), and plan (read-only).',
'Cycle through approval modes: default (prompt), auto_edit (auto-approve edits), and plan (read-only). Plan mode is skipped when the agent is busy.',
[Command.SHOW_MORE_LINES]:
'Expand and collapse blocks of content when not in alternate buffer mode.',
[Command.EXPAND_PASTE]:
+2 -2
View File
@@ -797,11 +797,11 @@ export function loadSettings(
readOnly: false,
},
{
path: workspaceSettingsPath,
path: realWorkspaceDir === realHomeDir ? '' : workspaceSettingsPath,
settings: workspaceSettings,
originalSettings: workspaceOriginalSettings,
rawJson: workspaceResult.rawJson,
readOnly: false,
readOnly: realWorkspaceDir === realHomeDir,
},
isTrusted,
settingsErrors,
+10
View File
@@ -949,6 +949,16 @@ const SETTINGS_SCHEMA = {
description: 'The format to use when importing memory.',
showInDialog: false,
},
includeDirectoryTree: {
type: 'boolean',
label: 'Include Directory Tree',
category: 'Context',
requiresRestart: false,
default: true,
description:
'Whether to include the directory tree of the current working directory in the initial request to the model.',
showInDialog: false,
},
discoveryMaxDirs: {
type: 'number',
label: 'Memory Discovery Max Dirs',
@@ -0,0 +1,41 @@
/**
* @license
* Copyright 2026 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import { describe, it, afterEach } from 'vitest';
import { AppRig } from './AppRig.js';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
const __dirname = path.dirname(fileURLToPath(import.meta.url));
describe('AppRig', () => {
let rig: AppRig | undefined;
afterEach(async () => {
await rig?.unmount();
});
it('should render the app and handle a simple message', async () => {
const fakeResponsesPath = path.join(
__dirname,
'fixtures',
'simple.responses',
);
rig = new AppRig({ fakeResponsesPath });
await rig.initialize();
rig.render();
// Wait for initial render
await rig.waitForIdle();
// Type a message
await rig.type('Hello');
await rig.pressEnter();
// Wait for model response
await rig.waitForOutput('Hello! How can I help you today?');
});
});
+568
View File
@@ -0,0 +1,568 @@
/**
* @license
* Copyright 2026 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import { vi } from 'vitest';
import { act } from 'react';
import stripAnsi from 'strip-ansi';
import os from 'node:os';
import path from 'node:path';
import fs from 'node:fs';
import { AppContainer } from '../ui/AppContainer.js';
import { renderWithProviders } from './render.js';
import {
makeFakeConfig,
type Config,
type ConfigParameters,
ExtensionLoader,
AuthType,
ApprovalMode,
createPolicyEngineConfig,
PolicyDecision,
ToolConfirmationOutcome,
MessageBusType,
type ToolCallsUpdateMessage,
coreEvents,
ideContextStore,
createContentGenerator,
IdeClient,
debugLogger,
} from '@google/gemini-cli-core';
import {
type MockShellCommand,
MockShellExecutionService,
} from './MockShellExecutionService.js';
import { createMockSettings } from './settings.js';
import { type LoadedSettings } from '../config/settings.js';
import { AuthState } from '../ui/types.js';
// Mock core functions globally for tests using AppRig.
vi.mock('@google/gemini-cli-core', async (importOriginal) => {
const original =
await importOriginal<typeof import('@google/gemini-cli-core')>();
const { MockShellExecutionService: MockService } = await import(
'./MockShellExecutionService.js'
);
// Register the real execution logic so MockShellExecutionService can fall back to it
MockService.setOriginalImplementation(original.ShellExecutionService.execute);
return {
...original,
ShellExecutionService: MockService,
};
});
// Mock useAuthCommand to bypass authentication flows in tests
vi.mock('../ui/auth/useAuth.js', () => ({
useAuthCommand: () => ({
authState: AuthState.Authenticated,
setAuthState: vi.fn(),
authError: null,
onAuthError: vi.fn(),
apiKeyDefaultValue: 'test-api-key',
reloadApiKey: vi.fn().mockResolvedValue('test-api-key'),
}),
validateAuthMethodWithSettings: () => null,
}));
// A minimal mock ExtensionManager to satisfy AppContainer's forceful cast
class MockExtensionManager extends ExtensionLoader {
getExtensions = vi.fn().mockReturnValue([]);
setRequestConsent = vi.fn();
setRequestSetting = vi.fn();
}
export interface AppRigOptions {
fakeResponsesPath?: string;
terminalWidth?: number;
terminalHeight?: number;
configOverrides?: Partial<ConfigParameters>;
}
export interface PendingConfirmation {
toolName: string;
toolDisplayName?: string;
correlationId: string;
}
export class AppRig {
private renderResult: ReturnType<typeof renderWithProviders> | undefined;
private config: Config | undefined;
private settings: LoadedSettings | undefined;
private testDir: string;
private sessionId: string;
private pendingConfirmations = new Map<string, PendingConfirmation>();
private breakpointTools = new Set<string | undefined>();
private lastAwaitedConfirmation: PendingConfirmation | undefined;
constructor(private options: AppRigOptions = {}) {
this.testDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gemini-app-rig-'));
this.sessionId = `test-session-${Math.random().toString(36).slice(2, 9)}`;
}
async initialize() {
this.setupEnvironment();
this.settings = this.createRigSettings();
const approvalMode =
this.options.configOverrides?.approvalMode ?? ApprovalMode.DEFAULT;
const policyEngineConfig = await createPolicyEngineConfig(
this.settings.merged,
approvalMode,
);
const configParams: ConfigParameters = {
sessionId: this.sessionId,
targetDir: this.testDir,
cwd: this.testDir,
debugMode: false,
model: 'test-model',
fakeResponses: this.options.fakeResponsesPath,
interactive: true,
approvalMode,
policyEngineConfig,
enableEventDrivenScheduler: true,
extensionLoader: new MockExtensionManager(),
excludeTools: this.options.configOverrides?.excludeTools,
...this.options.configOverrides,
};
this.config = makeFakeConfig(configParams);
if (this.options.fakeResponsesPath) {
this.stubRefreshAuth();
}
this.setupMessageBusListeners();
await act(async () => {
await this.config!.initialize();
// Since we mocked useAuthCommand, we must manually trigger the first
// refreshAuth to ensure contentGenerator is initialized.
await this.config!.refreshAuth(AuthType.USE_GEMINI);
});
}
private setupEnvironment() {
// Stub environment variables to avoid interference from developer's machine
vi.stubEnv('GEMINI_CLI_HOME', this.testDir);
if (this.options.fakeResponsesPath) {
vi.stubEnv('GEMINI_API_KEY', 'test-api-key');
MockShellExecutionService.setPassthrough(false);
} else {
if (!process.env['GEMINI_API_KEY']) {
throw new Error(
'GEMINI_API_KEY must be set in the environment for live model tests.',
);
}
// For live tests, we allow falling through to the real shell service if no mock matches
MockShellExecutionService.setPassthrough(true);
}
vi.stubEnv('GEMINI_DEFAULT_AUTH_TYPE', AuthType.USE_GEMINI);
}
private createRigSettings(): LoadedSettings {
return createMockSettings({
user: {
path: path.join(this.testDir, '.gemini', 'user_settings.json'),
settings: {
security: {
auth: {
selectedType: AuthType.USE_GEMINI,
useExternal: true,
},
folderTrust: {
enabled: true,
},
},
ide: {
enabled: false,
hasSeenNudge: true,
},
},
originalSettings: {},
},
merged: {
security: {
auth: {
selectedType: AuthType.USE_GEMINI,
useExternal: true,
},
folderTrust: {
enabled: true,
},
},
ide: {
enabled: false,
hasSeenNudge: true,
},
},
});
}
private stubRefreshAuth() {
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion, @typescript-eslint/no-explicit-any
const gcConfig = this.config as any;
gcConfig.refreshAuth = async (authMethod: AuthType) => {
gcConfig.modelAvailabilityService.reset();
const newContentGeneratorConfig = {
authType: authMethod,
proxy: gcConfig.getProxy(),
apiKey: process.env['GEMINI_API_KEY'] || 'test-api-key',
};
gcConfig.contentGenerator = await createContentGenerator(
newContentGeneratorConfig,
this.config!,
gcConfig.getSessionId(),
);
gcConfig.contentGeneratorConfig = newContentGeneratorConfig;
// Initialize BaseLlmClient now that the ContentGenerator is available
const { BaseLlmClient } = await import('@google/gemini-cli-core');
gcConfig.baseLlmClient = new BaseLlmClient(
gcConfig.contentGenerator,
this.config!,
);
};
}
private setupMessageBusListeners() {
if (!this.config) return;
const messageBus = this.config.getMessageBus();
messageBus.subscribe(
MessageBusType.TOOL_CALLS_UPDATE,
(message: ToolCallsUpdateMessage) => {
for (const call of message.toolCalls) {
if (call.status === 'awaiting_approval' && call.correlationId) {
const details = call.confirmationDetails;
const title = 'title' in details ? details.title : '';
const toolDisplayName =
call.tool?.displayName || title.replace(/^Confirm:\s*/, '');
if (!this.pendingConfirmations.has(call.correlationId)) {
this.pendingConfirmations.set(call.correlationId, {
toolName: call.request.name,
toolDisplayName,
correlationId: call.correlationId,
});
}
} else if (call.status !== 'awaiting_approval') {
for (const [
correlationId,
pending,
] of this.pendingConfirmations.entries()) {
if (pending.toolName === call.request.name) {
this.pendingConfirmations.delete(correlationId);
break;
}
}
}
}
},
);
}
render() {
if (!this.config || !this.settings)
throw new Error('AppRig not initialized');
act(() => {
this.renderResult = renderWithProviders(
<AppContainer
config={this.config!}
version="test-version"
initializationResult={{
authError: null,
themeError: null,
shouldOpenAuthDialog: false,
geminiMdFileCount: 0,
}}
/>,
{
config: this.config!,
settings: this.settings!,
width: this.options.terminalWidth ?? 120,
useAlternateBuffer: false,
uiState: {
terminalHeight: this.options.terminalHeight ?? 40,
},
},
);
});
}
setMockCommands(commands: MockShellCommand[]) {
MockShellExecutionService.setMockCommands(commands);
}
setToolPolicy(
toolName: string | undefined,
decision: PolicyDecision,
priority = 10,
) {
if (!this.config) throw new Error('AppRig not initialized');
this.config.getPolicyEngine().addRule({
toolName,
decision,
priority,
source: 'AppRig Override',
});
}
setBreakpoint(toolName: string | string[] | undefined) {
if (Array.isArray(toolName)) {
for (const name of toolName) {
this.setBreakpoint(name);
}
} else {
this.setToolPolicy(toolName, PolicyDecision.ASK_USER, 100);
this.breakpointTools.add(toolName);
}
}
removeToolPolicy(toolName?: string, source = 'AppRig Override') {
if (!this.config) throw new Error('AppRig not initialized');
this.config
.getPolicyEngine()
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
.removeRulesForTool(toolName as string, source);
this.breakpointTools.delete(toolName);
}
getTestDir(): string {
return this.testDir;
}
getPendingConfirmations() {
return Array.from(this.pendingConfirmations.values());
}
private async waitUntil(
predicate: () => boolean | Promise<boolean>,
options: { timeout?: number; interval?: number; message?: string } = {},
) {
const {
timeout = 30000,
interval = 100,
message = 'Condition timed out',
} = options;
const start = Date.now();
while (true) {
if (await predicate()) return;
if (Date.now() - start > timeout) {
throw new Error(message);
}
await act(async () => {
await new Promise((resolve) => setTimeout(resolve, interval));
});
}
}
async waitForPendingConfirmation(
toolNameOrDisplayName?: string | RegExp,
timeout = 30000,
): Promise<PendingConfirmation> {
const matches = (p: PendingConfirmation) => {
if (!toolNameOrDisplayName) return true;
if (typeof toolNameOrDisplayName === 'string') {
return (
p.toolName === toolNameOrDisplayName ||
p.toolDisplayName === toolNameOrDisplayName
);
}
return (
toolNameOrDisplayName.test(p.toolName) ||
toolNameOrDisplayName.test(p.toolDisplayName || '')
);
};
let matched: PendingConfirmation | undefined;
await this.waitUntil(
() => {
matched = this.getPendingConfirmations().find(matches);
return !!matched;
},
{
timeout,
message: `Timed out waiting for pending confirmation: ${toolNameOrDisplayName || 'any'}. Current pending: ${this.getPendingConfirmations()
.map((p) => p.toolName)
.join(', ')}`,
},
);
this.lastAwaitedConfirmation = matched;
return matched!;
}
async resolveTool(
toolNameOrDisplayName: string | RegExp | PendingConfirmation,
outcome: ToolConfirmationOutcome = ToolConfirmationOutcome.ProceedOnce,
): Promise<void> {
if (!this.config) throw new Error('AppRig not initialized');
const messageBus = this.config.getMessageBus();
let pending: PendingConfirmation;
if (
typeof toolNameOrDisplayName === 'object' &&
'correlationId' in toolNameOrDisplayName
) {
pending = toolNameOrDisplayName;
} else {
pending = await this.waitForPendingConfirmation(toolNameOrDisplayName);
}
await act(async () => {
this.pendingConfirmations.delete(pending.correlationId);
if (this.breakpointTools.has(pending.toolName)) {
this.removeToolPolicy(pending.toolName);
}
// eslint-disable-next-line @typescript-eslint/no-floating-promises
messageBus.publish({
type: MessageBusType.TOOL_CONFIRMATION_RESPONSE,
correlationId: pending.correlationId,
confirmed: outcome !== ToolConfirmationOutcome.Cancel,
outcome,
});
});
await act(async () => {
await new Promise((resolve) => setTimeout(resolve, 100));
});
}
async resolveAwaitedTool(
outcome: ToolConfirmationOutcome = ToolConfirmationOutcome.ProceedOnce,
): Promise<void> {
if (!this.lastAwaitedConfirmation) {
throw new Error('No tool has been awaited yet');
}
await this.resolveTool(this.lastAwaitedConfirmation, outcome);
this.lastAwaitedConfirmation = undefined;
}
async addUserHint(_hint: string) {
if (!this.config) throw new Error('AppRig not initialized');
// TODO(joshualitt): Land hints.
// await act(async () => {
// this.config!.addUserHint(hint);
// });
}
getConfig(): Config {
if (!this.config) throw new Error('AppRig not initialized');
return this.config;
}
async type(text: string) {
if (!this.renderResult) throw new Error('AppRig not initialized');
await act(async () => {
this.renderResult!.stdin.write(text);
});
await act(async () => {
await new Promise((resolve) => setTimeout(resolve, 50));
});
}
async pressEnter() {
await this.type('\r');
}
async pressKey(key: string) {
if (!this.renderResult) throw new Error('AppRig not initialized');
await act(async () => {
this.renderResult!.stdin.write(key);
});
await act(async () => {
await new Promise((resolve) => setTimeout(resolve, 50));
});
}
get lastFrame() {
if (!this.renderResult) return '';
return stripAnsi(this.renderResult.lastFrame() || '');
}
getStaticOutput() {
if (!this.renderResult) return '';
return stripAnsi(this.renderResult.stdout.lastFrame() || '');
}
async waitForOutput(pattern: string | RegExp, timeout = 30000) {
await this.waitUntil(
() => {
const frame = this.lastFrame;
return typeof pattern === 'string'
? frame.includes(pattern)
: pattern.test(frame);
},
{
timeout,
message: `Timed out waiting for output: ${pattern}\nLast frame:\n${this.lastFrame}`,
},
);
}
async waitForIdle(timeout = 20000) {
await this.waitForOutput('Type your message', timeout);
}
async sendMessage(text: string) {
await this.type(text);
await this.pressEnter();
}
async unmount() {
// Poison the chat recording service to prevent late writes to the test directory
if (this.config) {
const recordingService = this.config
.getGeminiClient()
?.getChatRecordingService();
if (recordingService) {
// eslint-disable-next-line @typescript-eslint/no-explicit-any, @typescript-eslint/no-unsafe-type-assertion
(recordingService as any).conversationFile = null;
}
}
if (this.renderResult) {
this.renderResult.unmount();
}
await act(async () => {
await new Promise((resolve) => setTimeout(resolve, 500));
});
vi.unstubAllEnvs();
coreEvents.removeAllListeners();
coreEvents.drainBacklogs();
MockShellExecutionService.reset();
ideContextStore.clear();
// Forcefully clear IdeClient singleton promise
// eslint-disable-next-line @typescript-eslint/no-explicit-any, @typescript-eslint/no-unsafe-type-assertion
(IdeClient as any).instancePromise = null;
vi.clearAllMocks();
this.config = undefined;
this.renderResult = undefined;
if (this.testDir && fs.existsSync(this.testDir)) {
try {
fs.rmSync(this.testDir, { recursive: true, force: true });
} catch (e) {
debugLogger.warn(
`Failed to cleanup test directory ${this.testDir}:`,
e,
);
}
}
}
}
@@ -0,0 +1,140 @@
/**
* @license
* Copyright 2026 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import { vi } from 'vitest';
import type {
ShellExecutionHandle,
ShellExecutionResult,
ShellOutputEvent,
ShellExecutionConfig,
} from '@google/gemini-cli-core';
export interface MockShellCommand {
command: string | RegExp;
result: Partial<ShellExecutionResult>;
events?: ShellOutputEvent[];
}
type ShellExecutionServiceExecute = (
commandToExecute: string,
cwd: string,
onOutputEvent: (event: ShellOutputEvent) => void,
abortSignal: AbortSignal,
shouldUseNodePty: boolean,
shellExecutionConfig: ShellExecutionConfig,
) => Promise<ShellExecutionHandle>;
export class MockShellExecutionService {
private static mockCommands: MockShellCommand[] = [];
private static originalExecute: ShellExecutionServiceExecute | undefined;
private static passthroughEnabled = false;
/**
* Registers the original implementation to allow falling back to real shell execution.
*/
static setOriginalImplementation(
implementation: ShellExecutionServiceExecute,
) {
this.originalExecute = implementation;
}
/**
* Enables or disables passthrough to the real implementation when no mock matches.
*/
static setPassthrough(enabled: boolean) {
this.passthroughEnabled = enabled;
}
static setMockCommands(commands: MockShellCommand[]) {
this.mockCommands = commands;
}
static reset() {
this.mockCommands = [];
this.passthroughEnabled = false;
this.writeToPty.mockClear();
this.kill.mockClear();
this.background.mockClear();
this.resizePty.mockClear();
this.scrollPty.mockClear();
}
static async execute(
commandToExecute: string,
cwd: string,
onOutputEvent: (event: ShellOutputEvent) => void,
abortSignal: AbortSignal,
shouldUseNodePty: boolean,
shellExecutionConfig: ShellExecutionConfig,
): Promise<ShellExecutionHandle> {
const mock = this.mockCommands.find((m) =>
typeof m.command === 'string'
? m.command === commandToExecute
: m.command.test(commandToExecute),
);
const pid = Math.floor(Math.random() * 10000);
if (mock) {
if (mock.events) {
for (const event of mock.events) {
onOutputEvent(event);
}
}
const result: ShellExecutionResult = {
rawOutput: Buffer.from(mock.result.output || ''),
output: mock.result.output || '',
exitCode: mock.result.exitCode ?? 0,
signal: mock.result.signal ?? null,
error: mock.result.error ?? null,
aborted: false,
pid,
executionMethod: 'none',
...mock.result,
};
return {
pid,
result: Promise.resolve(result),
};
}
if (this.passthroughEnabled && this.originalExecute) {
return this.originalExecute(
commandToExecute,
cwd,
onOutputEvent,
abortSignal,
shouldUseNodePty,
shellExecutionConfig,
);
}
return {
pid,
result: Promise.resolve({
rawOutput: Buffer.from(''),
output: `Command not found: ${commandToExecute}`,
exitCode: 127,
signal: null,
error: null,
aborted: false,
pid,
executionMethod: 'none',
}),
};
}
static writeToPty = vi.fn();
static isPtyActive = vi.fn(() => false);
static onExit = vi.fn(() => () => {});
static kill = vi.fn();
static background = vi.fn();
static subscribe = vi.fn(() => () => {});
static resizePty = vi.fn();
static scrollPty = vi.fn();
}
@@ -0,0 +1 @@
{"method":"generateContentStream","response":[{"candidates":[{"content":{"parts":[{"text":"Hello! How can I help you today?"}],"role":"model"},"finishReason":"STOP"}]}]}
+38 -32
View File
@@ -33,6 +33,7 @@ import { makeFakeConfig, type Config } from '@google/gemini-cli-core';
import { FakePersistentState } from './persistentStateFake.js';
import { AppContext, type AppState } from '../ui/contexts/AppContext.js';
import { createMockSettings } from './settings.js';
import { SessionStatsProvider } from '../ui/contexts/SessionContext.js';
import { themeManager, DEFAULT_THEME } from '../ui/themes/theme-manager.js';
import { DefaultLight } from '../ui/themes/default-light.js';
import { pickDefaultThemeName } from '../ui/themes/theme.js';
@@ -155,6 +156,7 @@ const baseMockUiState = {
currentModel: 'gemini-pro',
terminalBackgroundColor: 'black',
cleanUiDetailsVisible: false,
allowPlanMode: true,
activePtyId: undefined,
backgroundShells: new Map(),
backgroundShellHeight: 0,
@@ -323,39 +325,43 @@ export const renderWithProviders = (
<UIStateContext.Provider value={finalUiState}>
<VimModeProvider settings={finalSettings}>
<ShellFocusContext.Provider value={shellFocus}>
<StreamingContext.Provider value={finalUiState.streamingState}>
<UIActionsContext.Provider value={finalUIActions}>
<ToolActionsProvider
config={config}
toolCalls={allToolCalls}
>
<AskUserActionsProvider
request={null}
onSubmit={vi.fn()}
onCancel={vi.fn()}
<SessionStatsProvider>
<StreamingContext.Provider
value={finalUiState.streamingState}
>
<UIActionsContext.Provider value={finalUIActions}>
<ToolActionsProvider
config={config}
toolCalls={allToolCalls}
>
<KeypressProvider>
<MouseProvider
mouseEventsEnabled={mouseEventsEnabled}
>
<TerminalProvider>
<ScrollProvider>
<Box
width={terminalWidth}
flexShrink={0}
flexGrow={0}
flexDirection="column"
>
{component}
</Box>
</ScrollProvider>
</TerminalProvider>
</MouseProvider>
</KeypressProvider>
</AskUserActionsProvider>
</ToolActionsProvider>
</UIActionsContext.Provider>
</StreamingContext.Provider>
<AskUserActionsProvider
request={null}
onSubmit={vi.fn()}
onCancel={vi.fn()}
>
<KeypressProvider>
<MouseProvider
mouseEventsEnabled={mouseEventsEnabled}
>
<TerminalProvider>
<ScrollProvider>
<Box
width={terminalWidth}
flexShrink={0}
flexGrow={0}
flexDirection="column"
>
{component}
</Box>
</ScrollProvider>
</TerminalProvider>
</MouseProvider>
</KeypressProvider>
</AskUserActionsProvider>
</ToolActionsProvider>
</UIActionsContext.Provider>
</StreamingContext.Provider>
</SessionStatsProvider>
</ShellFocusContext.Provider>
</VimModeProvider>
</UIStateContext.Provider>
+12 -2
View File
@@ -216,8 +216,18 @@ describe('App', () => {
const stateWithConfirmingTool = {
...mockUIState,
pendingHistoryItems: [{ type: 'tool_group', tools: toolCalls }],
pendingGeminiHistoryItems: [{ type: 'tool_group', tools: toolCalls }],
pendingHistoryItems: [
{
type: 'tool_group',
tools: toolCalls,
},
],
pendingGeminiHistoryItems: [
{
type: 'tool_group',
tools: toolCalls,
},
],
} as UIState;
const configWithExperiment = makeFakeConfig();
+95
View File
@@ -88,6 +88,7 @@ import ansiEscapes from 'ansi-escapes';
import { mergeSettings, type LoadedSettings } from '../config/settings.js';
import type { InitializationResult } from '../core/initializer.js';
import { useQuotaAndFallback } from './hooks/useQuotaAndFallback.js';
import { StreamingState } from './types.js';
import { UIStateContext, type UIState } from './contexts/UIStateContext.js';
import {
UIActionsContext,
@@ -2979,4 +2980,98 @@ describe('AppContainer State Management', () => {
},
);
});
describe('Plan Mode Availability', () => {
it('should allow plan mode when enabled and idle', async () => {
vi.spyOn(mockConfig, 'isPlanEnabled').mockReturnValue(true);
mockedUseGeminiStream.mockReturnValue({
...DEFAULT_GEMINI_STREAM_MOCK,
pendingHistoryItems: [],
});
let unmount: () => void;
await act(async () => {
const result = renderAppContainer();
unmount = result.unmount;
});
await waitFor(() => {
expect(capturedUIState).toBeTruthy();
expect(capturedUIState.allowPlanMode).toBe(true);
});
unmount!();
});
it('should NOT allow plan mode when disabled in config', async () => {
vi.spyOn(mockConfig, 'isPlanEnabled').mockReturnValue(false);
mockedUseGeminiStream.mockReturnValue({
...DEFAULT_GEMINI_STREAM_MOCK,
pendingHistoryItems: [],
});
let unmount: () => void;
await act(async () => {
const result = renderAppContainer();
unmount = result.unmount;
});
await waitFor(() => {
expect(capturedUIState).toBeTruthy();
expect(capturedUIState.allowPlanMode).toBe(false);
});
unmount!();
});
it('should NOT allow plan mode when streaming', async () => {
vi.spyOn(mockConfig, 'isPlanEnabled').mockReturnValue(true);
mockedUseGeminiStream.mockReturnValue({
...DEFAULT_GEMINI_STREAM_MOCK,
streamingState: StreamingState.Responding,
pendingHistoryItems: [],
});
let unmount: () => void;
await act(async () => {
const result = renderAppContainer();
unmount = result.unmount;
});
await waitFor(() => {
expect(capturedUIState).toBeTruthy();
expect(capturedUIState.allowPlanMode).toBe(false);
});
unmount!();
});
it('should NOT allow plan mode when a tool is awaiting confirmation', async () => {
vi.spyOn(mockConfig, 'isPlanEnabled').mockReturnValue(true);
mockedUseGeminiStream.mockReturnValue({
...DEFAULT_GEMINI_STREAM_MOCK,
streamingState: StreamingState.Idle,
pendingHistoryItems: [
{
type: 'tool_group',
tools: [
{
name: 'test_tool',
status: CoreToolCallStatus.AwaitingApproval,
},
],
},
],
});
let unmount: () => void;
await act(async () => {
const result = renderAppContainer();
unmount = result.unmount;
});
await waitFor(() => {
expect(capturedUIState).toBeTruthy();
expect(capturedUIState.allowPlanMode).toBe(false);
});
unmount!();
});
});
});
+53 -117
View File
@@ -134,7 +134,6 @@ import { ShellFocusContext } from './contexts/ShellFocusContext.js';
import { type ExtensionManager } from '../config/extension-manager.js';
import { requestConsentInteractive } from '../config/extensions/consent.js';
import { useSessionBrowser } from './hooks/useSessionBrowser.js';
import { persistentState } from '../utils/persistentState.js';
import { useSessionResume } from './hooks/useSessionResume.js';
import { useIncludeDirsTrust } from './hooks/useIncludeDirsTrust.js';
import { useSessionRetentionCheck } from './hooks/useSessionRetentionCheck.js';
@@ -189,8 +188,11 @@ interface AppContainerProps {
resumedSessionData?: ResumedSessionData;
}
const APPROVAL_MODE_REVEAL_DURATION_MS = 1200;
const FOCUS_UI_ENABLED_STATE_KEY = 'focusUiEnabled';
import { useRepeatedKeyPress } from './hooks/useRepeatedKeyPress.js';
import {
useVisibilityToggle,
APPROVAL_MODE_REVEAL_DURATION_MS,
} from './hooks/useVisibilityToggle.js';
/**
* The fraction of the terminal width to allocate to the shell.
@@ -803,65 +805,14 @@ Logging in with Google... Restarting Gemini CLI to continue.
const setIsBackgroundShellListOpenRef = useRef<(open: boolean) => void>(
() => {},
);
const [focusUiEnabledByDefault] = useState(
() => persistentState.get(FOCUS_UI_ENABLED_STATE_KEY) === true,
);
const [shortcutsHelpVisible, setShortcutsHelpVisible] = useState(false);
const [cleanUiDetailsVisible, setCleanUiDetailsVisibleState] = useState(
!focusUiEnabledByDefault,
);
const modeRevealTimeoutRef = useRef<NodeJS.Timeout | null>(null);
const cleanUiDetailsPinnedRef = useRef(!focusUiEnabledByDefault);
const clearModeRevealTimeout = useCallback(() => {
if (modeRevealTimeoutRef.current) {
clearTimeout(modeRevealTimeoutRef.current);
modeRevealTimeoutRef.current = null;
}
}, []);
const persistFocusUiPreference = useCallback((isFullUiVisible: boolean) => {
persistentState.set(FOCUS_UI_ENABLED_STATE_KEY, !isFullUiVisible);
}, []);
const setCleanUiDetailsVisible = useCallback(
(visible: boolean) => {
clearModeRevealTimeout();
cleanUiDetailsPinnedRef.current = visible;
setCleanUiDetailsVisibleState(visible);
persistFocusUiPreference(visible);
},
[clearModeRevealTimeout, persistFocusUiPreference],
);
const toggleCleanUiDetailsVisible = useCallback(() => {
clearModeRevealTimeout();
setCleanUiDetailsVisibleState((visible) => {
const nextVisible = !visible;
cleanUiDetailsPinnedRef.current = nextVisible;
persistFocusUiPreference(nextVisible);
return nextVisible;
});
}, [clearModeRevealTimeout, persistFocusUiPreference]);
const revealCleanUiDetailsTemporarily = useCallback(
(durationMs: number = APPROVAL_MODE_REVEAL_DURATION_MS) => {
if (cleanUiDetailsPinnedRef.current) {
return;
}
clearModeRevealTimeout();
setCleanUiDetailsVisibleState(true);
modeRevealTimeoutRef.current = setTimeout(() => {
if (!cleanUiDetailsPinnedRef.current) {
setCleanUiDetailsVisibleState(false);
}
modeRevealTimeoutRef.current = null;
}, durationMs);
},
[clearModeRevealTimeout],
);
useEffect(() => () => clearModeRevealTimeout(), [clearModeRevealTimeout]);
const {
cleanUiDetailsVisible,
setCleanUiDetailsVisible,
toggleCleanUiDetailsVisible,
revealCleanUiDetailsTemporarily,
} = useVisibilityToggle();
const slashCommandActions = useMemo(
() => ({
@@ -1136,14 +1087,6 @@ Logging in with Google... Restarting Gemini CLI to continue.
],
);
// Auto-accept indicator
const showApprovalModeIndicator = useApprovalModeIndicator({
config,
addItem: historyManager.addItem,
onApprovalModeChange: handleApprovalModeChangeWithUiReveal,
isActive: !embeddedShellFocused,
});
const { isMcpReady } = useMcpStatus(config);
const {
@@ -1396,10 +1339,29 @@ Logging in with Google... Restarting Gemini CLI to continue.
const [showFullTodos, setShowFullTodos] = useState<boolean>(false);
const [renderMarkdown, setRenderMarkdown] = useState<boolean>(true);
const [ctrlCPressCount, setCtrlCPressCount] = useState(0);
const ctrlCTimerRef = useRef<NodeJS.Timeout | null>(null);
const [ctrlDPressCount, setCtrlDPressCount] = useState(0);
const ctrlDTimerRef = useRef<NodeJS.Timeout | null>(null);
const handleExitRepeat = useCallback(
(count: number) => {
if (count > 2) {
recordExitFail(config);
}
if (count > 1) {
void handleSlashCommand('/quit', undefined, undefined, false);
}
},
[config, handleSlashCommand],
);
const { pressCount: ctrlCPressCount, handlePress: handleCtrlCPress } =
useRepeatedKeyPress({
windowMs: WARNING_PROMPT_DURATION_MS,
onRepeat: handleExitRepeat,
});
const { pressCount: ctrlDPressCount, handlePress: handleCtrlDPress } =
useRepeatedKeyPress({
windowMs: WARNING_PROMPT_DURATION_MS,
onRepeat: handleExitRepeat,
});
const [constrainHeight, setConstrainHeight] = useState<boolean>(true);
const [ideContextState, setIdeContextState] = useState<
IdeContext | undefined
@@ -1478,9 +1440,6 @@ Logging in with Google... Restarting Gemini CLI to continue.
if (tabFocusTimeoutRef.current) {
clearTimeout(tabFocusTimeoutRef.current);
}
if (modeRevealTimeoutRef.current) {
clearTimeout(modeRevealTimeoutRef.current);
}
};
}, [showTransientMessage]);
@@ -1553,44 +1512,6 @@ Logging in with Google... Restarting Gemini CLI to continue.
};
}, [config]);
useEffect(() => {
if (ctrlCTimerRef.current) {
clearTimeout(ctrlCTimerRef.current);
ctrlCTimerRef.current = null;
}
if (ctrlCPressCount > 2) {
recordExitFail(config);
}
if (ctrlCPressCount > 1) {
// eslint-disable-next-line @typescript-eslint/no-floating-promises
handleSlashCommand('/quit', undefined, undefined, false);
} else if (ctrlCPressCount > 0) {
ctrlCTimerRef.current = setTimeout(() => {
setCtrlCPressCount(0);
ctrlCTimerRef.current = null;
}, WARNING_PROMPT_DURATION_MS);
}
}, [ctrlCPressCount, config, setCtrlCPressCount, handleSlashCommand]);
useEffect(() => {
if (ctrlDTimerRef.current) {
clearTimeout(ctrlDTimerRef.current);
ctrlCTimerRef.current = null;
}
if (ctrlDPressCount > 2) {
recordExitFail(config);
}
if (ctrlDPressCount > 1) {
// eslint-disable-next-line @typescript-eslint/no-floating-promises
handleSlashCommand('/quit', undefined, undefined, false);
} else if (ctrlDPressCount > 0) {
ctrlDTimerRef.current = setTimeout(() => {
setCtrlDPressCount(0);
ctrlDTimerRef.current = null;
}, WARNING_PROMPT_DURATION_MS);
}
}, [ctrlDPressCount, config, setCtrlDPressCount, handleSlashCommand]);
const handleEscapePromptChange = useCallback((showPrompt: boolean) => {
setShowEscapePrompt(showPrompt);
}, []);
@@ -1637,10 +1558,10 @@ Logging in with Google... Restarting Gemini CLI to continue.
// This should happen regardless of the count.
cancelOngoingRequest?.();
setCtrlCPressCount((prev) => prev + 1);
handleCtrlCPress();
return true;
} else if (keyMatchers[Command.EXIT](key)) {
setCtrlDPressCount((prev) => prev + 1);
handleCtrlDPress();
return true;
} else if (keyMatchers[Command.SUSPEND_APP](key)) {
handleSuspend();
@@ -1781,8 +1702,8 @@ Logging in with Google... Restarting Gemini CLI to continue.
setShowErrorDetails,
config,
ideContextState,
setCtrlCPressCount,
setCtrlDPressCount,
handleCtrlCPress,
handleCtrlDPress,
handleSlashCommand,
cancelOngoingRequest,
activePtyId,
@@ -1968,6 +1889,19 @@ Logging in with Google... Restarting Gemini CLI to continue.
!!validationRequest ||
!!customDialog;
const allowPlanMode =
config.isPlanEnabled() &&
streamingState === StreamingState.Idle &&
!hasPendingActionRequired;
const showApprovalModeIndicator = useApprovalModeIndicator({
config,
addItem: historyManager.addItem,
onApprovalModeChange: handleApprovalModeChangeWithUiReveal,
isActive: !embeddedShellFocused,
allowPlanMode,
});
const isPassiveShortcutsHelpState =
isInputActive &&
streamingState === StreamingState.Idle &&
@@ -2102,6 +2036,7 @@ Logging in with Google... Restarting Gemini CLI to continue.
messageQueue,
queueErrorMessage,
showApprovalModeIndicator,
allowPlanMode,
currentModel,
quota: {
userTier,
@@ -2216,6 +2151,7 @@ Logging in with Google... Restarting Gemini CLI to continue.
messageQueue,
queueErrorMessage,
showApprovalModeIndicator,
allowPlanMode,
userTier,
quotaStats,
proQuotaRequest,
+5 -3
View File
@@ -4,6 +4,7 @@
* SPDX-License-Identifier: Apache-2.0
*/
import { act } from 'react';
import { renderWithProviders } from '../../test-utils/render.js';
import {
describe,
@@ -318,9 +319,10 @@ describe('AuthDialog', () => {
renderWithProviders(<AuthDialog {...props} />);
const { onSelect: handleAuthSelect } =
mockedRadioButtonSelect.mock.calls[0][0];
await handleAuthSelect(AuthType.LOGIN_WITH_GOOGLE);
await vi.runAllTimersAsync();
await act(async () => {
await handleAuthSelect(AuthType.LOGIN_WITH_GOOGLE);
await vi.runAllTimersAsync();
});
expect(mockedRunExitCleanup).toHaveBeenCalled();
expect(exitSpy).toHaveBeenCalledWith(RELAUNCH_EXIT_CODE);
+5 -1
View File
@@ -138,11 +138,15 @@ describe('useAuth', () => {
},
}) as LoadedSettings;
it('should initialize with Unauthenticated state', () => {
it('should initialize with Unauthenticated state', async () => {
const { result } = renderHook(() =>
useAuthCommand(createSettings(AuthType.LOGIN_WITH_GOOGLE), mockConfig),
);
expect(result.current.authState).toBe(AuthState.Unauthenticated);
await waitFor(() => {
expect(result.current.authState).toBe(AuthState.Authenticated);
});
});
it('should set error if no auth type is selected and no env key', async () => {
@@ -324,6 +324,7 @@ const configCommand: SlashCommand = {
const agentsRefreshCommand: SlashCommand = {
name: 'refresh',
altNames: ['reload'],
description: 'Reload the agent registry',
kind: CommandKind.BUILT_IN,
action: async (context: CommandContext) => {
@@ -69,6 +69,7 @@ export const commandsCommand: SlashCommand = {
subCommands: [
{
name: 'reload',
altNames: ['refresh'],
description:
'Reload custom command definitions from .toml files. Usage: /commands reload',
kind: CommandKind.BUILT_IN,
@@ -313,6 +313,7 @@ const schemaCommand: SlashCommand = {
const refreshCommand: SlashCommand = {
name: 'refresh',
altNames: ['reload'],
description: 'Restarts MCP servers',
kind: CommandKind.BUILT_IN,
autoExecute: true,
@@ -64,6 +64,7 @@ export const memoryCommand: SlashCommand = {
},
{
name: 'refresh',
altNames: ['reload'],
description: 'Refresh the memory from the source',
kind: CommandKind.BUILT_IN,
autoExecute: true,
@@ -9,6 +9,7 @@ import { modelCommand } from './modelCommand.js';
import { type CommandContext } from './types.js';
import { createMockCommandContext } from '../../test-utils/mockCommandContext.js';
import type { Config } from '@google/gemini-cli-core';
import { MessageType } from '../types.js';
describe('modelCommand', () => {
let mockContext: CommandContext;
@@ -17,7 +18,7 @@ describe('modelCommand', () => {
mockContext = createMockCommandContext();
});
it('should return a dialog action to open the model dialog', async () => {
it('should return a dialog action to open the model dialog when no args', async () => {
if (!modelCommand.action) {
throw new Error('The model command must have an action.');
}
@@ -30,7 +31,7 @@ describe('modelCommand', () => {
});
});
it('should call refreshUserQuota if config is available', async () => {
it('should call refreshUserQuota if config is available when opening dialog', async () => {
if (!modelCommand.action) {
throw new Error('The model command must have an action.');
}
@@ -45,10 +46,120 @@ describe('modelCommand', () => {
expect(mockRefreshUserQuota).toHaveBeenCalled();
});
describe('manage subcommand', () => {
it('should return a dialog action to open the model dialog', async () => {
const manageCommand = modelCommand.subCommands?.find(
(c) => c.name === 'manage',
);
expect(manageCommand).toBeDefined();
const result = await manageCommand!.action!(mockContext, '');
expect(result).toEqual({
type: 'dialog',
dialog: 'model',
});
});
it('should call refreshUserQuota if config is available', async () => {
const manageCommand = modelCommand.subCommands?.find(
(c) => c.name === 'manage',
);
const mockRefreshUserQuota = vi.fn();
mockContext.services.config = {
refreshUserQuota: mockRefreshUserQuota,
} as unknown as Config;
await manageCommand!.action!(mockContext, '');
expect(mockRefreshUserQuota).toHaveBeenCalled();
});
});
describe('set subcommand', () => {
it('should set the model and log the command', async () => {
const setCommand = modelCommand.subCommands?.find(
(c) => c.name === 'set',
);
expect(setCommand).toBeDefined();
const mockSetModel = vi.fn();
mockContext.services.config = {
setModel: mockSetModel,
getHasAccessToPreviewModel: vi.fn().mockReturnValue(true),
getUserId: vi.fn().mockReturnValue('test-user'),
getUsageStatisticsEnabled: vi.fn().mockReturnValue(true),
getSessionId: vi.fn().mockReturnValue('test-session'),
getContentGeneratorConfig: vi
.fn()
.mockReturnValue({ authType: 'test-auth' }),
isInteractive: vi.fn().mockReturnValue(true),
getExperiments: vi.fn().mockReturnValue({ experimentIds: [] }),
getPolicyEngine: vi.fn().mockReturnValue({
getApprovalMode: vi.fn().mockReturnValue('auto'),
}),
} as unknown as Config;
await setCommand!.action!(mockContext, 'gemini-pro');
expect(mockSetModel).toHaveBeenCalledWith('gemini-pro', true);
expect(mockContext.ui.addItem).toHaveBeenCalledWith(
expect.objectContaining({
type: MessageType.INFO,
text: expect.stringContaining('Model set to gemini-pro'),
}),
);
});
it('should set the model with persistence when --persist is used', async () => {
const setCommand = modelCommand.subCommands?.find(
(c) => c.name === 'set',
);
const mockSetModel = vi.fn();
mockContext.services.config = {
setModel: mockSetModel,
getHasAccessToPreviewModel: vi.fn().mockReturnValue(true),
getUserId: vi.fn().mockReturnValue('test-user'),
getUsageStatisticsEnabled: vi.fn().mockReturnValue(true),
getSessionId: vi.fn().mockReturnValue('test-session'),
getContentGeneratorConfig: vi
.fn()
.mockReturnValue({ authType: 'test-auth' }),
isInteractive: vi.fn().mockReturnValue(true),
getExperiments: vi.fn().mockReturnValue({ experimentIds: [] }),
getPolicyEngine: vi.fn().mockReturnValue({
getApprovalMode: vi.fn().mockReturnValue('auto'),
}),
} as unknown as Config;
await setCommand!.action!(mockContext, 'gemini-pro --persist');
expect(mockSetModel).toHaveBeenCalledWith('gemini-pro', false);
expect(mockContext.ui.addItem).toHaveBeenCalledWith(
expect.objectContaining({
type: MessageType.INFO,
text: expect.stringContaining('Model set to gemini-pro (persisted)'),
}),
);
});
it('should show error if no model name is provided', async () => {
const setCommand = modelCommand.subCommands?.find(
(c) => c.name === 'set',
);
await setCommand!.action!(mockContext, '');
expect(mockContext.ui.addItem).toHaveBeenCalledWith(
expect.objectContaining({
type: MessageType.ERROR,
text: expect.stringContaining('Usage: /model set <model-name>'),
}),
);
});
});
it('should have the correct name and description', () => {
expect(modelCommand.name).toBe('model');
expect(modelCommand.description).toBe(
'Opens a dialog to configure the model',
);
expect(modelCommand.description).toBe('Manage model configuration');
});
});
+49 -2
View File
@@ -4,14 +4,51 @@
* SPDX-License-Identifier: Apache-2.0
*/
import {
ModelSlashCommandEvent,
logModelSlashCommand,
} from '@google/gemini-cli-core';
import {
type CommandContext,
CommandKind,
type SlashCommand,
} from './types.js';
import { MessageType } from '../types.js';
export const modelCommand: SlashCommand = {
name: 'model',
const setModelCommand: SlashCommand = {
name: 'set',
description:
'Set the model to use. Usage: /model set <model-name> [--persist]',
kind: CommandKind.BUILT_IN,
autoExecute: false,
action: async (context: CommandContext, args: string) => {
const parts = args.trim().split(/\s+/).filter(Boolean);
if (parts.length === 0) {
context.ui.addItem({
type: MessageType.ERROR,
text: 'Usage: /model set <model-name> [--persist]',
});
return;
}
const modelName = parts[0];
const persist = parts.includes('--persist');
if (context.services.config) {
context.services.config.setModel(modelName, !persist);
const event = new ModelSlashCommandEvent(modelName);
logModelSlashCommand(context.services.config, event);
context.ui.addItem({
type: MessageType.INFO,
text: `Model set to ${modelName}${persist ? ' (persisted)' : ''}`,
});
}
},
};
const manageModelCommand: SlashCommand = {
name: 'manage',
description: 'Opens a dialog to configure the model',
kind: CommandKind.BUILT_IN,
autoExecute: true,
@@ -25,3 +62,13 @@ export const modelCommand: SlashCommand = {
};
},
};
export const modelCommand: SlashCommand = {
name: 'model',
description: 'Manage model configuration',
kind: CommandKind.BUILT_IN,
autoExecute: false,
subCommands: [manageModelCommand, setModelCommand],
action: async (context: CommandContext, args: string) =>
manageModelCommand.action!(context, args),
};
@@ -396,6 +396,7 @@ export const skillsCommand: SlashCommand = {
},
{
name: 'reload',
altNames: ['refresh'],
description:
'Reload the list of discovered skills. Usage: /skills reload',
kind: CommandKind.BUILT_IN,
@@ -53,8 +53,6 @@ export const AlternateBufferQuittingDisplay = () => {
terminalWidth={uiState.mainAreaWidth}
item={{ ...item, id: 0 }}
isPending={true}
activeShellPtyId={uiState.activePtyId}
embeddedShellFocused={uiState.embeddedShellFocused}
/>
))}
{showPromptedTool && (
@@ -14,57 +14,47 @@ describe('ApprovalModeIndicator', () => {
const { lastFrame } = render(
<ApprovalModeIndicator approvalMode={ApprovalMode.AUTO_EDIT} />,
);
const output = lastFrame();
expect(output).toContain('auto-accept edits');
expect(output).toContain('shift+tab to manual');
expect(lastFrame()).toMatchSnapshot();
});
it('renders correctly for AUTO_EDIT mode with plan enabled', () => {
const { lastFrame } = render(
<ApprovalModeIndicator
approvalMode={ApprovalMode.AUTO_EDIT}
isPlanEnabled={true}
allowPlanMode={true}
/>,
);
const output = lastFrame();
expect(output).toContain('auto-accept edits');
expect(output).toContain('shift+tab to manual');
expect(lastFrame()).toMatchSnapshot();
});
it('renders correctly for PLAN mode', () => {
const { lastFrame } = render(
<ApprovalModeIndicator approvalMode={ApprovalMode.PLAN} />,
);
const output = lastFrame();
expect(output).toContain('plan');
expect(output).toContain('shift+tab to accept edits');
expect(lastFrame()).toMatchSnapshot();
});
it('renders correctly for YOLO mode', () => {
const { lastFrame } = render(
<ApprovalModeIndicator approvalMode={ApprovalMode.YOLO} />,
);
const output = lastFrame();
expect(output).toContain('YOLO');
expect(output).toContain('ctrl+y');
expect(lastFrame()).toMatchSnapshot();
});
it('renders correctly for DEFAULT mode', () => {
const { lastFrame } = render(
<ApprovalModeIndicator approvalMode={ApprovalMode.DEFAULT} />,
);
const output = lastFrame();
expect(output).toContain('shift+tab to accept edits');
expect(lastFrame()).toMatchSnapshot();
});
it('renders correctly for DEFAULT mode with plan enabled', () => {
const { lastFrame } = render(
<ApprovalModeIndicator
approvalMode={ApprovalMode.DEFAULT}
isPlanEnabled={true}
allowPlanMode={true}
/>,
);
const output = lastFrame();
expect(output).toContain('shift+tab to plan');
expect(lastFrame()).toMatchSnapshot();
});
});
@@ -11,12 +11,22 @@ import { ApprovalMode } from '@google/gemini-cli-core';
interface ApprovalModeIndicatorProps {
approvalMode: ApprovalMode;
isPlanEnabled?: boolean;
allowPlanMode?: boolean;
}
export const APPROVAL_MODE_TEXT = {
AUTO_EDIT: 'auto-accept edits',
PLAN: 'plan',
YOLO: 'YOLO',
HINT_SWITCH_TO_PLAN_MODE: 'shift+tab to plan',
HINT_SWITCH_TO_MANUAL_MODE: 'shift+tab to manual',
HINT_SWITCH_TO_AUTO_EDIT_MODE: 'shift+tab to accept edits',
HINT_SWITCH_TO_YOLO_MODE: 'ctrl+y',
};
export const ApprovalModeIndicator: React.FC<ApprovalModeIndicatorProps> = ({
approvalMode,
isPlanEnabled,
allowPlanMode,
}) => {
let textColor = '';
let textContent = '';
@@ -25,26 +35,26 @@ export const ApprovalModeIndicator: React.FC<ApprovalModeIndicatorProps> = ({
switch (approvalMode) {
case ApprovalMode.AUTO_EDIT:
textColor = theme.status.warning;
textContent = 'auto-accept edits';
subText = 'shift+tab to manual';
textContent = APPROVAL_MODE_TEXT.AUTO_EDIT;
subText = allowPlanMode
? APPROVAL_MODE_TEXT.HINT_SWITCH_TO_PLAN_MODE
: APPROVAL_MODE_TEXT.HINT_SWITCH_TO_MANUAL_MODE;
break;
case ApprovalMode.PLAN:
textColor = theme.status.success;
textContent = 'plan';
subText = 'shift+tab to accept edits';
textContent = APPROVAL_MODE_TEXT.PLAN;
subText = APPROVAL_MODE_TEXT.HINT_SWITCH_TO_MANUAL_MODE;
break;
case ApprovalMode.YOLO:
textColor = theme.status.error;
textContent = 'YOLO';
subText = 'ctrl+y';
textContent = APPROVAL_MODE_TEXT.YOLO;
subText = APPROVAL_MODE_TEXT.HINT_SWITCH_TO_YOLO_MODE;
break;
case ApprovalMode.DEFAULT:
default:
textColor = theme.text.accent;
textContent = '';
subText = isPlanEnabled
? 'shift+tab to plan'
: 'shift+tab to accept edits';
subText = APPROVAL_MODE_TEXT.HINT_SWITCH_TO_AUTO_EDIT_MODE;
break;
}
+30 -18
View File
@@ -8,7 +8,7 @@ import { useState, useEffect, useMemo } from 'react';
import { Box, Text, useIsScreenReaderEnabled } from 'ink';
import {
ApprovalMode,
tokenLimit,
checkExhaustive,
CoreToolCallStatus,
} from '@google/gemini-cli-core';
import { LoadingIndicator } from './LoadingIndicator.js';
@@ -38,6 +38,7 @@ import { StreamingState, type HistoryItemToolGroup } from '../types.js';
import { ConfigInitDisplay } from '../components/ConfigInitDisplay.js';
import { TodoTray } from './messages/Todo.js';
import { getInlineThinkingMode } from '../utils/inlineThinkingMode.js';
import { isContextUsageHigh } from '../utils/contextUsage.js';
import { theme } from '../semantic-colors.js';
export const Composer = ({ isFocused = true }: { isFocused?: boolean }) => {
@@ -114,30 +115,41 @@ export const Composer = ({ isFocused = true }: { isFocused?: boolean }) => {
const showApprovalIndicator =
!uiState.shellModeActive && !hideUiDetailsForSuggestions;
const showRawMarkdownIndicator = !uiState.renderMarkdown;
const modeBleedThrough =
showApprovalModeIndicator === ApprovalMode.YOLO
? { text: 'YOLO', color: theme.status.error }
: showApprovalModeIndicator === ApprovalMode.PLAN
? { text: 'plan', color: theme.status.success }
: showApprovalModeIndicator === ApprovalMode.AUTO_EDIT
? { text: 'auto edit', color: theme.status.warning }
: null;
let modeBleedThrough: { text: string; color: string } | null = null;
switch (showApprovalModeIndicator) {
case ApprovalMode.YOLO:
modeBleedThrough = { text: 'YOLO', color: theme.status.error };
break;
case ApprovalMode.PLAN:
modeBleedThrough = { text: 'plan', color: theme.status.success };
break;
case ApprovalMode.AUTO_EDIT:
modeBleedThrough = { text: 'auto edit', color: theme.status.warning };
break;
case ApprovalMode.DEFAULT:
modeBleedThrough = null;
break;
default:
checkExhaustive(showApprovalModeIndicator);
modeBleedThrough = null;
break;
}
const hideMinimalModeHintWhileBusy =
!showUiDetails && (showLoadingIndicator || hasPendingActionRequired);
const minimalModeBleedThrough = hideMinimalModeHintWhileBusy
? null
: modeBleedThrough;
const hasMinimalStatusBleedThrough = shouldShowToast(uiState);
const contextTokenLimit =
typeof uiState.currentModel === 'string' && uiState.currentModel.length > 0
? tokenLimit(uiState.currentModel)
: 0;
const showMinimalContextBleedThrough =
!settings.merged.ui.footer.hideContextPercentage &&
typeof uiState.currentModel === 'string' &&
uiState.currentModel.length > 0 &&
contextTokenLimit > 0 &&
uiState.sessionStats.lastPromptTokenCount / contextTokenLimit > 0.6;
isContextUsageHigh(
uiState.sessionStats.lastPromptTokenCount,
typeof uiState.currentModel === 'string'
? uiState.currentModel
: undefined,
);
const hideShortcutsHintForSuggestions = hideUiDetailsForSuggestions;
const showShortcutsHint =
settings.merged.ui.showShortcutsHint &&
@@ -334,7 +346,7 @@ export const Composer = ({ isFocused = true }: { isFocused?: boolean }) => {
{showApprovalIndicator && (
<ApprovalModeIndicator
approvalMode={showApprovalModeIndicator}
isPlanEnabled={config.isPlanEnabled()}
allowPlanMode={uiState.allowPlanMode}
/>
)}
{!showLoadingIndicator && (
@@ -6,7 +6,7 @@
import { Text } from 'ink';
import { theme } from '../semantic-colors.js';
import { tokenLimit } from '@google/gemini-cli-core';
import { getContextUsagePercentage } from '../utils/contextUsage.js';
export const ContextUsageDisplay = ({
promptTokenCount,
@@ -17,7 +17,7 @@ export const ContextUsageDisplay = ({
model: string;
terminalWidth: number;
}) => {
const percentage = promptTokenCount / tokenLimit(model);
const percentage = getContextUsagePercentage(promptTokenCount, model);
const percentageLeft = ((1 - percentage) * 100).toFixed(0);
const label = terminalWidth < 100 ? '%' : '% context left';
@@ -33,10 +33,15 @@ describe('GeminiRespondingSpinner', () => {
const mockUseIsScreenReaderEnabled = vi.mocked(useIsScreenReaderEnabled);
beforeEach(() => {
vi.useFakeTimers();
vi.clearAllMocks();
mockUseIsScreenReaderEnabled.mockReturnValue(false);
});
afterEach(() => {
vi.useRealTimers();
});
it('renders spinner when responding', () => {
mockUseStreamingContext.mockReturnValue(StreamingState.Responding);
const { lastFrame } = render(<GeminiRespondingSpinner />);
@@ -44,8 +44,6 @@ interface HistoryItemDisplayProps {
terminalWidth: number;
isPending: boolean;
commands?: readonly SlashCommand[];
activeShellPtyId?: number | null;
embeddedShellFocused?: boolean;
availableTerminalHeightGemini?: number;
}
@@ -55,8 +53,6 @@ export const HistoryItemDisplay: React.FC<HistoryItemDisplayProps> = ({
terminalWidth,
isPending,
commands,
activeShellPtyId,
embeddedShellFocused,
availableTerminalHeightGemini,
}) => {
const settings = useSettings();
@@ -173,12 +169,10 @@ export const HistoryItemDisplay: React.FC<HistoryItemDisplayProps> = ({
)}
{itemForDisplay.type === 'tool_group' && (
<ToolGroupMessage
item={itemForDisplay}
toolCalls={itemForDisplay.tools}
groupId={itemForDisplay.id}
availableTerminalHeight={availableTerminalHeight}
terminalWidth={terminalWidth}
activeShellPtyId={activeShellPtyId}
embeddedShellFocused={embeddedShellFocused}
borderTop={itemForDisplay.borderTop}
borderBottom={itemForDisplay.borderBottom}
/>
+37 -58
View File
@@ -76,6 +76,7 @@ import { useMouse, type MouseEvent } from '../contexts/MouseContext.js';
import { useUIActions } from '../contexts/UIActionsContext.js';
import { useAlternateBuffer } from '../hooks/useAlternateBuffer.js';
import { shouldDismissShortcutsHelpOnHotkey } from '../utils/shortcutsHelp.js';
import { useRepeatedKeyPress } from '../hooks/useRepeatedKeyPress.js';
/**
* Returns if the terminal can be trusted to handle paste events atomically
@@ -227,10 +228,31 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
shortcutsHelpVisible,
} = useUIState();
const [suppressCompletion, setSuppressCompletion] = useState(false);
const escPressCount = useRef(0);
const lastPlainTabPressTimeRef = useRef<number | null>(null);
const { handlePress: registerPlainTabPress, resetCount: resetPlainTabPress } =
useRepeatedKeyPress({
windowMs: DOUBLE_TAB_CLEAN_UI_TOGGLE_WINDOW_MS,
});
const [showEscapePrompt, setShowEscapePrompt] = useState(false);
const escapeTimerRef = useRef<NodeJS.Timeout | null>(null);
const { handlePress: handleEscPress, resetCount: resetEscapeState } =
useRepeatedKeyPress({
windowMs: 500,
onRepeat: (count) => {
if (count === 1) {
setShowEscapePrompt(true);
} else if (count === 2) {
resetEscapeState();
if (buffer.text.length > 0) {
buffer.setText('');
resetCompletionState();
} else if (history.length > 0) {
onSubmit('/rewind');
} else {
coreEvents.emitFeedback('info', 'Nothing to rewind to');
}
}
},
onReset: () => setShowEscapePrompt(false),
});
const [recentUnsafePasteTime, setRecentUnsafePasteTime] = useState<
number | null
>(null);
@@ -284,15 +306,6 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
const showCursor = focus && isShellFocused && !isEmbeddedShellFocused;
const resetEscapeState = useCallback(() => {
if (escapeTimerRef.current) {
clearTimeout(escapeTimerRef.current);
escapeTimerRef.current = null;
}
escPressCount.current = 0;
setShowEscapePrompt(false);
}, []);
// Notify parent component about escape prompt state changes
useEffect(() => {
if (onEscapePromptChange) {
@@ -300,12 +313,9 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
}
}, [showEscapePrompt, onEscapePromptChange]);
// Clear escape prompt timer on unmount
// Clear paste timeout on unmount
useEffect(
() => () => {
if (escapeTimerRef.current) {
clearTimeout(escapeTimerRef.current);
}
if (pasteTimeoutRef.current) {
clearTimeout(pasteTimeoutRef.current);
}
@@ -335,8 +345,8 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
resetReverseSearchCompletionState();
},
[
onSubmit,
buffer,
onSubmit,
resetCompletionState,
shellModeActive,
shellHistory,
@@ -639,22 +649,16 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
commandSearchActive;
if (isPlainTab) {
if (!hasTabCompletionInteraction) {
const now = Date.now();
const isDoubleTabPress =
lastPlainTabPressTimeRef.current !== null &&
now - lastPlainTabPressTimeRef.current <=
DOUBLE_TAB_CLEAN_UI_TOGGLE_WINDOW_MS;
if (isDoubleTabPress) {
lastPlainTabPressTimeRef.current = null;
if (registerPlainTabPress() === 2) {
toggleCleanUiDetailsVisible();
resetPlainTabPress();
return true;
}
lastPlainTabPressTimeRef.current = now;
} else {
lastPlainTabPressTimeRef.current = null;
resetPlainTabPress();
}
} else {
lastPlainTabPressTimeRef.current = null;
resetPlainTabPress();
}
if (key.name === 'paste') {
@@ -732,9 +736,7 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
// Reset ESC count and hide prompt on any non-ESC key
if (key.name !== 'escape') {
if (escPressCount.current > 0 || showEscapePrompt) {
resetEscapeState();
}
resetEscapeState();
}
// Ctrl+O to expand/collapse paste placeholders
@@ -798,30 +800,7 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
return true;
}
// Handle double ESC
if (escPressCount.current === 0) {
escPressCount.current = 1;
setShowEscapePrompt(true);
if (escapeTimerRef.current) {
clearTimeout(escapeTimerRef.current);
}
escapeTimerRef.current = setTimeout(() => {
resetEscapeState();
}, 500);
return true;
}
// Second ESC
resetEscapeState();
if (buffer.text.length > 0) {
buffer.setText('');
resetCompletionState();
return true;
} else if (history.length > 0) {
onSubmit('/rewind');
return true;
}
coreEvents.emitFeedback('info', 'Nothing to rewind to');
handleEscPress();
return true;
}
@@ -1193,7 +1172,6 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
reverseSearchCompletion,
handleClipboardPaste,
resetCompletionState,
showEscapePrompt,
resetEscapeState,
vimHandleInput,
reverseSearchActive,
@@ -1205,16 +1183,17 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
kittyProtocol.enabled,
shortcutsHelpVisible,
setShortcutsHelpVisible,
toggleCleanUiDetailsVisible,
tryLoadQueuedMessages,
setBannerVisible,
onSubmit,
activePtyId,
setEmbeddedShellFocused,
backgroundShells.size,
backgroundShellHeight,
history,
streamingState,
handleEscPress,
registerPlainTabPress,
resetPlainTabPress,
toggleCleanUiDetailsVisible,
],
);
@@ -7,6 +7,7 @@
import { renderWithProviders } from '../../test-utils/render.js';
import { waitFor } from '../../test-utils/async.js';
import { MainContent } from './MainContent.js';
import { getToolGroupBorderAppearance } from '../utils/borderStyles.js';
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { Box, Text } from 'ink';
import { act, useState, type JSX } from 'react';
@@ -18,6 +19,7 @@ import {
type UIState,
} from '../contexts/UIStateContext.js';
import { CoreToolCallStatus } from '@google/gemini-cli-core';
import { type IndividualToolCallDisplay } from '../types.js';
// Mock dependencies
vi.mock('../contexts/SettingsContext.js', async () => {
@@ -76,6 +78,209 @@ vi.mock('./shared/ScrollableList.js', () => ({
SCROLL_TO_ITEM_END: 0,
}));
import { theme } from '../semantic-colors.js';
import { type BackgroundShell } from '../hooks/shellReducer.js';
describe('getToolGroupBorderAppearance', () => {
const mockBackgroundShells = new Map<number, BackgroundShell>();
const activeShellPtyId = 123;
it('returns default empty values for non-tool_group items', () => {
const item = { type: 'user' as const, text: 'Hello', id: 1 };
const result = getToolGroupBorderAppearance(
item,
null,
false,
[],
mockBackgroundShells,
);
expect(result).toEqual({ borderColor: '', borderDimColor: false });
});
it('inspects only the last pending tool_group item if current has no tools', () => {
const item = { type: 'tool_group' as const, tools: [], id: 1 };
const pendingItems = [
{
type: 'tool_group' as const,
tools: [
{
callId: '1',
name: 'some_tool',
description: '',
status: CoreToolCallStatus.Executing,
ptyId: undefined,
resultDisplay: undefined,
confirmationDetails: undefined,
} as IndividualToolCallDisplay,
],
},
{
type: 'tool_group' as const,
tools: [
{
callId: '2',
name: 'other_tool',
description: '',
status: CoreToolCallStatus.Success,
ptyId: undefined,
resultDisplay: undefined,
confirmationDetails: undefined,
} as IndividualToolCallDisplay,
],
},
];
// Only the last item (Success) should be inspected, so hasPending = false.
// The previous item was Executing (pending) but it shouldn't be counted.
const result = getToolGroupBorderAppearance(
item,
null,
false,
pendingItems,
mockBackgroundShells,
);
expect(result).toEqual({
borderColor: theme.border.default,
borderDimColor: false,
});
});
it('returns default border for completed normal tools', () => {
const item = {
type: 'tool_group' as const,
tools: [
{
callId: '1',
name: 'some_tool',
description: '',
status: CoreToolCallStatus.Success,
ptyId: undefined,
resultDisplay: undefined,
confirmationDetails: undefined,
} as IndividualToolCallDisplay,
],
id: 1,
};
const result = getToolGroupBorderAppearance(
item,
null,
false,
[],
mockBackgroundShells,
);
expect(result).toEqual({
borderColor: theme.border.default,
borderDimColor: false,
});
});
it('returns warning border for pending normal tools', () => {
const item = {
type: 'tool_group' as const,
tools: [
{
callId: '1',
name: 'some_tool',
description: '',
status: CoreToolCallStatus.Executing,
ptyId: undefined,
resultDisplay: undefined,
confirmationDetails: undefined,
} as IndividualToolCallDisplay,
],
id: 1,
};
const result = getToolGroupBorderAppearance(
item,
null,
false,
[],
mockBackgroundShells,
);
expect(result).toEqual({
borderColor: theme.status.warning,
borderDimColor: true,
});
});
it('returns symbol border for executing shell commands', () => {
const item = {
type: 'tool_group' as const,
tools: [
{
callId: '1',
name: SHELL_COMMAND_NAME,
description: '',
status: CoreToolCallStatus.Executing,
ptyId: activeShellPtyId,
resultDisplay: undefined,
confirmationDetails: undefined,
} as IndividualToolCallDisplay,
],
id: 1,
};
// While executing shell commands, it's dim false, border symbol
const result = getToolGroupBorderAppearance(
item,
activeShellPtyId,
true,
[],
mockBackgroundShells,
);
expect(result).toEqual({
borderColor: theme.ui.symbol,
borderDimColor: false,
});
});
it('returns symbol border and dims color for background executing shell command when another shell is active', () => {
const item = {
type: 'tool_group' as const,
tools: [
{
callId: '1',
name: SHELL_COMMAND_NAME,
description: '',
status: CoreToolCallStatus.Executing,
ptyId: 456, // Different ptyId, not active
resultDisplay: undefined,
confirmationDetails: undefined,
} as IndividualToolCallDisplay,
],
id: 1,
};
const result = getToolGroupBorderAppearance(
item,
activeShellPtyId,
false,
[],
mockBackgroundShells,
);
expect(result).toEqual({
borderColor: theme.ui.symbol,
borderDimColor: true,
});
});
it('handles empty tools with active shell turn (isCurrentlyInShellTurn)', () => {
const item = { type: 'tool_group' as const, tools: [], id: 1 };
// active shell turn
const result = getToolGroupBorderAppearance(
item,
activeShellPtyId,
true,
[],
mockBackgroundShells,
);
// Since there are no tools to inspect, it falls back to empty pending, but isCurrentlyInShellTurn=true
// so it counts as pending shell.
expect(result.borderColor).toEqual(theme.ui.symbol);
// It shouldn't be dim because there are no tools to say it isEmbeddedShellFocused = false
expect(result.borderDimColor).toBe(false);
});
});
describe('MainContent', () => {
const defaultMockUiState = {
history: [
@@ -258,7 +463,7 @@ describe('MainContent', () => {
history: [],
pendingHistoryItems: [
{
type: 'tool_group' as const,
type: 'tool_group',
id: 1,
tools: [
{
@@ -88,8 +88,6 @@ export const MainContent = () => {
terminalWidth={mainAreaWidth}
item={{ ...item, id: 0 }}
isPending={true}
activeShellPtyId={uiState.activePtyId}
embeddedShellFocused={uiState.embeddedShellFocused}
/>
))}
{showConfirmationQueue && confirmingTool && (
@@ -103,8 +101,6 @@ export const MainContent = () => {
isAlternateBuffer,
availableTerminalHeight,
mainAreaWidth,
uiState.activePtyId,
uiState.embeddedShellFocused,
showConfirmationQueue,
confirmingTool,
],
@@ -11,7 +11,7 @@ import * as SessionContext from '../contexts/SessionContext.js';
import * as SettingsContext from '../contexts/SettingsContext.js';
import type { LoadedSettings } from '../../config/settings.js';
import type { SessionMetrics } from '../contexts/SessionContext.js';
import { ToolCallDecision } from '@google/gemini-cli-core';
import { ToolCallDecision, LlmRole } from '@google/gemini-cli-core';
// Mock the context to provide controlled data for testing
vi.mock('../contexts/SessionContext.js', async (importOriginal) => {
@@ -118,6 +118,7 @@ describe('<ModelStatsDisplay />', () => {
thoughts: 0,
tool: 0,
},
roles: {},
},
},
tools: {
@@ -160,6 +161,7 @@ describe('<ModelStatsDisplay />', () => {
thoughts: 2,
tool: 0,
},
roles: {},
},
'gemini-2.5-flash': {
api: { totalRequests: 1, totalErrors: 0, totalLatencyMs: 50 },
@@ -172,6 +174,7 @@ describe('<ModelStatsDisplay />', () => {
thoughts: 0,
tool: 3,
},
roles: {},
},
},
tools: {
@@ -214,6 +217,7 @@ describe('<ModelStatsDisplay />', () => {
thoughts: 10,
tool: 5,
},
roles: {},
},
'gemini-2.5-flash': {
api: { totalRequests: 20, totalErrors: 2, totalLatencyMs: 500 },
@@ -226,6 +230,7 @@ describe('<ModelStatsDisplay />', () => {
thoughts: 20,
tool: 10,
},
roles: {},
},
},
tools: {
@@ -271,6 +276,7 @@ describe('<ModelStatsDisplay />', () => {
thoughts: 111111111,
tool: 222222222,
},
roles: {},
},
},
tools: {
@@ -309,6 +315,7 @@ describe('<ModelStatsDisplay />', () => {
thoughts: 2,
tool: 1,
},
roles: {},
},
},
tools: {
@@ -351,6 +358,7 @@ describe('<ModelStatsDisplay />', () => {
thoughts: 100,
tool: 50,
},
roles: {},
},
'gemini-3-flash-preview': {
api: { totalRequests: 20, totalErrors: 0, totalLatencyMs: 1000 },
@@ -363,6 +371,7 @@ describe('<ModelStatsDisplay />', () => {
thoughts: 200,
tool: 100,
},
roles: {},
},
},
tools: {
@@ -390,6 +399,64 @@ describe('<ModelStatsDisplay />', () => {
const output = lastFrame();
expect(output).toContain('gemini-3-pro-');
expect(output).toContain('gemini-3-flash-');
});
it('should display role breakdown correctly', () => {
const { lastFrame } = renderWithMockedStats({
models: {
'gemini-2.5-pro': {
api: { totalRequests: 2, totalErrors: 0, totalLatencyMs: 200 },
tokens: {
input: 20,
prompt: 30,
candidates: 40,
total: 70,
cached: 10,
thoughts: 0,
tool: 0,
},
roles: {
[LlmRole.MAIN]: {
totalRequests: 1,
totalErrors: 0,
totalLatencyMs: 100,
tokens: {
input: 10,
prompt: 15,
candidates: 20,
total: 35,
cached: 5,
thoughts: 0,
tool: 0,
},
},
},
},
},
tools: {
totalCalls: 0,
totalSuccess: 0,
totalFail: 0,
totalDurationMs: 0,
totalDecisions: {
accept: 0,
reject: 0,
modify: 0,
[ToolCallDecision.AUTO_ACCEPT]: 0,
},
byName: {},
},
files: {
totalLinesAdded: 0,
totalLinesRemoved: 0,
},
});
const output = lastFrame();
expect(output).toContain('main');
expect(output).toContain('Input');
expect(output).toContain('Output');
expect(output).toContain('Cache Reads');
expect(output).toMatchSnapshot();
});
@@ -427,6 +494,7 @@ describe('<ModelStatsDisplay />', () => {
thoughts: 0,
tool: 0,
},
roles: {},
},
},
tools: {
@@ -462,4 +530,121 @@ describe('<ModelStatsDisplay />', () => {
expect(output).toContain('Tier:');
expect(output).toContain('Pro');
});
it('should handle long role name layout', () => {
// Use the longest valid role name to test layout
const longRoleName = LlmRole.UTILITY_LOOP_DETECTOR;
const { lastFrame } = renderWithMockedStats({
models: {
'gemini-2.5-pro': {
api: { totalRequests: 1, totalErrors: 0, totalLatencyMs: 100 },
tokens: {
input: 10,
prompt: 10,
candidates: 20,
total: 30,
cached: 0,
thoughts: 0,
tool: 0,
},
roles: {
[longRoleName]: {
totalRequests: 1,
totalErrors: 0,
totalLatencyMs: 100,
tokens: {
input: 10,
prompt: 10,
candidates: 20,
total: 30,
cached: 0,
thoughts: 0,
tool: 0,
},
},
},
},
},
tools: {
totalCalls: 0,
totalSuccess: 0,
totalFail: 0,
totalDurationMs: 0,
totalDecisions: {
accept: 0,
reject: 0,
modify: 0,
[ToolCallDecision.AUTO_ACCEPT]: 0,
},
byName: {},
},
files: {
totalLinesAdded: 0,
totalLinesRemoved: 0,
},
});
const output = lastFrame();
expect(output).toContain(longRoleName);
expect(output).toMatchSnapshot();
});
it('should filter out invalid role names', () => {
const invalidRoleName =
'this_is_a_very_long_role_name_that_should_be_wrapped' as LlmRole;
const { lastFrame } = renderWithMockedStats({
models: {
'gemini-2.5-pro': {
api: { totalRequests: 1, totalErrors: 0, totalLatencyMs: 100 },
tokens: {
input: 10,
prompt: 10,
candidates: 20,
total: 30,
cached: 0,
thoughts: 0,
tool: 0,
},
roles: {
[invalidRoleName]: {
totalRequests: 1,
totalErrors: 0,
totalLatencyMs: 100,
tokens: {
input: 10,
prompt: 10,
candidates: 20,
total: 30,
cached: 0,
thoughts: 0,
tool: 0,
},
},
},
},
},
tools: {
totalCalls: 0,
totalSuccess: 0,
totalFail: 0,
totalDurationMs: 0,
totalDecisions: {
accept: 0,
reject: 0,
modify: 0,
[ToolCallDecision.AUTO_ACCEPT]: 0,
},
byName: {},
},
files: {
totalLinesAdded: 0,
totalLinesRemoved: 0,
},
});
const output = lastFrame();
expect(output).not.toContain(invalidRoleName);
expect(output).toMatchSnapshot();
});
});
@@ -13,10 +13,17 @@ import {
calculateCacheHitRate,
calculateErrorRate,
} from '../utils/computeStats.js';
import { useSessionStats } from '../contexts/SessionContext.js';
import {
useSessionStats,
type ModelMetrics,
} from '../contexts/SessionContext.js';
import { Table, type Column } from './Table.js';
import { useSettings } from '../contexts/SettingsContext.js';
import { getDisplayString, isAutoModel } from '@google/gemini-cli-core';
import {
getDisplayString,
isAutoModel,
LlmRole,
} from '@google/gemini-cli-core';
import type { QuotaStats } from '../types.js';
import { QuotaStatsInfo } from './QuotaStatsInfo.js';
@@ -25,9 +32,11 @@ interface StatRowData {
isSection?: boolean;
isSubtle?: boolean;
// Dynamic keys for model values
[key: string]: string | React.ReactNode | boolean | undefined;
[key: string]: string | React.ReactNode | boolean | undefined | number;
}
type RoleMetrics = NonNullable<NonNullable<ModelMetrics['roles']>[LlmRole]>;
interface ModelStatsDisplayProps {
selectedAuthType?: string;
userEmail?: string;
@@ -81,6 +90,22 @@ export const ModelStatsDisplay: React.FC<ModelStatsDisplayProps> = ({
([, metrics]) => metrics.tokens.cached > 0,
);
const allRoles = [
...new Set(
activeModels.flatMap(([, metrics]) => Object.keys(metrics.roles ?? {})),
),
]
.filter((role): role is LlmRole => {
const validRoles: string[] = Object.values(LlmRole);
return validRoles.includes(role);
})
.sort((a, b) => {
if (a === b) return 0;
if (a === LlmRole.MAIN) return -1;
if (b === LlmRole.MAIN) return 1;
return a.localeCompare(b);
});
// Helper to create a row with values for each model
const createRow = (
metric: string,
@@ -204,6 +229,60 @@ export const ModelStatsDisplay: React.FC<ModelStatsDisplayProps> = ({
),
);
// Roles Section
if (allRoles.length > 0) {
// Spacer
rows.push({ metric: '' });
rows.push({ metric: 'Roles', isSection: true });
allRoles.forEach((role) => {
// Role Header Row
const roleHeaderRow: StatRowData = {
metric: role,
isSection: true,
color: theme.text.primary,
};
// We don't populate model values for the role header row
rows.push(roleHeaderRow);
const addRoleMetric = (
metric: string,
getValue: (r: RoleMetrics) => string | React.ReactNode,
) => {
const row: StatRowData = {
metric,
isSubtle: true,
};
activeModels.forEach(([name, metrics]) => {
const roleMetrics = metrics.roles?.[role];
if (roleMetrics) {
row[name] = getValue(roleMetrics);
} else {
row[name] = <Text color={theme.text.secondary}>-</Text>;
}
});
rows.push(row);
};
addRoleMetric('Requests', (r) => r.totalRequests.toLocaleString());
addRoleMetric('Input', (r) => (
<Text color={theme.text.primary}>
{r.tokens.input.toLocaleString()}
</Text>
));
addRoleMetric('Output', (r) => (
<Text color={theme.text.primary}>
{r.tokens.candidates.toLocaleString()}
</Text>
));
addRoleMetric('Cache Reads', (r) => (
<Text color={theme.text.secondary}>
{r.tokens.cached.toLocaleString()}
</Text>
));
});
}
const columns: Array<Column<StatRowData>> = [
{
key: 'metric',
@@ -14,6 +14,10 @@ import type {
MessageRecord,
} from '@google/gemini-cli-core';
vi.mock('./CliSpinner.js', () => ({
CliSpinner: () => 'MockSpinner',
}));
vi.mock('../utils/formatters.js', async (importOriginal) => {
const original =
await importOriginal<typeof import('../utils/formatters.js')>();
@@ -55,6 +55,7 @@ describe('<SessionSummaryDisplay />', () => {
thoughts: 300,
tool: 200,
},
roles: {},
},
},
tools: {
@@ -14,5 +14,9 @@ interface SessionSummaryDisplayProps {
export const SessionSummaryDisplay: React.FC<SessionSummaryDisplayProps> = ({
duration,
}) => (
<StatsDisplay title="Agent powering down. Goodbye!" duration={duration} />
<StatsDisplay
title="Agent powering down. Goodbye!"
duration={duration}
footer="Tip: Resume a previous session using gemini --resume or /resume"
/>
);
@@ -93,6 +93,7 @@ describe('<StatsDisplay />', () => {
thoughts: 100,
tool: 50,
},
roles: {},
},
'gemini-2.5-flash': {
api: { totalRequests: 5, totalErrors: 1, totalLatencyMs: 4500 },
@@ -105,6 +106,7 @@ describe('<StatsDisplay />', () => {
thoughts: 2000,
tool: 1000,
},
roles: {},
},
},
});
@@ -133,6 +135,7 @@ describe('<StatsDisplay />', () => {
thoughts: 0,
tool: 0,
},
roles: {},
},
},
tools: {
@@ -227,6 +230,7 @@ describe('<StatsDisplay />', () => {
thoughts: 0,
tool: 0,
},
roles: {},
},
},
});
@@ -411,6 +415,7 @@ describe('<StatsDisplay />', () => {
thoughts: 0,
tool: 0,
},
roles: {},
},
},
});
@@ -178,7 +178,7 @@ const ModelUsageTable: React.FC<{
: `Model Usage`;
return (
<Box flexDirection="column" marginTop={1}>
<Box flexDirection="column" marginBottom={1}>
{/* Header */}
<Box alignItems="flex-end">
<Box width={nameWidth}>
@@ -379,6 +379,7 @@ interface StatsDisplayProps {
duration: string;
title?: string;
quotas?: RetrieveUserQuotaResponse;
footer?: string;
selectedAuthType?: string;
userEmail?: string;
tier?: string;
@@ -390,6 +391,7 @@ export const StatsDisplay: React.FC<StatsDisplayProps> = ({
duration,
title,
quotas,
footer,
selectedAuthType,
userEmail,
tier,
@@ -433,6 +435,13 @@ export const StatsDisplay: React.FC<StatsDisplayProps> = ({
);
};
const renderFooter = () => {
if (!footer) {
return null;
}
return <ThemedGradient bold>{footer}</ThemedGradient>;
};
return (
<Box
borderStyle="round"
@@ -536,6 +545,7 @@ export const StatsDisplay: React.FC<StatsDisplayProps> = ({
pooledLimit={pooledLimit}
pooledResetTime={pooledResetTime}
/>
{renderFooter()}
</Box>
);
};
@@ -114,10 +114,14 @@ export function ThemeDialog({
.getAvailableThemes()
.map((theme) => {
const fullTheme = themeManager.getTheme(theme.name);
const capitalizedType = capitalize(theme.type);
const typeDisplay = theme.name.endsWith(capitalizedType)
? ''
: capitalizedType;
return generateThemeItem(
theme.name,
capitalize(theme.type),
typeDisplay,
fullTheme,
terminalBackgroundColor,
);
@@ -7,7 +7,7 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { Box } from 'ink';
import { ToolConfirmationQueue } from './ToolConfirmationQueue.js';
import { StreamingState } from '../types.js';
import { StreamingState, ToolCallStatus } from '../types.js';
import { renderWithProviders } from '../../test-utils/render.js';
import { waitFor } from '../../test-utils/async.js';
import { type Config, CoreToolCallStatus } from '@google/gemini-cli-core';
@@ -223,6 +223,58 @@ describe('ToolConfirmationQueue', () => {
expect(lastFrame()).toMatchSnapshot();
});
it('provides more height for ask_user by subtracting less overhead', async () => {
const confirmingTool = {
tool: {
callId: 'call-1',
name: 'ask_user',
description: 'ask user',
status: ToolCallStatus.Confirming,
confirmationDetails: {
type: 'ask_user' as const,
questions: [
{
type: 'choice',
header: 'Height Test',
question: 'Line 1\nLine 2\nLine 3\nLine 4\nLine 5\nLine 6',
options: [{ label: 'Option 1', description: 'Desc' }],
},
],
},
},
index: 1,
total: 1,
};
const { lastFrame } = renderWithProviders(
<ToolConfirmationQueue
confirmingTool={confirmingTool as unknown as ConfirmingToolState}
/>,
{
config: mockConfig,
uiState: {
terminalWidth: 80,
terminalHeight: 40,
availableTerminalHeight: 20,
constrainHeight: true,
streamingState: StreamingState.WaitingForConfirmation,
},
},
);
// Calculation:
// availableTerminalHeight: 20 -> maxHeight: 19 (20-1)
// hideToolIdentity is true for ask_user -> subtracts 4 instead of 6
// availableContentHeight = 19 - 4 = 15
// ToolConfirmationMessage handlesOwnUI=true -> returns full 15
// AskUserDialog uses 15 lines to render its multi-line question and options.
await waitFor(() => {
expect(lastFrame()).toContain('Line 6');
expect(lastFrame()).not.toContain('lines hidden');
});
expect(lastFrame()).toMatchSnapshot();
});
it('does not render expansion hint when constrainHeight is false', () => {
const longDiff = 'line\n'.repeat(50);
const confirmingTool = {
@@ -60,6 +60,12 @@ export const ToolConfirmationQueue: React.FC<ToolConfirmationQueueProps> = ({
? Math.max(uiAvailableHeight - 1, 4)
: Math.floor(terminalHeight * 0.5);
const isRoutine =
tool.confirmationDetails?.type === 'ask_user' ||
tool.confirmationDetails?.type === 'exit_plan_mode';
const borderColor = isRoutine ? theme.status.success : theme.status.warning;
const hideToolIdentity = isRoutine;
// ToolConfirmationMessage needs to know the height available for its OWN content.
// We subtract the lines used by the Queue wrapper:
// - 2 lines for the rounded border
@@ -67,15 +73,9 @@ export const ToolConfirmationQueue: React.FC<ToolConfirmationQueueProps> = ({
// - 2 lines for Tool Identity (text + margin)
const availableContentHeight =
constrainHeight && !isAlternateBuffer
? Math.max(maxHeight - 6, 4)
? Math.max(maxHeight - (hideToolIdentity ? 4 : 6), 4)
: undefined;
const isRoutine =
tool.confirmationDetails?.type === 'ask_user' ||
tool.confirmationDetails?.type === 'exit_plan_mode';
const borderColor = isRoutine ? theme.status.success : theme.status.warning;
const hideToolIdentity = isRoutine;
return (
<OverflowProvider>
<Box flexDirection="column" width={mainAreaWidth} flexShrink={0}>
@@ -0,0 +1,13 @@
// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
exports[`ApprovalModeIndicator > renders correctly for AUTO_EDIT mode 1`] = `"auto-accept edits shift+tab to manual"`;
exports[`ApprovalModeIndicator > renders correctly for AUTO_EDIT mode with plan enabled 1`] = `"auto-accept edits shift+tab to plan"`;
exports[`ApprovalModeIndicator > renders correctly for DEFAULT mode 1`] = `"shift+tab to accept edits"`;
exports[`ApprovalModeIndicator > renders correctly for DEFAULT mode with plan enabled 1`] = `"shift+tab to accept edits"`;
exports[`ApprovalModeIndicator > renders correctly for PLAN mode 1`] = `"plan shift+tab to manual"`;
exports[`ApprovalModeIndicator > renders correctly for YOLO mode 1`] = `"YOLO ctrl+y"`;
@@ -77,39 +77,6 @@ exports[`InputPrompt > mouse interaction > should toggle paste expansion on doub
▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄"
`;
exports[`InputPrompt > mouse interaction > should toggle paste expansion on double-click 4`] = `
"▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀
> [Pasted Text: 10 lines]
▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄"
`;
exports[`InputPrompt > mouse interaction > should toggle paste expansion on double-click 5`] = `
"▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀
 > [Pasted Text: 10 lines] 
▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄"
`;
exports[`InputPrompt > mouse interaction > should toggle paste expansion on double-click 6`] = `
"▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀
 > line1 
 line2 
 line3 
 line4 
 line5 
 line6 
 line7 
 line8 
 line9 
 line10 
▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄"
`;
exports[`InputPrompt > mouse interaction > should toggle paste expansion on double-click 7`] = `
"▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀
 > [Pasted Text: 10 lines] 
▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄"
`;
exports[`InputPrompt > snapshots > should not show inverted cursor when shell is focused 1`] = `
"▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀
> Type your message or @path/to/file
@@ -44,6 +44,32 @@ exports[`<ModelStatsDisplay /> > should display conditional rows if at least one
╰──────────────────────────────────────────────────────────────────────────────────────────────────╯"
`;
exports[`<ModelStatsDisplay /> > should display role breakdown correctly 1`] = `
"╭──────────────────────────────────────────────────────────────────────────────────────────────────╮
│ │
│ Model Stats For Nerds │
│ │
│ │
│ Metric gemini-2.5-pro │
│ ────────────────────────────────────────────────────────────────────────────────────────────── │
│ API │
│ Requests 2 │
│ Errors 0 (0.0%) │
│ Avg Latency 100ms │
│ Tokens │
│ Total 70 │
│ ↳ Input 20 │
│ ↳ Cache Reads 10 (33.3%) │
│ ↳ Output 40 │
│ Roles │
│ main │
│ ↳ Requests 1 │
│ ↳ Input 10 │
│ ↳ Output 20 │
│ ↳ Cache Reads 5 │
╰──────────────────────────────────────────────────────────────────────────────────────────────────╯"
`;
exports[`<ModelStatsDisplay /> > should display stats for multiple models correctly 1`] = `
"╭──────────────────────────────────────────────────────────────────────────────────────────────────╮
│ │
@@ -66,6 +92,25 @@ exports[`<ModelStatsDisplay /> > should display stats for multiple models correc
╰──────────────────────────────────────────────────────────────────────────────────────────────────╯"
`;
exports[`<ModelStatsDisplay /> > should filter out invalid role names 1`] = `
"╭──────────────────────────────────────────────────────────────────────────────────────────────────╮
│ │
│ Model Stats For Nerds │
│ │
│ │
│ Metric gemini-2.5-pro │
│ ────────────────────────────────────────────────────────────────────────────────────────────── │
│ API │
│ Requests 1 │
│ Errors 0 (0.0%) │
│ Avg Latency 100ms │
│ Tokens │
│ Total 30 │
│ ↳ Input 10 │
│ ↳ Output 20 │
╰──────────────────────────────────────────────────────────────────────────────────────────────────╯"
`;
exports[`<ModelStatsDisplay /> > should handle large values without wrapping or overlapping 1`] = `
"╭──────────────────────────────────────────────────────────────────────────────────────────────────╮
│ │
@@ -88,6 +133,31 @@ exports[`<ModelStatsDisplay /> > should handle large values without wrapping or
╰──────────────────────────────────────────────────────────────────────────────────────────────────╯"
`;
exports[`<ModelStatsDisplay /> > should handle long role name layout 1`] = `
"╭──────────────────────────────────────────────────────────────────────────────────────────────────╮
│ │
│ Model Stats For Nerds │
│ │
│ │
│ Metric gemini-2.5-pro │
│ ────────────────────────────────────────────────────────────────────────────────────────────── │
│ API │
│ Requests 1 │
│ Errors 0 (0.0%) │
│ Avg Latency 100ms │
│ Tokens │
│ Total 30 │
│ ↳ Input 10 │
│ ↳ Output 20 │
│ Roles │
│ utility_loop_detector │
│ ↳ Requests 1 │
│ ↳ Input 10 │
│ ↳ Output 20 │
│ ↳ Cache Reads 0 │
╰──────────────────────────────────────────────────────────────────────────────────────────────────╯"
`;
exports[`<ModelStatsDisplay /> > should handle models with long names (gemini-3-*-preview) without layout breaking 1`] = `
"╭──────────────────────────────────────────────────────────────────────────────╮
│ │
@@ -17,12 +17,13 @@ exports[`<SessionSummaryDisplay /> > renders the summary display with a title 1`
│ » API Time: 50.2s (100.0%) │
│ » Tool Time: 0s (0.0%) │
│ │
│ │
│ Model Usage │
│ Model Reqs Input Tokens Cache Reads Output Tokens │
│ ──────────────────────────────────────────────────────────────────────────── │
│ gemini-2.5-pro 10 500 500 2,000 │
│ │
│ Savings Highlight: 500 (50.0%) of input tokens were served from the cache, reducing costs. │
│ │
│ Tip: Resume a previous session using gemini --resume or /resume │
╰──────────────────────────────────────────────────────────────────────────────────────────────────╯"
`;
@@ -112,11 +112,11 @@ exports[`<StatsDisplay /> > Conditional Rendering Tests > hides Efficiency secti
│ » API Time: 100ms (100.0%) │
│ » Tool Time: 0s (0.0%) │
│ │
│ │
│ Model Usage │
│ Model Reqs Input Tokens Cache Reads Output Tokens │
│ ──────────────────────────────────────────────────────────────────────────── │
│ gemini-2.5-pro 1 100 0 100 │
│ │
╰──────────────────────────────────────────────────────────────────────────────────────────────────╯"
`;
@@ -155,7 +155,6 @@ exports[`<StatsDisplay /> > Quota Display > renders pooled quota information for
│ » API Time: 0s (0.0%) │
│ » Tool Time: 0s (0.0%) │
│ │
│ │
│ auto Usage │
│ 65% usage remaining │
│ Usage limit: 1,100 │
@@ -166,6 +165,7 @@ exports[`<StatsDisplay /> > Quota Display > renders pooled quota information for
│ ──────────────────────────────────────────────────────────── │
│ gemini-2.5-pro - │
│ gemini-2.5-flash - │
│ │
╰──────────────────────────────────────────────────────────────────────────────────────────────────╯"
`;
@@ -185,11 +185,11 @@ exports[`<StatsDisplay /> > Quota Display > renders quota information for unused
│ » API Time: 0s (0.0%) │
│ » Tool Time: 0s (0.0%) │
│ │
│ │
│ Model Usage │
│ Model Reqs Usage remaining │
│ ──────────────────────────────────────────────────────────── │
│ gemini-2.5-flash - 50.0% resets in 2h │
│ │
╰──────────────────────────────────────────────────────────────────────────────────────────────────╯"
`;
@@ -209,11 +209,11 @@ exports[`<StatsDisplay /> > Quota Display > renders quota information when quota
│ » API Time: 100ms (100.0%) │
│ » Tool Time: 0s (0.0%) │
│ │
│ │
│ Model Usage │
│ Model Reqs Usage remaining │
│ ──────────────────────────────────────────────────────────── │
│ gemini-2.5-pro 1 75.0% resets in 1h 30m │
│ │
╰──────────────────────────────────────────────────────────────────────────────────────────────────╯"
`;
@@ -271,7 +271,6 @@ exports[`<StatsDisplay /> > renders a table with two models correctly 1`] = `
│ » API Time: 19.5s (100.0%) │
│ » Tool Time: 0s (0.0%) │
│ │
│ │
│ Model Usage │
│ Model Reqs Input Tokens Cache Reads Output Tokens │
│ ──────────────────────────────────────────────────────────────────────────── │
@@ -279,6 +278,7 @@ exports[`<StatsDisplay /> > renders a table with two models correctly 1`] = `
│ gemini-2.5-flash 5 15,000 10,000 15,000 │
│ │
│ Savings Highlight: 10,500 (40.4%) of input tokens were served from the cache, reducing costs. │
│ │
╰──────────────────────────────────────────────────────────────────────────────────────────────────╯"
`;
@@ -299,13 +299,13 @@ exports[`<StatsDisplay /> > renders all sections when all data is present 1`] =
│ » API Time: 100ms (44.8%) │
│ » Tool Time: 123ms (55.2%) │
│ │
│ │
│ Model Usage │
│ Model Reqs Input Tokens Cache Reads Output Tokens │
│ ──────────────────────────────────────────────────────────────────────────── │
│ gemini-2.5-pro 1 50 50 100 │
│ │
│ Savings Highlight: 50 (50.0%) of input tokens were served from the cache, reducing costs. │
│ │
╰──────────────────────────────────────────────────────────────────────────────────────────────────╯"
`;
@@ -13,10 +13,10 @@ exports[`Initial Theme Selection > should default to a dark theme when terminal
│ 6. GitHub Dark │ 5 a, b = b, a + b │ │
│ 7. Holiday Dark │ 6 return a │ │
│ 8. Shades Of Purple Dark │ │ │
│ 9. ANSI Light Light (Incompatible) │ 1 - print("Hello, " + name) │ │
│ 10. Ayu Light Light (Incompatible) │ 1 + print(f"Hello, {name}!") │ │
│ 11. Default Light Light (Incompatible) │ │ │
│ 12. GitHub Light Light (Incompatible) └────────────────────────────────────────────────────────────┘ │
│ 9. Solarized Dark │ 1 - print("Hello, " + name) │ │
│ 10. ANSI Light │ 1 + print(f"Hello, {name}!") │ │
│ 11. Ayu Light │ │ │
│ 12. Default Light └────────────────────────────────────────────────────────────┘ │
│ ▼ │
│ │
│ (Use Enter to select, Tab to configure scope, Esc to close) │
@@ -29,18 +29,18 @@ exports[`Initial Theme Selection > should default to a light theme when terminal
│ │
│ > Select Theme Preview │
│ ▲ ┌────────────────────────────────────────────────────────────┐ │
│ 1. ANSI Light Light │ │ │
│ 2. Ayu Light Light │ 1 # function │ │
│ ● 3. Default Light Light │ 2 def fibonacci(n): │ │
│ 4. GitHub Light Light │ 3 a, b = 0, 1 │ │
│ 1. ANSI Light │ │ │
│ 2. Ayu Light │ 1 # function │ │
│ ● 3. Default Light │ 2 def fibonacci(n): │ │
│ 4. GitHub Light │ 3 a, b = 0, 1 │ │
│ 5. Google Code Light │ 4 for _ in range(n): │ │
│ 6. Xcode Light │ 5 a, b = b, a + b │ │
│ 7. ANSI Dark (Incompatible) │ 6 return a │ │
│ 8. Atom One Dark (Incompatible) │ │ │
│ 9. Ayu Dark (Incompatible) │ 1 - print("Hello, " + name) │ │
│ 10. Default Dark (Incompatible) │ 1 + print(f"Hello, {name}!") │ │
│ 11. Dracula Dark (Incompatible) │ │ │
│ 12. GitHub Dark (Incompatible) └────────────────────────────────────────────────────────────┘ │
│ 6. Solarized Light │ 5 a, b = b, a + b │ │
│ 7. Xcode Light │ 6 return a │ │
│ 8. ANSI Dark (Incompatible) │ │ │
│ 9. Atom One Dark (Incompatible) │ 1 - print("Hello, " + name) │ │
│ 10. Ayu Dark (Incompatible) │ 1 + print(f"Hello, {name}!") │ │
│ 11. Default Dark (Incompatible) │ │ │
│ 12. Dracula Dark (Incompatible) └────────────────────────────────────────────────────────────┘ │
│ ▼ │
│ │
│ (Use Enter to select, Tab to configure scope, Esc to close) │
@@ -61,10 +61,10 @@ exports[`Initial Theme Selection > should use the theme from settings even if te
│ 6. GitHub Dark │ 5 a, b = b, a + b │ │
│ 7. Holiday Dark │ 6 return a │ │
│ 8. Shades Of Purple Dark │ │ │
│ 9. ANSI Light Light (Incompatible) │ 1 - print("Hello, " + name) │ │
│ 10. Ayu Light Light (Incompatible) │ 1 + print(f"Hello, {name}!") │ │
│ 11. Default Light Light (Incompatible) │ │ │
│ 12. GitHub Light Light (Incompatible) └────────────────────────────────────────────────────────────┘ │
│ 9. Solarized Dark │ 1 - print("Hello, " + name) │ │
│ 10. ANSI Light │ 1 + print(f"Hello, {name}!") │ │
│ 11. Ayu Light │ │ │
│ 12. Default Light └────────────────────────────────────────────────────────────┘ │
│ ▼ │
│ │
│ (Use Enter to select, Tab to configure scope, Esc to close) │
@@ -98,10 +98,10 @@ exports[`ThemeDialog Snapshots > should render correctly in theme selection mode
│ 6. GitHub Dark │ 5 a, b = b, a + b │ │
│ 7. Holiday Dark │ 6 return a │ │
│ 8. Shades Of Purple Dark │ │ │
│ 9. ANSI Light Light (Incompatible) │ 1 - print("Hello, " + name) │ │
│ 10. Ayu Light Light (Incompatible) │ 1 + print(f"Hello, {name}!") │ │
│ 11. Default Light Light (Incompatible) │ │ │
│ 12. GitHub Light Light (Incompatible) └────────────────────────────────────────────────────────────┘ │
│ 9. Solarized Dark │ 1 - print("Hello, " + name) │ │
│ 10. ANSI Light │ 1 + print(f"Hello, {name}!") │ │
│ 11. Ayu Light │ │ │
│ 12. Default Light └────────────────────────────────────────────────────────────┘ │
│ ▼ │
│ │
│ (Use Enter to select, Tab to configure scope, Esc to close) │
@@ -40,6 +40,25 @@ exports[`ToolConfirmationQueue > does not render expansion hint when constrainHe
╰──────────────────────────────────────────────────────────────────────────────╯"
`;
exports[`ToolConfirmationQueue > provides more height for ask_user by subtracting less overhead 1`] = `
"╭──────────────────────────────────────────────────────────────────────────────╮
│ Answer Questions │
│ │
│ Line 1 │
│ Line 2 │
│ Line 3 │
│ Line 4 │
│ Line 5 │
│ Line 6 │
│ │
│ ● 1. Option 1 │
│ Desc │
│ 2. Enter a custom value │
│ │
│ Enter to select · ↑/↓ to navigate · Esc to cancel │
╰──────────────────────────────────────────────────────────────────────────────╯"
`;
exports[`ToolConfirmationQueue > renders AskUser tool confirmation with Success color 1`] = `
"╭──────────────────────────────────────────────────────────────────────────────╮
│ Answer Questions │
@@ -88,20 +88,16 @@ describe('<ShellToolMessage />', () => {
CoreToolCallStatus.Executing,
);
updateStatus = setStatus;
return (
<ShellToolMessage
{...baseProps}
status={status}
embeddedShellFocused={true}
activeShellPtyId={1}
ptyId={1}
/>
);
return <ShellToolMessage {...baseProps} status={status} ptyId={1} />;
};
const { lastFrame } = renderWithProviders(<Wrapper />, {
uiActions,
uiState: { streamingState: StreamingState.Idle },
uiState: {
streamingState: StreamingState.Idle,
embeddedShellFocused: true,
activePtyId: 1,
},
});
// Verify it is initially focused
@@ -143,21 +139,29 @@ describe('<ShellToolMessage />', () => {
'renders in Alternate Buffer mode while focused',
{
status: CoreToolCallStatus.Executing,
embeddedShellFocused: true,
activeShellPtyId: 1,
ptyId: 1,
},
{ useAlternateBuffer: true },
{
useAlternateBuffer: true,
uiState: {
embeddedShellFocused: true,
activePtyId: 1,
},
},
],
[
'renders in Alternate Buffer mode while unfocused',
{
status: CoreToolCallStatus.Executing,
embeddedShellFocused: false,
activeShellPtyId: 1,
ptyId: 1,
},
{ useAlternateBuffer: true },
{
useAlternateBuffer: true,
uiState: {
embeddedShellFocused: false,
activePtyId: 1,
},
},
],
])('%s', async (_, props, options) => {
const { lastFrame } = renderShell(props, options);
@@ -199,12 +203,16 @@ describe('<ShellToolMessage />', () => {
resultDisplay: LONG_OUTPUT,
renderOutputAsMarkdown: false,
availableTerminalHeight,
activeShellPtyId: 1,
ptyId: focused ? 1 : 2,
ptyId: 1,
status: CoreToolCallStatus.Executing,
embeddedShellFocused: focused,
},
{ useAlternateBuffer: true },
{
useAlternateBuffer: true,
uiState: {
activePtyId: focused ? 1 : 2,
embeddedShellFocused: focused,
},
},
);
await waitFor(() => {
@@ -29,9 +29,9 @@ import {
import { useAlternateBuffer } from '../../hooks/useAlternateBuffer.js';
import { type Config, CoreToolCallStatus } from '@google/gemini-cli-core';
import { useUIState } from '../../contexts/UIStateContext.js';
export interface ShellToolMessageProps extends ToolMessageProps {
activeShellPtyId?: number | null;
embeddedShellFocused?: boolean;
config?: Config;
}
@@ -52,10 +52,6 @@ export const ShellToolMessage: React.FC<ShellToolMessageProps> = ({
renderOutputAsMarkdown = true,
activeShellPtyId,
embeddedShellFocused,
ptyId,
config,
@@ -66,6 +62,7 @@ export const ShellToolMessage: React.FC<ShellToolMessageProps> = ({
borderDimColor,
}) => {
const { activePtyId: activeShellPtyId, embeddedShellFocused } = useUIState();
const isAlternateBuffer = useAlternateBuffer();
const isThisShellFocused = checkIsShellFocused(
name,
@@ -235,6 +235,10 @@ export const ToolConfirmationMessage: React.FC<
return undefined;
}
if (handlesOwnUI) {
return availableTerminalHeight;
}
// Calculate the vertical space (in lines) consumed by UI elements
// surrounding the main body content.
const PADDING_OUTER_Y = 2; // Main container has `padding={1}` (top & bottom).
@@ -253,7 +257,7 @@ export const ToolConfirmationMessage: React.FC<
1; // Reserve one line for 'ShowMoreLines' hint
return Math.max(availableTerminalHeight - surroundingElementsHeight, 1);
}, [availableTerminalHeight, getOptions]);
}, [availableTerminalHeight, getOptions, handlesOwnUI]);
const { question, bodyContent, options } = useMemo(() => {
let bodyContent: React.ReactNode | null = null;
@@ -7,7 +7,11 @@
import { renderWithProviders } from '../../../test-utils/render.js';
import { describe, it, expect, vi, afterEach } from 'vitest';
import { ToolGroupMessage } from './ToolGroupMessage.js';
import type { IndividualToolCallDisplay } from '../../types.js';
import type {
HistoryItem,
HistoryItemWithoutId,
IndividualToolCallDisplay,
} from '../../types.js';
import { Scrollable } from '../shared/Scrollable.js';
import {
makeFakeConfig,
@@ -40,10 +44,17 @@ describe('<ToolGroupMessage />', () => {
});
const baseProps = {
groupId: 1,
terminalWidth: 80,
};
const createItem = (
tools: IndividualToolCallDisplay[],
): HistoryItem | HistoryItemWithoutId => ({
id: 1,
type: 'tool_group',
tools,
});
const baseMockConfig = makeFakeConfig({
model: 'gemini-pro',
targetDir: os.tmpdir(),
@@ -56,12 +67,18 @@ describe('<ToolGroupMessage />', () => {
describe('Golden Snapshots', () => {
it('renders single successful tool call', () => {
const toolCalls = [createToolCall()];
const item = createItem(toolCalls);
const { lastFrame, unmount } = renderWithProviders(
<ToolGroupMessage {...baseProps} toolCalls={toolCalls} />,
<ToolGroupMessage {...baseProps} item={item} toolCalls={toolCalls} />,
{
config: baseMockConfig,
uiState: {
pendingHistoryItems: [{ type: 'tool_group', tools: toolCalls }],
pendingHistoryItems: [
{
type: 'tool_group',
tools: toolCalls,
},
],
},
},
);
@@ -81,9 +98,10 @@ describe('<ToolGroupMessage />', () => {
},
}),
];
const item = createItem(toolCalls);
const { lastFrame, unmount } = renderWithProviders(
<ToolGroupMessage {...baseProps} toolCalls={toolCalls} />,
<ToolGroupMessage {...baseProps} item={item} toolCalls={toolCalls} />,
{ config: baseMockConfig },
);
@@ -113,13 +131,19 @@ describe('<ToolGroupMessage />', () => {
status: CoreToolCallStatus.Error,
}),
];
const item = createItem(toolCalls);
const { lastFrame, unmount } = renderWithProviders(
<ToolGroupMessage {...baseProps} toolCalls={toolCalls} />,
<ToolGroupMessage {...baseProps} item={item} toolCalls={toolCalls} />,
{
config: baseMockConfig,
uiState: {
pendingHistoryItems: [{ type: 'tool_group', tools: toolCalls }],
pendingHistoryItems: [
{
type: 'tool_group',
tools: toolCalls,
},
],
},
},
);
@@ -153,13 +177,19 @@ describe('<ToolGroupMessage />', () => {
status: CoreToolCallStatus.Scheduled,
}),
];
const item = createItem(toolCalls);
const { lastFrame, unmount } = renderWithProviders(
<ToolGroupMessage {...baseProps} toolCalls={toolCalls} />,
<ToolGroupMessage {...baseProps} item={item} toolCalls={toolCalls} />,
{
config: baseMockConfig,
uiState: {
pendingHistoryItems: [{ type: 'tool_group', tools: toolCalls }],
pendingHistoryItems: [
{
type: 'tool_group',
tools: toolCalls,
},
],
},
},
);
@@ -188,16 +218,23 @@ describe('<ToolGroupMessage />', () => {
resultDisplay: 'More output here',
}),
];
const item = createItem(toolCalls);
const { lastFrame, unmount } = renderWithProviders(
<ToolGroupMessage
{...baseProps}
item={item}
toolCalls={toolCalls}
availableTerminalHeight={10}
/>,
{
config: baseMockConfig,
uiState: {
pendingHistoryItems: [{ type: 'tool_group', tools: toolCalls }],
pendingHistoryItems: [
{
type: 'tool_group',
tools: toolCalls,
},
],
},
},
);
@@ -213,16 +250,23 @@ describe('<ToolGroupMessage />', () => {
'This is a very long description that might cause wrapping issues',
}),
];
const item = createItem(toolCalls);
const { lastFrame, unmount } = renderWithProviders(
<ToolGroupMessage
{...baseProps}
item={item}
toolCalls={toolCalls}
terminalWidth={40}
/>,
{
config: baseMockConfig,
uiState: {
pendingHistoryItems: [{ type: 'tool_group', tools: toolCalls }],
pendingHistoryItems: [
{
type: 'tool_group',
tools: toolCalls,
},
],
},
},
);
@@ -231,12 +275,19 @@ describe('<ToolGroupMessage />', () => {
});
it('renders empty tool calls array', () => {
const toolCalls: IndividualToolCallDisplay[] = [];
const item = createItem(toolCalls);
const { lastFrame, unmount } = renderWithProviders(
<ToolGroupMessage {...baseProps} toolCalls={[]} />,
<ToolGroupMessage {...baseProps} item={item} toolCalls={toolCalls} />,
{
config: baseMockConfig,
uiState: {
pendingHistoryItems: [{ type: 'tool_group', tools: [] }],
pendingHistoryItems: [
{
type: 'tool_group',
tools: [],
},
],
},
},
);
@@ -260,14 +311,20 @@ describe('<ToolGroupMessage />', () => {
resultDisplay: 'line1\nline2',
}),
];
const item = createItem(toolCalls);
const { lastFrame, unmount } = renderWithProviders(
<Scrollable height={10} hasFocus={true} scrollToBottom={true}>
<ToolGroupMessage {...baseProps} toolCalls={toolCalls} />
<ToolGroupMessage {...baseProps} item={item} toolCalls={toolCalls} />
</Scrollable>,
{
config: baseMockConfig,
uiState: {
pendingHistoryItems: [{ type: 'tool_group', tools: toolCalls }],
pendingHistoryItems: [
{
type: 'tool_group',
tools: toolCalls,
},
],
},
},
);
@@ -285,12 +342,18 @@ describe('<ToolGroupMessage />', () => {
outputFile: '/path/to/output.txt',
}),
];
const item = createItem(toolCalls);
const { lastFrame, unmount } = renderWithProviders(
<ToolGroupMessage {...baseProps} toolCalls={toolCalls} />,
<ToolGroupMessage {...baseProps} item={item} toolCalls={toolCalls} />,
{
config: baseMockConfig,
uiState: {
pendingHistoryItems: [{ type: 'tool_group', tools: toolCalls }],
pendingHistoryItems: [
{
type: 'tool_group',
tools: toolCalls,
},
],
},
},
);
@@ -307,6 +370,7 @@ describe('<ToolGroupMessage />', () => {
resultDisplay: 'line1\nline2\nline3\nline4\nline5',
}),
];
const item1 = createItem(toolCalls1);
const toolCalls2 = [
createToolCall({
callId: '2',
@@ -315,18 +379,33 @@ describe('<ToolGroupMessage />', () => {
resultDisplay: 'line1',
}),
];
const item2 = createItem(toolCalls2);
const { lastFrame, unmount } = renderWithProviders(
<Scrollable height={6} hasFocus={true} scrollToBottom={true}>
<ToolGroupMessage {...baseProps} toolCalls={toolCalls1} />
<ToolGroupMessage {...baseProps} toolCalls={toolCalls2} />
<ToolGroupMessage
{...baseProps}
item={item1}
toolCalls={toolCalls1}
/>
<ToolGroupMessage
{...baseProps}
item={item2}
toolCalls={toolCalls2}
/>
</Scrollable>,
{
config: baseMockConfig,
uiState: {
pendingHistoryItems: [
{ type: 'tool_group', tools: toolCalls1 },
{ type: 'tool_group', tools: toolCalls2 },
{
type: 'tool_group',
tools: toolCalls1,
},
{
type: 'tool_group',
tools: toolCalls2,
},
],
},
},
@@ -344,12 +423,18 @@ describe('<ToolGroupMessage />', () => {
status: CoreToolCallStatus.Success,
}),
];
const item = createItem(toolCalls);
const { lastFrame, unmount } = renderWithProviders(
<ToolGroupMessage {...baseProps} toolCalls={toolCalls} />,
<ToolGroupMessage {...baseProps} item={item} toolCalls={toolCalls} />,
{
config: baseMockConfig,
uiState: {
pendingHistoryItems: [{ type: 'tool_group', tools: toolCalls }],
pendingHistoryItems: [
{
type: 'tool_group',
tools: toolCalls,
},
],
},
},
);
@@ -366,12 +451,18 @@ describe('<ToolGroupMessage />', () => {
status: CoreToolCallStatus.Success,
}),
];
const item = createItem(toolCalls);
const { lastFrame, unmount } = renderWithProviders(
<ToolGroupMessage {...baseProps} toolCalls={toolCalls} />,
<ToolGroupMessage {...baseProps} item={item} toolCalls={toolCalls} />,
{
config: baseMockConfig,
uiState: {
pendingHistoryItems: [{ type: 'tool_group', tools: toolCalls }],
pendingHistoryItems: [
{
type: 'tool_group',
tools: toolCalls,
},
],
},
},
);
@@ -396,16 +487,23 @@ describe('<ToolGroupMessage />', () => {
resultDisplay: '', // No result
}),
];
const item = createItem(toolCalls);
const { lastFrame, unmount } = renderWithProviders(
<ToolGroupMessage
{...baseProps}
item={item}
toolCalls={toolCalls}
availableTerminalHeight={20}
/>,
{
config: baseMockConfig,
uiState: {
pendingHistoryItems: [{ type: 'tool_group', tools: toolCalls }],
pendingHistoryItems: [
{
type: 'tool_group',
tools: toolCalls,
},
],
},
},
);
@@ -453,9 +551,10 @@ describe('<ToolGroupMessage />', () => {
resultDisplay,
}),
];
const item = createItem(toolCalls);
const { lastFrame, unmount } = renderWithProviders(
<ToolGroupMessage {...baseProps} toolCalls={toolCalls} />,
<ToolGroupMessage {...baseProps} item={item} toolCalls={toolCalls} />,
{ config: baseMockConfig },
);
@@ -481,9 +580,10 @@ describe('<ToolGroupMessage />', () => {
status: CoreToolCallStatus.Scheduled,
}),
];
const item = createItem(toolCalls);
const { lastFrame, unmount } = renderWithProviders(
<ToolGroupMessage {...baseProps} toolCalls={toolCalls} />,
<ToolGroupMessage {...baseProps} item={item} toolCalls={toolCalls} />,
{ config: baseMockConfig },
);
@@ -502,10 +602,12 @@ describe('<ToolGroupMessage />', () => {
status: CoreToolCallStatus.Executing,
}),
];
const item = createItem(toolCalls);
const { lastFrame, unmount } = renderWithProviders(
<ToolGroupMessage
{...baseProps}
item={item}
toolCalls={toolCalls}
borderBottom={false}
/>,
@@ -540,9 +642,10 @@ describe('<ToolGroupMessage />', () => {
approvalMode: mode,
}),
];
const item = createItem(toolCalls);
const { lastFrame, unmount } = renderWithProviders(
<ToolGroupMessage {...baseProps} toolCalls={toolCalls} />,
<ToolGroupMessage {...baseProps} item={item} toolCalls={toolCalls} />,
{ config: baseMockConfig },
);
@@ -7,27 +7,27 @@
import type React from 'react';
import { useMemo } from 'react';
import { Box, Text } from 'ink';
import type { IndividualToolCallDisplay } from '../../types.js';
import type {
HistoryItem,
HistoryItemWithoutId,
IndividualToolCallDisplay,
} from '../../types.js';
import { ToolCallStatus, mapCoreStatusToDisplayStatus } from '../../types.js';
import { ToolMessage } from './ToolMessage.js';
import { ShellToolMessage } from './ShellToolMessage.js';
import { theme } from '../../semantic-colors.js';
import { useConfig } from '../../contexts/ConfigContext.js';
import { isShellTool, isThisShellFocused } from './ToolShared.js';
import {
CoreToolCallStatus,
shouldHideToolCall,
} from '@google/gemini-cli-core';
import { isShellTool } from './ToolShared.js';
import { shouldHideToolCall } from '@google/gemini-cli-core';
import { ShowMoreLines } from '../ShowMoreLines.js';
import { useUIState } from '../../contexts/UIStateContext.js';
import { getToolGroupBorderAppearance } from '../../utils/borderStyles.js';
interface ToolGroupMessageProps {
groupId: number;
item: HistoryItem | HistoryItemWithoutId;
toolCalls: IndividualToolCallDisplay[];
availableTerminalHeight?: number;
terminalWidth: number;
activeShellPtyId?: number | null;
embeddedShellFocused?: boolean;
onShellInputSubmit?: (input: string) => void;
borderTop?: boolean;
borderBottom?: boolean;
@@ -37,11 +37,10 @@ interface ToolGroupMessageProps {
const TOOL_MESSAGE_HORIZONTAL_MARGIN = 4;
export const ToolGroupMessage: React.FC<ToolGroupMessageProps> = ({
item,
toolCalls: allToolCalls,
availableTerminalHeight,
terminalWidth,
activeShellPtyId,
embeddedShellFocused,
borderTop: borderTopOverride,
borderBottom: borderBottomOverride,
}) => {
@@ -61,7 +60,31 @@ export const ToolGroupMessage: React.FC<ToolGroupMessageProps> = ({
);
const config = useConfig();
const { constrainHeight } = useUIState();
const {
constrainHeight,
activePtyId,
embeddedShellFocused,
backgroundShells,
pendingHistoryItems,
} = useUIState();
const { borderColor, borderDimColor } = useMemo(
() =>
getToolGroupBorderAppearance(
item,
activePtyId,
embeddedShellFocused,
pendingHistoryItems,
backgroundShells,
),
[
item,
activePtyId,
embeddedShellFocused,
pendingHistoryItems,
backgroundShells,
],
);
// We HIDE tools that are still in pre-execution states (Confirming, Pending)
// from the History log. They live in the Global Queue or wait for their turn.
@@ -80,31 +103,6 @@ export const ToolGroupMessage: React.FC<ToolGroupMessageProps> = ({
[toolCalls],
);
const isEmbeddedShellFocused = visibleToolCalls.some((t) =>
isThisShellFocused(
t.name,
t.status,
t.ptyId,
activeShellPtyId,
embeddedShellFocused,
),
);
const hasPending = !visibleToolCalls.every(
(t) => t.status === CoreToolCallStatus.Success,
);
const isShellCommand = toolCalls.some((t) => isShellTool(t.name));
const borderColor =
(isShellCommand && hasPending) || isEmbeddedShellFocused
? theme.ui.symbol
: hasPending
? theme.status.warning
: theme.border.default;
const borderDimColor =
hasPending && (!isShellCommand || !isEmbeddedShellFocused);
const staticHeight = /* border */ 2 + /* marginBottom */ 1;
// If all tools are filtered out (e.g., in-progress AskUser tools, confirming tools),
@@ -175,12 +173,7 @@ export const ToolGroupMessage: React.FC<ToolGroupMessageProps> = ({
width={contentWidth}
>
{isShellToolCall ? (
<ShellToolMessage
{...commonProps}
activeShellPtyId={activeShellPtyId}
embeddedShellFocused={embeddedShellFocused}
config={config}
/>
<ShellToolMessage {...commonProps} config={config} />
) : (
<ToolMessage {...commonProps} />
)}
@@ -13,6 +13,10 @@ import { type AnsiOutput, CoreToolCallStatus } from '@google/gemini-cli-core';
import { renderWithProviders } from '../../../test-utils/render.js';
import { tryParseJSON } from '../../../utils/jsonoutput.js';
vi.mock('../GeminiRespondingSpinner.js', () => ({
GeminiRespondingSpinner: () => <Text>MockRespondingSpinner</Text>,
}));
vi.mock('../TerminalOutput.js', () => ({
TerminalOutput: function MockTerminalOutput({
cursor,
@@ -35,7 +35,7 @@ describe('ToolResultDisplay Overflow', () => {
const { lastFrame } = renderWithProviders(
<OverflowProvider>
<ToolGroupMessage
groupId={1}
item={{ id: 1, type: 'tool_group', tools: toolCalls }}
toolCalls={toolCalls}
availableTerminalHeight={15} // Small height to force overflow
terminalWidth={80}
@@ -79,7 +79,7 @@ describe('ToolMessage Sticky Header Regression', () => {
data={['item1']}
renderItem={() => (
<ToolGroupMessage
groupId={1}
item={{ id: 1, type: 'tool_group', tools: toolCalls }}
toolCalls={toolCalls}
terminalWidth={terminalWidth - 2} // Account for ScrollableList padding
/>
@@ -165,7 +165,7 @@ describe('ToolMessage Sticky Header Regression', () => {
data={['item1']}
renderItem={() => (
<ToolGroupMessage
groupId={1}
item={{ id: 1, type: 'tool_group', tools: toolCalls }}
toolCalls={toolCalls}
terminalWidth={terminalWidth - 2}
/>
@@ -26,7 +26,7 @@ exports[`<ToolMessage /> > ToolStatusIndicator rendering > shows - for Canceled
exports[`<ToolMessage /> > ToolStatusIndicator rendering > shows MockRespondingSpinner for Executing status when streamingState is Responding 1`] = `
"╭──────────────────────────────────────────────────────────────────────────────╮
test-tool A tool for testing
MockRespondingSpinnertest-tool A tool for testing │
│ │
│ Test result │"
`;
@@ -40,14 +40,14 @@ exports[`<ToolMessage /> > ToolStatusIndicator rendering > shows o for Pending s
exports[`<ToolMessage /> > ToolStatusIndicator rendering > shows paused spinner for Executing status when streamingState is Idle 1`] = `
"╭──────────────────────────────────────────────────────────────────────────────╮
test-tool A tool for testing
MockRespondingSpinnertest-tool A tool for testing │
│ │
│ Test result │"
`;
exports[`<ToolMessage /> > ToolStatusIndicator rendering > shows paused spinner for Executing status when streamingState is WaitingForConfirmation 1`] = `
"╭──────────────────────────────────────────────────────────────────────────────╮
test-tool A tool for testing
MockRespondingSpinnertest-tool A tool for testing │
│ │
│ Test result │"
`;
@@ -138,8 +138,10 @@ describe('ScrollableList Demo Behavior', () => {
let listRef: ScrollableListRef<Item> | null = null;
let lastFrame: () => string | undefined;
let result: ReturnType<typeof render>;
await act(async () => {
const result = render(
result = render(
<TestComponent
onAddItem={(add) => {
addItem = add;
@@ -192,6 +194,10 @@ describe('ScrollableList Demo Behavior', () => {
expect(lastFrame!()).toContain('Count: 1003');
});
expect(lastFrame!()).not.toContain('Item 1003');
await act(async () => {
result.unmount();
});
});
it('should display sticky header when scrolled past the item', async () => {
@@ -243,8 +249,9 @@ describe('ScrollableList Demo Behavior', () => {
};
let lastFrame: () => string | undefined;
let result: ReturnType<typeof render>;
await act(async () => {
const result = render(<StickyTestComponent />);
result = render(<StickyTestComponent />);
lastFrame = result.lastFrame;
});
@@ -286,6 +293,10 @@ describe('ScrollableList Demo Behavior', () => {
expect(lastFrame!()).toContain('[Normal] Item 1');
});
expect(lastFrame!()).not.toContain('[STICKY] Item 1');
await act(async () => {
result.unmount();
});
});
describe('Keyboard Navigation', () => {
@@ -299,8 +310,9 @@ describe('ScrollableList Demo Behavior', () => {
title: `Item ${i}`,
}));
let result: ReturnType<typeof render>;
await act(async () => {
const result = render(
result = render(
<MouseProvider mouseEventsEnabled={false}>
<KeypressProvider>
<ScrollProvider>
@@ -378,6 +390,10 @@ describe('ScrollableList Demo Behavior', () => {
await waitFor(() => {
expect(listRef?.getScrollState()?.scrollTop).toBe(0);
});
await act(async () => {
result.unmount();
});
});
});
@@ -386,8 +402,9 @@ describe('ScrollableList Demo Behavior', () => {
const items = [{ id: '1', title: 'Item 1' }];
let lastFrame: () => string | undefined;
let result: ReturnType<typeof render>;
await act(async () => {
const result = render(
result = render(
<MouseProvider mouseEventsEnabled={false}>
<KeypressProvider>
<ScrollProvider>
@@ -411,6 +428,10 @@ describe('ScrollableList Demo Behavior', () => {
await waitFor(() => {
expect(lastFrame()).toContain('Item 1');
});
await act(async () => {
result.unmount();
});
});
});
});
@@ -8,7 +8,7 @@ import { useState, useEffect, useCallback } from 'react';
import { Box, Text } from 'ink';
import Spinner from 'ink-spinner';
import type { Config } from '@google/gemini-cli-core';
import { debugLogger, spawnAsync } from '@google/gemini-cli-core';
import { debugLogger, spawnAsync, LlmRole } from '@google/gemini-cli-core';
import { useKeypress } from '../../hooks/useKeypress.js';
import { keyMatchers, Command } from '../../keyMatchers.js';
@@ -279,6 +279,7 @@ Return a JSON object with:
},
abortSignal: new AbortController().signal,
promptId: 'triage-duplicates',
role: LlmRole.UTILITY_TOOL,
});
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
@@ -8,7 +8,7 @@ import { useState, useEffect, useCallback, useRef } from 'react';
import { Box, Text } from 'ink';
import Spinner from 'ink-spinner';
import type { Config } from '@google/gemini-cli-core';
import { debugLogger, spawnAsync } from '@google/gemini-cli-core';
import { debugLogger, spawnAsync, LlmRole } from '@google/gemini-cli-core';
import { useKeypress } from '../../hooks/useKeypress.js';
import { keyMatchers, Command } from '../../keyMatchers.js';
import { TextInput } from '../shared/TextInput.js';
@@ -223,6 +223,7 @@ Return a JSON object with:
},
abortSignal: abortControllerRef.current.signal,
promptId: 'triage-issues',
role: LlmRole.UTILITY_TOOL,
});
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
-1
View File
@@ -76,7 +76,6 @@ export const INFORMATIVE_TIPS = [
'Set the number of lines to keep when truncating outputs (/settings)…',
'Enable policy-based tool confirmation via message bus (/settings)…',
'Enable write_todos_list tool to generate task lists (/settings)…',
'Enable model routing based on complexity (/settings)…',
'Enable experimental subagents for task delegation (/settings)…',
'Enable extension management features (settings.json)…',
'Enable extension reloading within the CLI session (settings.json)…',
@@ -141,6 +141,7 @@ const MAC_ALT_KEY_CHARACTER_MAP: Record<string, string> = {
'\u00B5': 'm', // "µ" toggle markup view
'\u03A9': 'z', // "Ω" Option+z
'\u00B8': 'Z', // "¸" Option+Shift+z
'\u2202': 'd', // "∂" delete word forward
};
function nonKeyboardEventFilter(
@@ -100,6 +100,7 @@ describe('SessionStatsContext', () => {
thoughts: 20,
tool: 10,
},
roles: {},
},
},
tools: {
@@ -180,6 +181,7 @@ describe('SessionStatsContext', () => {
thoughts: 0,
tool: 0,
},
roles: {},
},
},
tools: {
@@ -130,6 +130,7 @@ export interface UIState {
messageQueue: string[];
queueErrorMessage: string | null;
showApprovalModeIndicator: ApprovalMode;
allowPlanMode: boolean;
// Quota-related state
quota: QuotaState;
currentModel: string;
@@ -1286,7 +1286,9 @@ describe('handleAtCommand', () => {
// Assert
// It SHOULD be called for the tool_group
expect(mockAddItem).toHaveBeenCalledWith(
expect.objectContaining({ type: 'tool_group' }),
expect.objectContaining({
type: 'tool_group',
}),
999,
);
@@ -1343,7 +1345,9 @@ describe('handleAtCommand', () => {
});
expect(containsResourceText).toBe(true);
expect(mockAddItem).toHaveBeenCalledWith(
expect.objectContaining({ type: 'tool_group' }),
expect.objectContaining({
type: 'tool_group',
}),
expect.any(Number),
);
});
+9 -2
View File
@@ -23,10 +23,15 @@ import {
*/
export function mapToDisplay(
toolOrTools: ToolCall[] | ToolCall,
options: { borderTop?: boolean; borderBottom?: boolean } = {},
options: {
borderTop?: boolean;
borderBottom?: boolean;
borderColor?: string;
borderDimColor?: boolean;
} = {},
): HistoryItemToolGroup {
const toolCalls = Array.isArray(toolOrTools) ? toolOrTools : [toolOrTools];
const { borderTop, borderBottom } = options;
const { borderTop, borderBottom, borderColor, borderDimColor } = options;
const toolDisplays = toolCalls.map((call): IndividualToolCallDisplay => {
let description: string;
@@ -104,5 +109,7 @@ export function mapToDisplay(
tools: toolDisplays,
borderTop,
borderBottom,
borderColor,
borderDimColor,
};
}
@@ -202,7 +202,7 @@ describe('useApprovalModeIndicator', () => {
);
expect(result.current).toBe(ApprovalMode.YOLO);
// Shift+Tab cycles back to DEFAULT (since PLAN is disabled by default in mock)
// Shift+Tab cycles back to AUTO_EDIT (from YOLO)
act(() => {
capturedUseKeypressHandler({
name: 'tab',
@@ -236,41 +236,6 @@ describe('useApprovalModeIndicator', () => {
expect(result.current).toBe(ApprovalMode.AUTO_EDIT);
});
it('should cycle through DEFAULT -> PLAN -> AUTO_EDIT -> DEFAULT when plan is enabled', () => {
mockConfigInstance.getApprovalMode.mockReturnValue(ApprovalMode.DEFAULT);
mockConfigInstance.isPlanEnabled.mockReturnValue(true);
renderHook(() =>
useApprovalModeIndicator({
config: mockConfigInstance as unknown as ActualConfigType,
addItem: vi.fn(),
}),
);
// DEFAULT -> PLAN
act(() => {
capturedUseKeypressHandler({ name: 'tab', shift: true } as Key);
});
expect(mockConfigInstance.setApprovalMode).toHaveBeenCalledWith(
ApprovalMode.PLAN,
);
// PLAN -> AUTO_EDIT
act(() => {
capturedUseKeypressHandler({ name: 'tab', shift: true } as Key);
});
expect(mockConfigInstance.setApprovalMode).toHaveBeenCalledWith(
ApprovalMode.AUTO_EDIT,
);
// AUTO_EDIT -> DEFAULT
act(() => {
capturedUseKeypressHandler({ name: 'tab', shift: true } as Key);
});
expect(mockConfigInstance.setApprovalMode).toHaveBeenCalledWith(
ApprovalMode.DEFAULT,
);
});
it('should not toggle if only one key or other keys combinations are pressed', () => {
mockConfigInstance.getApprovalMode.mockReturnValue(ApprovalMode.DEFAULT);
renderHook(() =>
@@ -729,4 +694,44 @@ describe('useApprovalModeIndicator', () => {
ApprovalMode.AUTO_EDIT,
);
});
it('should cycle to PLAN when allowPlanMode is true', () => {
mockConfigInstance.getApprovalMode.mockReturnValue(ApprovalMode.AUTO_EDIT);
renderHook(() =>
useApprovalModeIndicator({
config: mockConfigInstance as unknown as ActualConfigType,
addItem: vi.fn(),
allowPlanMode: true,
}),
);
// AUTO_EDIT -> PLAN
act(() => {
capturedUseKeypressHandler({ name: 'tab', shift: true } as Key);
});
expect(mockConfigInstance.setApprovalMode).toHaveBeenCalledWith(
ApprovalMode.PLAN,
);
});
it('should cycle to DEFAULT when allowPlanMode is false', () => {
mockConfigInstance.getApprovalMode.mockReturnValue(ApprovalMode.AUTO_EDIT);
renderHook(() =>
useApprovalModeIndicator({
config: mockConfigInstance as unknown as ActualConfigType,
addItem: vi.fn(),
allowPlanMode: false,
}),
);
// AUTO_EDIT -> DEFAULT
act(() => {
capturedUseKeypressHandler({ name: 'tab', shift: true } as Key);
});
expect(mockConfigInstance.setApprovalMode).toHaveBeenCalledWith(
ApprovalMode.DEFAULT,
);
});
});
@@ -20,6 +20,7 @@ export interface UseApprovalModeIndicatorArgs {
addItem?: (item: HistoryItemWithoutId, timestamp: number) => void;
onApprovalModeChange?: (mode: ApprovalMode) => void;
isActive?: boolean;
allowPlanMode?: boolean;
}
export function useApprovalModeIndicator({
@@ -27,6 +28,7 @@ export function useApprovalModeIndicator({
addItem,
onApprovalModeChange,
isActive = true,
allowPlanMode = false,
}: UseApprovalModeIndicatorArgs): ApprovalMode {
const currentConfigValue = config.getApprovalMode();
const [showApprovalMode, setApprovalMode] = useState(currentConfigValue);
@@ -72,14 +74,14 @@ export function useApprovalModeIndicator({
const currentMode = config.getApprovalMode();
switch (currentMode) {
case ApprovalMode.DEFAULT:
nextApprovalMode = config.isPlanEnabled()
? ApprovalMode.PLAN
: ApprovalMode.AUTO_EDIT;
break;
case ApprovalMode.PLAN:
nextApprovalMode = ApprovalMode.AUTO_EDIT;
break;
case ApprovalMode.AUTO_EDIT:
nextApprovalMode = allowPlanMode
? ApprovalMode.PLAN
: ApprovalMode.DEFAULT;
break;
case ApprovalMode.PLAN:
nextApprovalMode = ApprovalMode.DEFAULT;
break;
case ApprovalMode.YOLO:
@@ -44,6 +44,7 @@ import type { Part, PartListUnion } from '@google/genai';
import type { UseHistoryManagerReturn } from './useHistoryManager.js';
import type { SlashCommandProcessorResult } from '../types.js';
import { MessageType, StreamingState } from '../types.js';
import type { LoadedSettings } from '../../config/settings.js';
// --- MOCKS ---
@@ -159,13 +160,17 @@ vi.mock('./useLogger.js', () => ({
const mockStartNewPrompt = vi.fn();
const mockAddUsage = vi.fn();
vi.mock('../contexts/SessionContext.js', () => ({
useSessionStats: vi.fn(() => ({
startNewPrompt: mockStartNewPrompt,
addUsage: mockAddUsage,
getPromptCount: vi.fn(() => 5),
})),
}));
vi.mock('../contexts/SessionContext.js', async (importOriginal) => {
const actual = (await importOriginal()) as any;
return {
...actual,
useSessionStats: vi.fn(() => ({
startNewPrompt: mockStartNewPrompt,
addUsage: mockAddUsage,
getPromptCount: vi.fn(() => 5),
})),
};
});
vi.mock('./slashCommandProcessor.js', () => ({
handleSlashCommand: vi.fn().mockReturnValue(false),

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