Compare commits

...

15 Commits

Author SHA1 Message Date
jacob314 25baa158aa Fix bottom border color 2026-02-17 17:32:49 -08: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
84 changed files with 2504 additions and 858 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.
+1
View File
@@ -80,5 +80,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
+1 -1
View File
@@ -61,7 +61,7 @@ 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`).
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">
+3 -3
View File
@@ -1,8 +1,8 @@
# 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.
+47
View File
@@ -172,6 +172,9 @@ The file has the following structure:
`"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.
- `themes`: An array of custom themes provided by the extension. Each theme is
an object that defines the color scheme for the CLI UI. See the
[Themes guide](../cli/themes.md) for more details on the theme format.
When Gemini CLI starts, it loads all the extensions and merges their
configurations. If there are any conflicts, the workspace configuration takes
@@ -302,6 +305,50 @@ extension's root folder and add your agent definition files (`.md`) there.
Gemini CLI will automatically discover and load these agents when the extension
is installed and enabled.
### Themes
Extensions can provide custom themes to personalize the CLI UI. Themes are
defined in the `themes` array in `gemini-extension.json`.
**Example**
```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"
}
}
]
}
```
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
+1
View File
@@ -21,6 +21,7 @@ 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
+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 -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,
+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();
+38 -109
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(
() => ({
@@ -1396,10 +1347,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 +1448,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 +1520,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 +1566,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 +1710,8 @@ Logging in with Google... Restarting Gemini CLI to continue.
setShowErrorDetails,
config,
ideContextState,
setCtrlCPressCount,
setCtrlDPressCount,
handleCtrlCPress,
handleCtrlDPress,
handleSlashCommand,
cancelOngoingRequest,
activePtyId,
+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,
@@ -14,9 +14,7 @@ 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', () => {
@@ -26,35 +24,28 @@ describe('ApprovalModeIndicator', () => {
isPlanEnabled={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', () => {
@@ -64,7 +55,6 @@ describe('ApprovalModeIndicator', () => {
isPlanEnabled={true}
/>,
);
const output = lastFrame();
expect(output).toContain('shift+tab to plan');
expect(lastFrame()).toMatchSnapshot();
});
});
@@ -14,6 +14,16 @@ interface ApprovalModeIndicatorProps {
isPlanEnabled?: 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,
@@ -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 = isPlanEnabled
? 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;
}
+29 -17
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 &&
@@ -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 />);
@@ -47,6 +47,8 @@ interface HistoryItemDisplayProps {
activeShellPtyId?: number | null;
embeddedShellFocused?: boolean;
availableTerminalHeightGemini?: number;
borderColor?: string;
borderDimColor?: boolean;
}
export const HistoryItemDisplay: React.FC<HistoryItemDisplayProps> = ({
@@ -58,6 +60,8 @@ export const HistoryItemDisplay: React.FC<HistoryItemDisplayProps> = ({
activeShellPtyId,
embeddedShellFocused,
availableTerminalHeightGemini,
borderColor,
borderDimColor,
}) => {
const settings = useSettings();
const inlineThinkingMode = getInlineThinkingMode(settings);
@@ -181,6 +185,8 @@ export const HistoryItemDisplay: React.FC<HistoryItemDisplayProps> = ({
embeddedShellFocused={embeddedShellFocused}
borderTop={itemForDisplay.borderTop}
borderBottom={itemForDisplay.borderBottom}
borderColor={borderColor ?? ''}
borderDimColor={borderDimColor ?? false}
/>
)}
{itemForDisplay.type === 'compression' && (
+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,
],
);
@@ -6,7 +6,7 @@
import { renderWithProviders } from '../../test-utils/render.js';
import { waitFor } from '../../test-utils/async.js';
import { MainContent } from './MainContent.js';
import { MainContent, getToolGroupBorderAppearance } from './MainContent.js';
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { Box, Text } from 'ink';
import { act, useState, type JSX } from 'react';
@@ -18,6 +18,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 +77,208 @@ 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
});
});
describe('MainContent', () => {
const defaultMockUiState = {
history: [
@@ -258,7 +461,7 @@ describe('MainContent', () => {
history: [],
pendingHistoryItems: [
{
type: 'tool_group' as const,
type: 'tool_group',
id: 1,
tools: [
{
+145 -29
View File
@@ -19,10 +19,85 @@ import { useMemo, memo, useCallback, useEffect, useRef } from 'react';
import { MAX_GEMINI_MESSAGE_LINES } from '../constants.js';
import { useConfirmingTool } from '../hooks/useConfirmingTool.js';
import { ToolConfirmationQueue } from './ToolConfirmationQueue.js';
import { CoreToolCallStatus } from '@google/gemini-cli-core';
import { isShellTool } from './messages/ToolShared.js';
import { theme } from '../semantic-colors.js';
import type {
HistoryItem,
HistoryItemWithoutId,
HistoryItemToolGroup,
} from '../types.js';
import type { UIState } from '../contexts/UIStateContext.js';
const MemoizedHistoryItemDisplay = memo(HistoryItemDisplay);
const MemoizedAppHeader = memo(AppHeader);
/**
* Calculates the border color and dimming state for a tool group message.
*/
export function getToolGroupBorderAppearance(
item: HistoryItem | HistoryItemWithoutId,
activeShellPtyId: number | null | undefined,
embeddedShellFocused: boolean | undefined,
allPendingItems: HistoryItemWithoutId[],
backgroundShells: UIState['backgroundShells'],
): { borderColor: string; borderDimColor: boolean } {
if (item.type !== 'tool_group') {
return { borderColor: '', borderDimColor: false };
}
// If this item has no tools, it's a closing slice for the current batch.
// We need to look at the last pending item to determine the batch's appearance.
const toolsToInspect =
item.tools.length > 0
? item.tools
: allPendingItems
.filter(
(i): i is HistoryItemToolGroup =>
i !== null && i !== undefined && i.type === 'tool_group',
)
.slice(-1)
.flatMap((i) => i.tools);
const hasPending = toolsToInspect.some(
(t) =>
t.status !== CoreToolCallStatus.Success &&
t.status !== CoreToolCallStatus.Error &&
t.status !== CoreToolCallStatus.Cancelled,
);
const isEmbeddedShellFocused = toolsToInspect.some(
(t) =>
isShellTool(t.name) &&
t.status === CoreToolCallStatus.Executing &&
t.ptyId === activeShellPtyId &&
!!embeddedShellFocused,
);
const isShellCommand = toolsToInspect.some((t) => isShellTool(t.name));
// If we have an active PTY that isn't a background shell, then the current
// pending batch is definitely a shell batch.
const isCurrentlyInShellTurn =
!!activeShellPtyId && !backgroundShells.has(activeShellPtyId);
const isShell =
isShellCommand || (item.tools.length === 0 && isCurrentlyInShellTurn);
const isPending =
hasPending || (item.tools.length === 0 && isCurrentlyInShellTurn);
const borderColor =
(isShell && isPending) || isEmbeddedShellFocused
? theme.ui.symbol
: isPending
? theme.status.warning
: theme.border.default;
const borderDimColor = isPending && (!isShell || !isEmbeddedShellFocused);
return { borderColor, borderDimColor };
}
// Limit Gemini messages to a very high number of lines to mitigate performance
// issues in the worst case if we somehow get an enormous response from Gemini.
// This threshold is arbitrary but should be high enough to never impact normal
@@ -49,49 +124,77 @@ export const MainContent = () => {
staticAreaMaxItemHeight,
availableTerminalHeight,
cleanUiDetailsVisible,
activePtyId,
embeddedShellFocused,
backgroundShells,
} = uiState;
const showHeaderDetails = cleanUiDetailsVisible;
const historyItems = useMemo(
() =>
uiState.history.map((h) => (
<MemoizedHistoryItemDisplay
terminalWidth={mainAreaWidth}
availableTerminalHeight={staticAreaMaxItemHeight}
availableTerminalHeightGemini={MAX_GEMINI_MESSAGE_LINES}
key={h.id}
item={h}
isPending={false}
commands={uiState.slashCommands}
/>
)),
uiState.history.map((h) => {
const { borderColor, borderDimColor } = getToolGroupBorderAppearance(
h,
activePtyId,
embeddedShellFocused,
[],
backgroundShells,
);
return (
<MemoizedHistoryItemDisplay
terminalWidth={mainAreaWidth}
availableTerminalHeight={staticAreaMaxItemHeight}
availableTerminalHeightGemini={MAX_GEMINI_MESSAGE_LINES}
key={h.id}
item={h}
isPending={false}
commands={uiState.slashCommands}
borderColor={borderColor}
borderDimColor={borderDimColor}
/>
);
}),
[
uiState.history,
mainAreaWidth,
staticAreaMaxItemHeight,
uiState.slashCommands,
activePtyId,
embeddedShellFocused,
backgroundShells,
],
);
const pendingItems = useMemo(
() => (
<Box flexDirection="column">
{pendingHistoryItems.map((item, i) => (
<HistoryItemDisplay
key={i}
availableTerminalHeight={
(uiState.constrainHeight && !isAlternateBuffer) ||
isAlternateBuffer
? availableTerminalHeight
: undefined
}
terminalWidth={mainAreaWidth}
item={{ ...item, id: 0 }}
isPending={true}
activeShellPtyId={uiState.activePtyId}
embeddedShellFocused={uiState.embeddedShellFocused}
/>
))}
{pendingHistoryItems.map((item, i) => {
const { borderColor, borderDimColor } = getToolGroupBorderAppearance(
item,
activePtyId,
embeddedShellFocused,
pendingHistoryItems,
backgroundShells,
);
return (
<HistoryItemDisplay
key={i}
availableTerminalHeight={
(uiState.constrainHeight && !isAlternateBuffer) ||
isAlternateBuffer
? availableTerminalHeight
: undefined
}
terminalWidth={mainAreaWidth}
item={{ ...item, id: 0 }}
isPending={true}
activeShellPtyId={activePtyId}
embeddedShellFocused={embeddedShellFocused}
borderColor={borderColor}
borderDimColor={borderDimColor}
/>
);
})}
{showConfirmationQueue && confirmingTool && (
<ToolConfirmationQueue confirmingTool={confirmingTool} />
)}
@@ -103,8 +206,9 @@ export const MainContent = () => {
isAlternateBuffer,
availableTerminalHeight,
mainAreaWidth,
uiState.activePtyId,
uiState.embeddedShellFocused,
activePtyId,
embeddedShellFocused,
backgroundShells,
showConfirmationQueue,
confirmingTool,
],
@@ -130,6 +234,13 @@ export const MainContent = () => {
/>
);
} else if (item.type === 'history') {
const { borderColor, borderDimColor } = getToolGroupBorderAppearance(
item.item,
activePtyId,
embeddedShellFocused,
[],
backgroundShells,
);
return (
<MemoizedHistoryItemDisplay
terminalWidth={mainAreaWidth}
@@ -139,6 +250,8 @@ export const MainContent = () => {
item={item.item}
isPending={false}
commands={uiState.slashCommands}
borderColor={borderColor}
borderDimColor={borderDimColor}
/>
);
} else {
@@ -151,6 +264,9 @@ export const MainContent = () => {
mainAreaWidth,
uiState.slashCommands,
pendingItems,
activePtyId,
embeddedShellFocused,
backgroundShells,
],
);
@@ -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')>();
@@ -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,
);
@@ -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
@@ -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) │
@@ -42,6 +42,8 @@ describe('<ToolGroupMessage />', () => {
const baseProps = {
groupId: 1,
terminalWidth: 80,
borderColor: 'grey',
borderDimColor: false,
};
const baseMockConfig = makeFakeConfig({
@@ -61,7 +63,12 @@ describe('<ToolGroupMessage />', () => {
{
config: baseMockConfig,
uiState: {
pendingHistoryItems: [{ type: 'tool_group', tools: toolCalls }],
pendingHistoryItems: [
{
type: 'tool_group',
tools: toolCalls,
},
],
},
},
);
@@ -119,7 +126,12 @@ describe('<ToolGroupMessage />', () => {
{
config: baseMockConfig,
uiState: {
pendingHistoryItems: [{ type: 'tool_group', tools: toolCalls }],
pendingHistoryItems: [
{
type: 'tool_group',
tools: toolCalls,
},
],
},
},
);
@@ -159,7 +171,12 @@ describe('<ToolGroupMessage />', () => {
{
config: baseMockConfig,
uiState: {
pendingHistoryItems: [{ type: 'tool_group', tools: toolCalls }],
pendingHistoryItems: [
{
type: 'tool_group',
tools: toolCalls,
},
],
},
},
);
@@ -197,7 +214,12 @@ describe('<ToolGroupMessage />', () => {
{
config: baseMockConfig,
uiState: {
pendingHistoryItems: [{ type: 'tool_group', tools: toolCalls }],
pendingHistoryItems: [
{
type: 'tool_group',
tools: toolCalls,
},
],
},
},
);
@@ -222,7 +244,12 @@ describe('<ToolGroupMessage />', () => {
{
config: baseMockConfig,
uiState: {
pendingHistoryItems: [{ type: 'tool_group', tools: toolCalls }],
pendingHistoryItems: [
{
type: 'tool_group',
tools: toolCalls,
},
],
},
},
);
@@ -236,7 +263,12 @@ describe('<ToolGroupMessage />', () => {
{
config: baseMockConfig,
uiState: {
pendingHistoryItems: [{ type: 'tool_group', tools: [] }],
pendingHistoryItems: [
{
type: 'tool_group',
tools: [],
},
],
},
},
);
@@ -267,7 +299,12 @@ describe('<ToolGroupMessage />', () => {
{
config: baseMockConfig,
uiState: {
pendingHistoryItems: [{ type: 'tool_group', tools: toolCalls }],
pendingHistoryItems: [
{
type: 'tool_group',
tools: toolCalls,
},
],
},
},
);
@@ -290,7 +327,12 @@ describe('<ToolGroupMessage />', () => {
{
config: baseMockConfig,
uiState: {
pendingHistoryItems: [{ type: 'tool_group', tools: toolCalls }],
pendingHistoryItems: [
{
type: 'tool_group',
tools: toolCalls,
},
],
},
},
);
@@ -325,8 +367,14 @@ describe('<ToolGroupMessage />', () => {
config: baseMockConfig,
uiState: {
pendingHistoryItems: [
{ type: 'tool_group', tools: toolCalls1 },
{ type: 'tool_group', tools: toolCalls2 },
{
type: 'tool_group',
tools: toolCalls1,
},
{
type: 'tool_group',
tools: toolCalls2,
},
],
},
},
@@ -349,7 +397,12 @@ describe('<ToolGroupMessage />', () => {
{
config: baseMockConfig,
uiState: {
pendingHistoryItems: [{ type: 'tool_group', tools: toolCalls }],
pendingHistoryItems: [
{
type: 'tool_group',
tools: toolCalls,
},
],
},
},
);
@@ -371,7 +424,12 @@ describe('<ToolGroupMessage />', () => {
{
config: baseMockConfig,
uiState: {
pendingHistoryItems: [{ type: 'tool_group', tools: toolCalls }],
pendingHistoryItems: [
{
type: 'tool_group',
tools: toolCalls,
},
],
},
},
);
@@ -405,7 +463,12 @@ describe('<ToolGroupMessage />', () => {
{
config: baseMockConfig,
uiState: {
pendingHistoryItems: [{ type: 'tool_group', tools: toolCalls }],
pendingHistoryItems: [
{
type: 'tool_group',
tools: toolCalls,
},
],
},
},
);
@@ -13,11 +13,8 @@ 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';
@@ -31,6 +28,8 @@ interface ToolGroupMessageProps {
onShellInputSubmit?: (input: string) => void;
borderTop?: boolean;
borderBottom?: boolean;
borderColor: string;
borderDimColor: boolean;
}
// Main component renders the border and maps the tools using ToolMessage
@@ -44,6 +43,8 @@ export const ToolGroupMessage: React.FC<ToolGroupMessageProps> = ({
embeddedShellFocused,
borderTop: borderTopOverride,
borderBottom: borderBottomOverride,
borderColor,
borderDimColor,
}) => {
// Filter out tool calls that should be hidden (e.g. in-progress Ask User, or Plan Mode operations).
const toolCalls = useMemo(
@@ -80,31 +81,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),
@@ -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,6 +35,8 @@ describe('ToolResultDisplay Overflow', () => {
const { lastFrame } = renderWithProviders(
<OverflowProvider>
<ToolGroupMessage
borderColor="grey"
borderDimColor={false}
groupId={1}
toolCalls={toolCalls}
availableTerminalHeight={15} // Small height to force overflow
@@ -79,6 +79,8 @@ describe('ToolMessage Sticky Header Regression', () => {
data={['item1']}
renderItem={() => (
<ToolGroupMessage
borderColor="grey"
borderDimColor={false}
groupId={1}
toolCalls={toolCalls}
terminalWidth={terminalWidth - 2} // Account for ScrollableList padding
@@ -165,6 +167,8 @@ describe('ToolMessage Sticky Header Regression', () => {
data={['item1']}
renderItem={() => (
<ToolGroupMessage
borderColor="grey"
borderDimColor={false}
groupId={1}
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();
});
});
});
});
-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)…',
@@ -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),
);
});
+4 -1
View File
@@ -23,7 +23,10 @@ import {
*/
export function mapToDisplay(
toolOrTools: ToolCall[] | ToolCall,
options: { borderTop?: boolean; borderBottom?: boolean } = {},
options: {
borderTop?: boolean;
borderBottom?: boolean;
} = {},
): HistoryItemToolGroup {
const toolCalls = Array.isArray(toolOrTools) ? toolOrTools : [toolOrTools];
const { borderTop, borderBottom } = options;
@@ -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,7 +236,7 @@ describe('useApprovalModeIndicator', () => {
expect(result.current).toBe(ApprovalMode.AUTO_EDIT);
});
it('should cycle through DEFAULT -> PLAN -> AUTO_EDIT -> DEFAULT when plan is enabled', () => {
it('should cycle through DEFAULT -> AUTO_EDIT -> PLAN -> DEFAULT when plan is enabled', () => {
mockConfigInstance.getApprovalMode.mockReturnValue(ApprovalMode.DEFAULT);
mockConfigInstance.isPlanEnabled.mockReturnValue(true);
renderHook(() =>
@@ -246,15 +246,7 @@ describe('useApprovalModeIndicator', () => {
}),
);
// DEFAULT -> PLAN
act(() => {
capturedUseKeypressHandler({ name: 'tab', shift: true } as Key);
});
expect(mockConfigInstance.setApprovalMode).toHaveBeenCalledWith(
ApprovalMode.PLAN,
);
// PLAN -> AUTO_EDIT
// DEFAULT -> AUTO_EDIT
act(() => {
capturedUseKeypressHandler({ name: 'tab', shift: true } as Key);
});
@@ -262,7 +254,15 @@ describe('useApprovalModeIndicator', () => {
ApprovalMode.AUTO_EDIT,
);
// AUTO_EDIT -> DEFAULT
// AUTO_EDIT -> PLAN
act(() => {
capturedUseKeypressHandler({ name: 'tab', shift: true } as Key);
});
expect(mockConfigInstance.setApprovalMode).toHaveBeenCalledWith(
ApprovalMode.PLAN,
);
// PLAN -> DEFAULT
act(() => {
capturedUseKeypressHandler({ name: 'tab', shift: true } as Key);
});
@@ -72,14 +72,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 = config.isPlanEnabled()
? 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 ---
+98 -40
View File
@@ -78,6 +78,11 @@ import {
type TrackedWaitingToolCall,
type TrackedExecutingToolCall,
} from './useToolScheduler.js';
import { theme } from '../semantic-colors.js';
import {
isShellTool,
isThisShellFocused,
} from '../components/messages/ToolShared.js';
import { promises as fs } from 'node:fs';
import path from 'node:path';
import { useSessionStats } from '../contexts/SessionContext.js';
@@ -120,6 +125,42 @@ function showCitations(settings: LoadedSettings): boolean {
return true;
}
export function getToolGroupBorderAppearance(
toolCalls: TrackedToolCall[],
activeShellPtyId: number | null,
embeddedShellFocused: boolean,
): { borderColor: string; borderDimColor: boolean } {
const hasPending = toolCalls.some(
(t) =>
t.status !== 'success' &&
t.status !== 'error' &&
t.status !== 'cancelled',
);
const isEmbeddedShellFocused = toolCalls.some((t) =>
isThisShellFocused(
t.request.name,
t.status,
t.status === 'executing' ? t.pid : undefined,
activeShellPtyId,
embeddedShellFocused,
),
);
const isShellCommand = toolCalls.some((t) => isShellTool(t.request.name));
const borderColor =
(isShellCommand && hasPending) || isEmbeddedShellFocused
? theme.ui.symbol
: hasPending
? theme.status.warning
: theme.border.default;
const borderDimColor =
hasPending && (!isShellCommand || !isEmbeddedShellFocused);
return { borderColor, borderDimColor };
}
/**
* Calculates the current streaming state based on tool call status and responding flag.
*/
@@ -250,6 +291,8 @@ export const useGeminiStream = (
mapTrackedToolCallsToDisplay(toolsToPush as TrackedToolCall[], {
borderTop: isFirstToolInGroupRef.current,
borderBottom: true,
borderColor: theme.border.default,
borderDimColor: false,
}),
);
}
@@ -290,6 +333,45 @@ export const useGeminiStream = (
getPreferredEditor,
);
const activeToolPtyId = useMemo(() => {
const executingShellTool = toolCalls.find(
(tc) =>
tc.status === 'executing' && tc.request.name === 'run_shell_command',
);
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
return (executingShellTool as TrackedExecutingToolCall | undefined)?.pid;
}, [toolCalls]);
const onExec = useCallback(async (done: Promise<void>) => {
setIsResponding(true);
await done;
setIsResponding(false);
}, []);
const {
handleShellCommand,
activeShellPtyId,
lastShellOutputTime,
backgroundShellCount,
isBackgroundShellVisible,
toggleBackgroundShell,
backgroundCurrentShell,
registerBackgroundShell,
dismissBackgroundShell,
backgroundShells,
} = useShellCommandProcessor(
addItem,
setPendingHistoryItem,
onExec,
onDebugMessage,
config,
geminiClient,
setShellInputFocused,
terminalWidth,
terminalHeight,
activeToolPtyId,
);
const streamingState = useMemo(
() => calculateStreamingState(isResponding, toolCalls),
[isResponding, toolCalls],
@@ -347,6 +429,11 @@ export const useGeminiStream = (
const historyItem = mapTrackedToolCallsToDisplay(tc, {
borderTop: isFirst,
borderBottom: isLastInBatch,
...getToolGroupBorderAppearance(
toolCalls,
activeShellPtyId,
!!isShellFocused,
),
});
addItem(historyItem);
isFirst = false;
@@ -362,6 +449,8 @@ export const useGeminiStream = (
setPushedToolCallIds,
setIsFirstToolInGroup,
addItem,
activeShellPtyId,
isShellFocused,
]);
const pendingToolGroupItems = useMemo((): HistoryItemWithoutId[] => {
@@ -371,11 +460,18 @@ export const useGeminiStream = (
const items: HistoryItemWithoutId[] = [];
const appearance = getToolGroupBorderAppearance(
toolCalls,
activeShellPtyId,
!!isShellFocused,
);
if (remainingTools.length > 0) {
items.push(
mapTrackedToolCallsToDisplay(remainingTools, {
borderTop: pushedToolCallIds.size === 0,
borderBottom: false, // Stay open to connect with the slice below
...appearance,
}),
);
}
@@ -423,20 +519,12 @@ export const useGeminiStream = (
tools: [] as IndividualToolCallDisplay[],
borderTop: false,
borderBottom: true,
...appearance,
});
}
return items;
}, [toolCalls, pushedToolCallIds]);
const activeToolPtyId = useMemo(() => {
const executingShellTool = toolCalls.find(
(tc) =>
tc.status === 'executing' && tc.request.name === 'run_shell_command',
);
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
return (executingShellTool as TrackedExecutingToolCall | undefined)?.pid;
}, [toolCalls]);
}, [toolCalls, pushedToolCallIds, activeShellPtyId, isShellFocused]);
const lastQueryRef = useRef<PartListUnion | null>(null);
const lastPromptIdRef = useRef<string | null>(null);
@@ -448,36 +536,6 @@ export const useGeminiStream = (
onComplete: (result: { userSelection: 'disable' | 'keep' }) => void;
} | null>(null);
const onExec = useCallback(async (done: Promise<void>) => {
setIsResponding(true);
await done;
setIsResponding(false);
}, []);
const {
handleShellCommand,
activeShellPtyId,
lastShellOutputTime,
backgroundShellCount,
isBackgroundShellVisible,
toggleBackgroundShell,
backgroundCurrentShell,
registerBackgroundShell,
dismissBackgroundShell,
backgroundShells,
} = useShellCommandProcessor(
addItem,
setPendingHistoryItem,
onExec,
onDebugMessage,
config,
geminiClient,
setShellInputFocused,
terminalWidth,
terminalHeight,
activeToolPtyId,
);
const activePtyId = activeShellPtyId || activeToolPtyId;
const prevActiveShellPtyIdRef = useRef<number | null>(null);
@@ -341,6 +341,46 @@ Your admin might have disabled the access. Contact them to enable the Preview Re
expect(result.current.proQuotaRequest).toBeNull();
});
it('should handle ModelNotFoundError with invalid model correctly', async () => {
const { result } = renderHook(() =>
useQuotaAndFallback({
config: mockConfig,
historyManager: mockHistoryManager,
userTier: UserTierId.FREE,
setModelSwitchedFromQuotaError: mockSetModelSwitchedFromQuotaError,
onShowAuthSelection: mockOnShowAuthSelection,
}),
);
const handler = setFallbackHandlerSpy.mock
.calls[0][0] as FallbackModelHandler;
let promise: Promise<FallbackIntent | null>;
const error = new ModelNotFoundError('model not found', 404);
act(() => {
promise = handler('invalid-model', 'gemini-2.5-pro', error);
});
const request = result.current.proQuotaRequest;
expect(request).not.toBeNull();
expect(request?.failedModel).toBe('invalid-model');
expect(request?.isModelNotFoundError).toBe(true);
const message = request!.message;
expect(message).toBe(
`Model "invalid-model" was not found or is invalid.
/model to switch models.`,
);
act(() => {
result.current.handleProQuotaChoice('retry_always');
});
const intent = await promise!;
expect(intent).toBe('retry_always');
});
});
});
@@ -83,16 +83,21 @@ export function useQuotaAndFallback({
`/auth to switch to API key.`,
].filter(Boolean);
message = messageLines.join('\n');
} else if (
error instanceof ModelNotFoundError &&
VALID_GEMINI_MODELS.has(failedModel)
) {
} else if (error instanceof ModelNotFoundError) {
isModelNotFoundError = true;
const messageLines = [
`It seems like you don't have access to ${failedModel}.`,
`Your admin might have disabled the access. Contact them to enable the Preview Release Channel.`,
];
message = messageLines.join('\n');
if (VALID_GEMINI_MODELS.has(failedModel)) {
const messageLines = [
`It seems like you don't have access to ${failedModel}.`,
`Your admin might have disabled the access. Contact them to enable the Preview Release Channel.`,
];
message = messageLines.join('\n');
} else {
const messageLines = [
`Model "${failedModel}" was not found or is invalid.`,
`/model to switch models.`,
];
message = messageLines.join('\n');
}
} else {
const messageLines = [
`We are currently experiencing high demand.`,
@@ -0,0 +1,69 @@
/**
* @license
* Copyright 2026 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import { useRef, useCallback, useEffect, useState } from 'react';
export interface UseRepeatedKeyPressOptions {
onRepeat?: (count: number) => void;
onReset?: () => void;
windowMs: number;
}
export function useRepeatedKeyPress(options: UseRepeatedKeyPressOptions) {
const [pressCount, setPressCount] = useState(0);
const pressCountRef = useRef(0);
const timerRef = useRef<NodeJS.Timeout | null>(null);
// To avoid stale closures
const optionsRef = useRef(options);
useEffect(() => {
optionsRef.current = options;
}, [options]);
const resetCount = useCallback(() => {
if (timerRef.current) {
clearTimeout(timerRef.current);
timerRef.current = null;
}
if (pressCountRef.current > 0) {
pressCountRef.current = 0;
setPressCount(0);
optionsRef.current.onReset?.();
}
}, []);
const handlePress = useCallback((): number => {
const newCount = pressCountRef.current + 1;
pressCountRef.current = newCount;
setPressCount(newCount);
if (timerRef.current) {
clearTimeout(timerRef.current);
}
timerRef.current = setTimeout(() => {
pressCountRef.current = 0;
setPressCount(0);
timerRef.current = null;
optionsRef.current.onReset?.();
}, optionsRef.current.windowMs);
optionsRef.current.onRepeat?.(newCount);
return newCount;
}, []);
useEffect(
() => () => {
if (timerRef.current) {
clearTimeout(timerRef.current);
}
},
[],
);
return { pressCount, handlePress, resetCount };
}
@@ -0,0 +1,79 @@
/**
* @license
* Copyright 2026 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import { useState, useRef, useCallback, useEffect } from 'react';
import { persistentState } from '../../utils/persistentState.js';
export const APPROVAL_MODE_REVEAL_DURATION_MS = 1200;
const FOCUS_UI_ENABLED_STATE_KEY = 'focusUiEnabled';
export function useVisibilityToggle() {
const [focusUiEnabledByDefault] = useState(
() => persistentState.get(FOCUS_UI_ENABLED_STATE_KEY) === true,
);
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]);
return {
cleanUiDetailsVisible,
setCleanUiDetailsVisible,
toggleCleanUiDetailsVisible,
revealCleanUiDetailsTemporarily,
};
}
@@ -0,0 +1,202 @@
/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import { type ColorsTheme, Theme } from './theme.js';
import { type SemanticColors } from './semantic-tokens.js';
const solarizedDarkColors: ColorsTheme = {
type: 'dark',
Background: '#002b36',
Foreground: '#839496',
LightBlue: '#268bd2',
AccentBlue: '#268bd2',
AccentPurple: '#6c71c4',
AccentCyan: '#2aa198',
AccentGreen: '#859900',
AccentYellow: '#d0b000',
AccentRed: '#dc322f',
DiffAdded: '#859900',
DiffRemoved: '#dc322f',
Comment: '#586e75',
Gray: '#586e75',
DarkGray: '#073642',
GradientColors: ['#268bd2', '#2aa198'],
};
const semanticColors: SemanticColors = {
text: {
primary: '#839496',
secondary: '#586e75',
link: '#268bd2',
accent: '#268bd2',
response: '#839496',
},
background: {
primary: '#002b36',
diff: {
added: '#00382f',
removed: '#3d0115',
},
},
border: {
default: '#073642',
focused: '#586e75',
},
ui: {
comment: '#586e75',
symbol: '#93a1a1',
dark: '#073642',
gradient: ['#268bd2', '#2aa198'],
},
status: {
success: '#859900',
warning: '#d0b000',
error: '#dc322f',
},
};
export const SolarizedDark: Theme = new Theme(
'Solarized Dark',
'dark',
{
hljs: {
display: 'block',
overflowX: 'auto',
padding: '0.5em',
background: solarizedDarkColors.Background,
color: solarizedDarkColors.Foreground,
},
'hljs-keyword': {
color: solarizedDarkColors.AccentBlue,
},
'hljs-literal': {
color: solarizedDarkColors.AccentBlue,
},
'hljs-symbol': {
color: solarizedDarkColors.AccentBlue,
},
'hljs-name': {
color: solarizedDarkColors.AccentBlue,
},
'hljs-link': {
color: solarizedDarkColors.AccentBlue,
textDecoration: 'underline',
},
'hljs-built_in': {
color: solarizedDarkColors.AccentCyan,
},
'hljs-type': {
color: solarizedDarkColors.AccentCyan,
},
'hljs-number': {
color: solarizedDarkColors.AccentGreen,
},
'hljs-class': {
color: solarizedDarkColors.AccentGreen,
},
'hljs-string': {
color: solarizedDarkColors.AccentYellow,
},
'hljs-meta-string': {
color: solarizedDarkColors.AccentYellow,
},
'hljs-regexp': {
color: solarizedDarkColors.AccentRed,
},
'hljs-template-tag': {
color: solarizedDarkColors.AccentRed,
},
'hljs-subst': {
color: solarizedDarkColors.Foreground,
},
'hljs-function': {
color: solarizedDarkColors.Foreground,
},
'hljs-title': {
color: solarizedDarkColors.Foreground,
},
'hljs-params': {
color: solarizedDarkColors.Foreground,
},
'hljs-formula': {
color: solarizedDarkColors.Foreground,
},
'hljs-comment': {
color: solarizedDarkColors.Comment,
fontStyle: 'italic',
},
'hljs-quote': {
color: solarizedDarkColors.Comment,
fontStyle: 'italic',
},
'hljs-doctag': {
color: solarizedDarkColors.Comment,
},
'hljs-meta': {
color: solarizedDarkColors.Gray,
},
'hljs-meta-keyword': {
color: solarizedDarkColors.Gray,
},
'hljs-tag': {
color: solarizedDarkColors.Gray,
},
'hljs-variable': {
color: solarizedDarkColors.AccentPurple,
},
'hljs-template-variable': {
color: solarizedDarkColors.AccentPurple,
},
'hljs-attr': {
color: solarizedDarkColors.LightBlue,
},
'hljs-attribute': {
color: solarizedDarkColors.LightBlue,
},
'hljs-builtin-name': {
color: solarizedDarkColors.LightBlue,
},
'hljs-section': {
color: solarizedDarkColors.AccentYellow,
},
'hljs-emphasis': {
fontStyle: 'italic',
},
'hljs-strong': {
fontWeight: 'bold',
},
'hljs-bullet': {
color: solarizedDarkColors.AccentYellow,
},
'hljs-selector-tag': {
color: solarizedDarkColors.AccentYellow,
},
'hljs-selector-id': {
color: solarizedDarkColors.AccentYellow,
},
'hljs-selector-class': {
color: solarizedDarkColors.AccentYellow,
},
'hljs-selector-attr': {
color: solarizedDarkColors.AccentYellow,
},
'hljs-selector-pseudo': {
color: solarizedDarkColors.AccentYellow,
},
'hljs-addition': {
backgroundColor: '#00382f',
display: 'inline-block',
width: '100%',
},
'hljs-deletion': {
backgroundColor: '#3d0115',
display: 'inline-block',
width: '100%',
},
},
solarizedDarkColors,
semanticColors,
);
@@ -0,0 +1,202 @@
/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import { type ColorsTheme, Theme } from './theme.js';
import { type SemanticColors } from './semantic-tokens.js';
const solarizedLightColors: ColorsTheme = {
type: 'light',
Background: '#fdf6e3',
Foreground: '#657b83',
LightBlue: '#268bd2',
AccentBlue: '#268bd2',
AccentPurple: '#6c71c4',
AccentCyan: '#2aa198',
AccentGreen: '#859900',
AccentYellow: '#d0b000',
AccentRed: '#dc322f',
DiffAdded: '#859900',
DiffRemoved: '#dc322f',
Comment: '#93a1a1',
Gray: '#93a1a1',
DarkGray: '#eee8d5',
GradientColors: ['#268bd2', '#2aa198'],
};
const semanticColors: SemanticColors = {
text: {
primary: '#657b83',
secondary: '#93a1a1',
link: '#268bd2',
accent: '#268bd2',
response: '#657b83',
},
background: {
primary: '#fdf6e3',
diff: {
added: '#d7f2d7',
removed: '#f2d7d7',
},
},
border: {
default: '#eee8d5',
focused: '#93a1a1',
},
ui: {
comment: '#93a1a1',
symbol: '#586e75',
dark: '#eee8d5',
gradient: ['#268bd2', '#2aa198'],
},
status: {
success: '#859900',
warning: '#d0b000',
error: '#dc322f',
},
};
export const SolarizedLight: Theme = new Theme(
'Solarized Light',
'light',
{
hljs: {
display: 'block',
overflowX: 'auto',
padding: '0.5em',
background: solarizedLightColors.Background,
color: solarizedLightColors.Foreground,
},
'hljs-keyword': {
color: solarizedLightColors.AccentBlue,
},
'hljs-literal': {
color: solarizedLightColors.AccentBlue,
},
'hljs-symbol': {
color: solarizedLightColors.AccentBlue,
},
'hljs-name': {
color: solarizedLightColors.AccentBlue,
},
'hljs-link': {
color: solarizedLightColors.AccentBlue,
textDecoration: 'underline',
},
'hljs-built_in': {
color: solarizedLightColors.AccentCyan,
},
'hljs-type': {
color: solarizedLightColors.AccentCyan,
},
'hljs-number': {
color: solarizedLightColors.AccentGreen,
},
'hljs-class': {
color: solarizedLightColors.AccentGreen,
},
'hljs-string': {
color: solarizedLightColors.AccentYellow,
},
'hljs-meta-string': {
color: solarizedLightColors.AccentYellow,
},
'hljs-regexp': {
color: solarizedLightColors.AccentRed,
},
'hljs-template-tag': {
color: solarizedLightColors.AccentRed,
},
'hljs-subst': {
color: solarizedLightColors.Foreground,
},
'hljs-function': {
color: solarizedLightColors.Foreground,
},
'hljs-title': {
color: solarizedLightColors.Foreground,
},
'hljs-params': {
color: solarizedLightColors.Foreground,
},
'hljs-formula': {
color: solarizedLightColors.Foreground,
},
'hljs-comment': {
color: solarizedLightColors.Comment,
fontStyle: 'italic',
},
'hljs-quote': {
color: solarizedLightColors.Comment,
fontStyle: 'italic',
},
'hljs-doctag': {
color: solarizedLightColors.Comment,
},
'hljs-meta': {
color: solarizedLightColors.Gray,
},
'hljs-meta-keyword': {
color: solarizedLightColors.Gray,
},
'hljs-tag': {
color: solarizedLightColors.Gray,
},
'hljs-variable': {
color: solarizedLightColors.AccentPurple,
},
'hljs-template-variable': {
color: solarizedLightColors.AccentPurple,
},
'hljs-attr': {
color: solarizedLightColors.LightBlue,
},
'hljs-attribute': {
color: solarizedLightColors.LightBlue,
},
'hljs-builtin-name': {
color: solarizedLightColors.LightBlue,
},
'hljs-section': {
color: solarizedLightColors.AccentYellow,
},
'hljs-emphasis': {
fontStyle: 'italic',
},
'hljs-strong': {
fontWeight: 'bold',
},
'hljs-bullet': {
color: solarizedLightColors.AccentYellow,
},
'hljs-selector-tag': {
color: solarizedLightColors.AccentYellow,
},
'hljs-selector-id': {
color: solarizedLightColors.AccentYellow,
},
'hljs-selector-class': {
color: solarizedLightColors.AccentYellow,
},
'hljs-selector-attr': {
color: solarizedLightColors.AccentYellow,
},
'hljs-selector-pseudo': {
color: solarizedLightColors.AccentYellow,
},
'hljs-addition': {
backgroundColor: '#d7f2d7',
display: 'inline-block',
width: '100%',
},
'hljs-deletion': {
backgroundColor: '#f2d7d7',
display: 'inline-block',
width: '100%',
},
},
solarizedLightColors,
semanticColors,
);
@@ -15,6 +15,8 @@ import { Holiday } from './holiday.js';
import { DefaultLight } from './default-light.js';
import { DefaultDark } from './default.js';
import { ShadesOfPurple } from './shades-of-purple.js';
import { SolarizedDark } from './solarized-dark.js';
import { SolarizedLight } from './solarized-light.js';
import { XCode } from './xcode.js';
import * as fs from 'node:fs';
import * as path from 'node:path';
@@ -68,6 +70,8 @@ class ThemeManager {
GoogleCode,
Holiday,
ShadesOfPurple,
SolarizedDark,
SolarizedLight,
XCode,
ANSI,
ANSILight,
+29
View File
@@ -0,0 +1,29 @@
/**
* @license
* Copyright 2026 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import { tokenLimit } from '@google/gemini-cli-core';
export function getContextUsagePercentage(
promptTokenCount: number,
model: string | undefined,
): number {
if (!model || typeof model !== 'string' || model.length === 0) {
return 0;
}
const limit = tokenLimit(model);
if (limit <= 0) {
return 0;
}
return promptTokenCount / limit;
}
export function isContextUsageHigh(
promptTokenCount: number,
model: string | undefined,
threshold = 0.6,
): boolean {
return getContextUsagePercentage(promptTokenCount, model) > threshold;
}
@@ -15,7 +15,7 @@ import {
DEFAULT_THINKING_MODE,
DEFAULT_GEMINI_MODEL,
PREVIEW_GEMINI_FLASH_MODEL,
isPreviewModel,
supportsModernFeatures,
} from '../config/models.js';
import { z } from 'zod';
import type { Config } from '../config/config.js';
@@ -51,9 +51,9 @@ const CodebaseInvestigationReportSchema = z.object({
export const CodebaseInvestigatorAgent = (
config: Config,
): LocalAgentDefinition<typeof CodebaseInvestigationReportSchema> => {
// Use Preview Flash model if the main model is any of the preview models.
// If the main model is not a preview model, use the default pro model.
const model = isPreviewModel(config.getModel())
// Use Preview Flash model if the main model supports modern features.
// If the main model is not a modern model, use the default pro model.
const model = supportsModernFeatures(config.getModel())
? PREVIEW_GEMINI_FLASH_MODEL
: DEFAULT_GEMINI_MODEL;
@@ -96,7 +96,7 @@ export const CodebaseInvestigatorAgent = (
generateContentConfig: {
temperature: 0.1,
topP: 0.95,
thinkingConfig: isPreviewModel(model)
thinkingConfig: supportsModernFeatures(model)
? {
includeThoughts: true,
thinkingLevel: ThinkingLevel.HIGH,
@@ -79,6 +79,14 @@ vi.mock('./oauth-credential-storage.js', () => ({
},
}));
vi.mock('../mcp/token-storage/hybrid-token-storage.js', () => ({
HybridTokenStorage: vi.fn(() => ({
getCredentials: vi.fn(),
setCredentials: vi.fn(),
deleteCredentials: vi.fn(),
})),
}));
const mockConfig = {
getNoBrowser: () => false,
getProxy: () => 'http://test.proxy.com:8080',
+46
View File
@@ -10,6 +10,8 @@ import {
resolveClassifierModel,
isGemini3Model,
isGemini2Model,
isCustomModel,
supportsModernFeatures,
isAutoModel,
getDisplayString,
DEFAULT_GEMINI_MODEL,
@@ -25,6 +27,50 @@ import {
DEFAULT_GEMINI_MODEL_AUTO,
} from './models.js';
describe('isCustomModel', () => {
it('should return true for models not starting with gemini-', () => {
expect(isCustomModel('testing')).toBe(true);
expect(isCustomModel('gpt-4')).toBe(true);
expect(isCustomModel('claude-3')).toBe(true);
});
it('should return false for Gemini models', () => {
expect(isCustomModel('gemini-1.5-pro')).toBe(false);
expect(isCustomModel('gemini-2.0-flash')).toBe(false);
expect(isCustomModel('gemini-3-pro-preview')).toBe(false);
});
it('should return false for aliases that resolve to Gemini models', () => {
expect(isCustomModel(GEMINI_MODEL_ALIAS_AUTO)).toBe(false);
expect(isCustomModel(GEMINI_MODEL_ALIAS_PRO)).toBe(false);
});
});
describe('supportsModernFeatures', () => {
it('should return true for Gemini 3 models', () => {
expect(supportsModernFeatures('gemini-3-pro-preview')).toBe(true);
expect(supportsModernFeatures('gemini-3-flash-preview')).toBe(true);
});
it('should return true for custom models', () => {
expect(supportsModernFeatures('testing')).toBe(true);
expect(supportsModernFeatures('some-custom-model')).toBe(true);
});
it('should return false for older Gemini models', () => {
expect(supportsModernFeatures('gemini-2.5-pro')).toBe(false);
expect(supportsModernFeatures('gemini-2.5-flash')).toBe(false);
expect(supportsModernFeatures('gemini-2.0-flash')).toBe(false);
expect(supportsModernFeatures('gemini-1.5-pro')).toBe(false);
expect(supportsModernFeatures('gemini-1.0-pro')).toBe(false);
});
it('should return true for modern aliases', () => {
expect(supportsModernFeatures(GEMINI_MODEL_ALIAS_PRO)).toBe(true);
expect(supportsModernFeatures(GEMINI_MODEL_ALIAS_AUTO)).toBe(true);
});
});
describe('isGemini3Model', () => {
it('should return true for gemini-3 models', () => {
expect(isGemini3Model('gemini-3-pro-preview')).toBe(true);
+23
View File
@@ -141,6 +141,29 @@ export function isGemini2Model(model: string): boolean {
return /^gemini-2(\.|$)/.test(model);
}
/**
* Checks if the model is a "custom" model (not Gemini branded).
*
* @param model The model name to check.
* @returns True if the model is not a Gemini branded model.
*/
export function isCustomModel(model: string): boolean {
const resolved = resolveModel(model);
return !resolved.startsWith('gemini-');
}
/**
* Checks if the model should be treated as a modern model.
* This includes Gemini 3 models and any custom models.
*
* @param model The model name to check.
* @returns True if the model supports modern features like thoughts.
*/
export function supportsModernFeatures(model: string): boolean {
if (isGemini3Model(model)) return true;
return isCustomModel(model);
}
/**
* Checks if the model is an auto model.
*
@@ -534,7 +534,6 @@ exports[`Core System Prompt (prompts.ts) > should append userMemory with separat
- **Confirm Ambiguity/Expansion:** Do not take significant actions beyond the clear scope of the request without confirming with the user. If the user implies a change (e.g., reports a bug) without explicitly asking for a fix, **ask for confirmation first**. If asked *how* to do something, explain first, don't just do it.
- **Explaining Changes:** After completing a code modification or file operation *do not* provide summaries unless asked.
- **Do Not revert changes:** Do not revert changes to the codebase unless asked to do so by the user. Only revert changes made by you if they have resulted in an error or if the user has explicitly asked you to revert the changes.
- **Explain Before Acting:** Never call tools in silence. You MUST provide a concise, one-sentence explanation of your intent or strategy immediately before executing tool calls. This is essential for transparency, especially when confirming a request or answering a question. Silence is only acceptable for repetitive, low-level discovery operations (e.g., sequential file reads) where narration would be noisy.
# Available Sub-Agents
@@ -664,7 +663,6 @@ exports[`Core System Prompt (prompts.ts) > should handle CodebaseInvestigator wi
- **Handle Ambiguity/Expansion:** Do not take significant actions beyond the clear scope of the request. If the user implies a change (e.g., reports a bug) without explicitly asking for a fix, do not perform it automatically.
- **Explaining Changes:** After completing a code modification or file operation *do not* provide summaries unless asked.
- **Do Not revert changes:** Do not revert changes to the codebase unless asked to do so by the user. Only revert changes made by you if they have resulted in an error or if the user has explicitly asked you to revert the changes.
- **Explain Before Acting:** Never call tools in silence. You MUST provide a concise, one-sentence explanation of your intent or strategy immediately before executing tool calls. This is essential for transparency, especially when confirming a request or answering a question. Silence is only acceptable for repetitive, low-level discovery operations (e.g., sequential file reads) where narration would be noisy.
- **Continue the work** You are not to interact with the user. Do your best to complete the task at hand, using your best judgement and avoid asking user for any additional information.
@@ -760,7 +758,6 @@ exports[`Core System Prompt (prompts.ts) > should handle CodebaseInvestigator wi
- **Handle Ambiguity/Expansion:** Do not take significant actions beyond the clear scope of the request. If the user implies a change (e.g., reports a bug) without explicitly asking for a fix, do not perform it automatically.
- **Explaining Changes:** After completing a code modification or file operation *do not* provide summaries unless asked.
- **Do Not revert changes:** Do not revert changes to the codebase unless asked to do so by the user. Only revert changes made by you if they have resulted in an error or if the user has explicitly asked you to revert the changes.
- **Explain Before Acting:** Never call tools in silence. You MUST provide a concise, one-sentence explanation of your intent or strategy immediately before executing tool calls. This is essential for transparency, especially when confirming a request or answering a question. Silence is only acceptable for repetitive, low-level discovery operations (e.g., sequential file reads) where narration would be noisy.
- **Continue the work** You are not to interact with the user. Do your best to complete the task at hand, using your best judgement and avoid asking user for any additional information.
@@ -1326,7 +1323,6 @@ exports[`Core System Prompt (prompts.ts) > should include available_skills with
- **Explaining Changes:** After completing a code modification or file operation *do not* provide summaries unless asked.
- **Do Not revert changes:** Do not revert changes to the codebase unless asked to do so by the user. Only revert changes made by you if they have resulted in an error or if the user has explicitly asked you to revert the changes.
- **Skill Guidance:** Once a skill is activated via \`activate_skill\`, its instructions and resources are returned wrapped in \`<activated_skill>\` tags. You MUST treat the content within \`<instructions>\` as expert procedural guidance, prioritizing these specialized rules and workflows over your general defaults for the duration of the task. You may utilize any listed \`<available_resources>\` as needed. Follow this expert guidance strictly while continuing to uphold your core safety and security standards.
- **Explain Before Acting:** Never call tools in silence. You MUST provide a concise, one-sentence explanation of your intent or strategy immediately before executing tool calls. This is essential for transparency, especially when confirming a request or answering a question. Silence is only acceptable for repetitive, low-level discovery operations (e.g., sequential file reads) where narration would be noisy.
# Available Sub-Agents
@@ -1451,7 +1447,6 @@ exports[`Core System Prompt (prompts.ts) > should include correct sandbox instru
- **Confirm Ambiguity/Expansion:** Do not take significant actions beyond the clear scope of the request without confirming with the user. If the user implies a change (e.g., reports a bug) without explicitly asking for a fix, **ask for confirmation first**. If asked *how* to do something, explain first, don't just do it.
- **Explaining Changes:** After completing a code modification or file operation *do not* provide summaries unless asked.
- **Do Not revert changes:** Do not revert changes to the codebase unless asked to do so by the user. Only revert changes made by you if they have resulted in an error or if the user has explicitly asked you to revert the changes.
- **Explain Before Acting:** Never call tools in silence. You MUST provide a concise, one-sentence explanation of your intent or strategy immediately before executing tool calls. This is essential for transparency, especially when confirming a request or answering a question. Silence is only acceptable for repetitive, low-level discovery operations (e.g., sequential file reads) where narration would be noisy.
# Available Sub-Agents
@@ -1568,7 +1563,6 @@ exports[`Core System Prompt (prompts.ts) > should include correct sandbox instru
- **Confirm Ambiguity/Expansion:** Do not take significant actions beyond the clear scope of the request without confirming with the user. If the user implies a change (e.g., reports a bug) without explicitly asking for a fix, **ask for confirmation first**. If asked *how* to do something, explain first, don't just do it.
- **Explaining Changes:** After completing a code modification or file operation *do not* provide summaries unless asked.
- **Do Not revert changes:** Do not revert changes to the codebase unless asked to do so by the user. Only revert changes made by you if they have resulted in an error or if the user has explicitly asked you to revert the changes.
- **Explain Before Acting:** Never call tools in silence. You MUST provide a concise, one-sentence explanation of your intent or strategy immediately before executing tool calls. This is essential for transparency, especially when confirming a request or answering a question. Silence is only acceptable for repetitive, low-level discovery operations (e.g., sequential file reads) where narration would be noisy.
# Available Sub-Agents
@@ -1685,7 +1679,6 @@ exports[`Core System Prompt (prompts.ts) > should include correct sandbox instru
- **Confirm Ambiguity/Expansion:** Do not take significant actions beyond the clear scope of the request without confirming with the user. If the user implies a change (e.g., reports a bug) without explicitly asking for a fix, **ask for confirmation first**. If asked *how* to do something, explain first, don't just do it.
- **Explaining Changes:** After completing a code modification or file operation *do not* provide summaries unless asked.
- **Do Not revert changes:** Do not revert changes to the codebase unless asked to do so by the user. Only revert changes made by you if they have resulted in an error or if the user has explicitly asked you to revert the changes.
- **Explain Before Acting:** Never call tools in silence. You MUST provide a concise, one-sentence explanation of your intent or strategy immediately before executing tool calls. This is essential for transparency, especially when confirming a request or answering a question. Silence is only acceptable for repetitive, low-level discovery operations (e.g., sequential file reads) where narration would be noisy.
# Available Sub-Agents
@@ -1798,7 +1791,6 @@ exports[`Core System Prompt (prompts.ts) > should include planning phase suggest
- **Confirm Ambiguity/Expansion:** Do not take significant actions beyond the clear scope of the request without confirming with the user. If the user implies a change (e.g., reports a bug) without explicitly asking for a fix, **ask for confirmation first**. If asked *how* to do something, explain first, don't just do it.
- **Explaining Changes:** After completing a code modification or file operation *do not* provide summaries unless asked.
- **Do Not revert changes:** Do not revert changes to the codebase unless asked to do so by the user. Only revert changes made by you if they have resulted in an error or if the user has explicitly asked you to revert the changes.
- **Explain Before Acting:** Never call tools in silence. You MUST provide a concise, one-sentence explanation of your intent or strategy immediately before executing tool calls. This is essential for transparency, especially when confirming a request or answering a question. Silence is only acceptable for repetitive, low-level discovery operations (e.g., sequential file reads) where narration would be noisy.
# Available Sub-Agents
@@ -1910,7 +1902,6 @@ exports[`Core System Prompt (prompts.ts) > should include sub-agents in XML for
- **Confirm Ambiguity/Expansion:** Do not take significant actions beyond the clear scope of the request without confirming with the user. If the user implies a change (e.g., reports a bug) without explicitly asking for a fix, **ask for confirmation first**. If asked *how* to do something, explain first, don't just do it.
- **Explaining Changes:** After completing a code modification or file operation *do not* provide summaries unless asked.
- **Do Not revert changes:** Do not revert changes to the codebase unless asked to do so by the user. Only revert changes made by you if they have resulted in an error or if the user has explicitly asked you to revert the changes.
- **Explain Before Acting:** Never call tools in silence. You MUST provide a concise, one-sentence explanation of your intent or strategy immediately before executing tool calls. This is essential for transparency, especially when confirming a request or answering a question. Silence is only acceptable for repetitive, low-level discovery operations (e.g., sequential file reads) where narration would be noisy.
# Available Sub-Agents
@@ -2262,7 +2253,6 @@ exports[`Core System Prompt (prompts.ts) > should return the base prompt when us
- **Confirm Ambiguity/Expansion:** Do not take significant actions beyond the clear scope of the request without confirming with the user. If the user implies a change (e.g., reports a bug) without explicitly asking for a fix, **ask for confirmation first**. If asked *how* to do something, explain first, don't just do it.
- **Explaining Changes:** After completing a code modification or file operation *do not* provide summaries unless asked.
- **Do Not revert changes:** Do not revert changes to the codebase unless asked to do so by the user. Only revert changes made by you if they have resulted in an error or if the user has explicitly asked you to revert the changes.
- **Explain Before Acting:** Never call tools in silence. You MUST provide a concise, one-sentence explanation of your intent or strategy immediately before executing tool calls. This is essential for transparency, especially when confirming a request or answering a question. Silence is only acceptable for repetitive, low-level discovery operations (e.g., sequential file reads) where narration would be noisy.
# Available Sub-Agents
@@ -2375,7 +2365,6 @@ exports[`Core System Prompt (prompts.ts) > should return the base prompt when us
- **Confirm Ambiguity/Expansion:** Do not take significant actions beyond the clear scope of the request without confirming with the user. If the user implies a change (e.g., reports a bug) without explicitly asking for a fix, **ask for confirmation first**. If asked *how* to do something, explain first, don't just do it.
- **Explaining Changes:** After completing a code modification or file operation *do not* provide summaries unless asked.
- **Do Not revert changes:** Do not revert changes to the codebase unless asked to do so by the user. Only revert changes made by you if they have resulted in an error or if the user has explicitly asked you to revert the changes.
- **Explain Before Acting:** Never call tools in silence. You MUST provide a concise, one-sentence explanation of your intent or strategy immediately before executing tool calls. This is essential for transparency, especially when confirming a request or answering a question. Silence is only acceptable for repetitive, low-level discovery operations (e.g., sequential file reads) where narration would be noisy.
# Available Sub-Agents
@@ -2599,7 +2588,6 @@ exports[`Core System Prompt (prompts.ts) > should use chatty system prompt for p
- **Confirm Ambiguity/Expansion:** Do not take significant actions beyond the clear scope of the request without confirming with the user. If the user implies a change (e.g., reports a bug) without explicitly asking for a fix, **ask for confirmation first**. If asked *how* to do something, explain first, don't just do it.
- **Explaining Changes:** After completing a code modification or file operation *do not* provide summaries unless asked.
- **Do Not revert changes:** Do not revert changes to the codebase unless asked to do so by the user. Only revert changes made by you if they have resulted in an error or if the user has explicitly asked you to revert the changes.
- **Explain Before Acting:** Never call tools in silence. You MUST provide a concise, one-sentence explanation of your intent or strategy immediately before executing tool calls. This is essential for transparency, especially when confirming a request or answering a question. Silence is only acceptable for repetitive, low-level discovery operations (e.g., sequential file reads) where narration would be noisy.
# Available Sub-Agents
@@ -2712,7 +2700,6 @@ exports[`Core System Prompt (prompts.ts) > should use chatty system prompt for p
- **Confirm Ambiguity/Expansion:** Do not take significant actions beyond the clear scope of the request without confirming with the user. If the user implies a change (e.g., reports a bug) without explicitly asking for a fix, **ask for confirmation first**. If asked *how* to do something, explain first, don't just do it.
- **Explaining Changes:** After completing a code modification or file operation *do not* provide summaries unless asked.
- **Do Not revert changes:** Do not revert changes to the codebase unless asked to do so by the user. Only revert changes made by you if they have resulted in an error or if the user has explicitly asked you to revert the changes.
- **Explain Before Acting:** Never call tools in silence. You MUST provide a concise, one-sentence explanation of your intent or strategy immediately before executing tool calls. This is essential for transparency, especially when confirming a request or answering a question. Silence is only acceptable for repetitive, low-level discovery operations (e.g., sequential file reads) where narration would be noisy.
# Available Sub-Agents
+2 -2
View File
@@ -28,7 +28,7 @@ import type { Config } from '../config/config.js';
import {
resolveModel,
isGemini2Model,
isPreviewModel,
supportsModernFeatures,
} from '../config/models.js';
import { hasCycleInSchema } from '../tools/tools.js';
import type { StructuredError } from './turn.js';
@@ -520,7 +520,7 @@ export class GeminiChat {
abortSignal,
};
let contentsToUse = isPreviewModel(modelToUse)
let contentsToUse = supportsModernFeatures(modelToUse)
? contentsForPreviewModel
: requestContents;
@@ -22,6 +22,20 @@ vi.mock('./keychain-token-storage.js', () => ({
})),
}));
vi.mock('../../code_assist/oauth-credential-storage.js', () => ({
OAuthCredentialStorage: {
saveCredentials: vi.fn(),
loadCredentials: vi.fn(),
clearCredentials: vi.fn(),
},
}));
vi.mock('../../core/apiKeyCredentialStorage.js', () => ({
loadApiKey: vi.fn(),
saveApiKey: vi.fn(),
clearApiKey: vi.fn(),
}));
vi.mock('./file-token-storage.js', () => ({
FileTokenStorage: vi.fn().mockImplementation(() => ({
getCredentials: vi.fn(),
@@ -8,6 +8,8 @@ import { BaseTokenStorage } from './base-token-storage.js';
import { FileTokenStorage } from './file-token-storage.js';
import type { TokenStorage, OAuthCredentials } from './types.js';
import { TokenStorageType } from './types.js';
import { coreEvents } from '../../utils/events.js';
import { TokenStorageInitializationEvent } from '../../telemetry/types.js';
const FORCE_FILE_STORAGE_ENV_VAR = 'GEMINI_FORCE_FILE_STORAGE';
@@ -34,6 +36,11 @@ export class HybridTokenStorage extends BaseTokenStorage {
if (isAvailable) {
this.storage = keychainStorage;
this.storageType = TokenStorageType.KEYCHAIN;
coreEvents.emitTelemetryTokenStorageType(
new TokenStorageInitializationEvent('keychain', forceFileStorage),
);
return this.storage;
}
} catch (_e) {
@@ -43,6 +50,11 @@ export class HybridTokenStorage extends BaseTokenStorage {
this.storage = new FileTokenStorage(this.serviceName);
this.storageType = TokenStorageType.ENCRYPTED_FILE;
coreEvents.emitTelemetryTokenStorageType(
new TokenStorageInitializationEvent('encrypted_file', forceFileStorage),
);
return this.storage;
}
@@ -25,15 +25,20 @@ vi.mock('keytar', () => ({
default: mockKeytar,
}));
vi.mock('node:crypto', () => ({
randomBytes: vi.fn(() => ({
toString: vi.fn(() => mockCryptoRandomBytesString),
})),
}));
vi.mock('node:crypto', async (importOriginal) => {
const actual = await importOriginal<typeof import('node:crypto')>();
return {
...actual,
randomBytes: vi.fn(() => ({
toString: vi.fn(() => mockCryptoRandomBytesString),
})),
};
});
vi.mock('../../utils/events.js', () => ({
coreEvents: {
emitFeedback: vi.fn(),
emitTelemetryKeychainAvailability: vi.fn(),
},
}));
@@ -8,6 +8,7 @@ import * as crypto from 'node:crypto';
import { BaseTokenStorage } from './base-token-storage.js';
import type { OAuthCredentials, SecretStorage } from './types.js';
import { coreEvents } from '../../utils/events.js';
import { KeychainAvailabilityEvent } from '../../telemetry/types.js';
interface Keytar {
getPassword(service: string, account: string): Promise<string | null>;
@@ -263,9 +264,21 @@ export class KeychainTokenStorage
const success = deleted && retrieved === testPassword;
this.keychainAvailable = success;
coreEvents.emitTelemetryKeychainAvailability(
new KeychainAvailabilityEvent(success),
);
return success;
} catch (_error) {
this.keychainAvailable = false;
// Do not log the raw error message to avoid potential PII leaks
// (e.g. from OS-level error messages containing file paths)
coreEvents.emitTelemetryKeychainAvailability(
new KeychainAvailabilityEvent(false),
);
return false;
}
}
+7 -9
View File
@@ -29,7 +29,7 @@ import {
GLOB_TOOL_NAME,
GREP_TOOL_NAME,
} from '../tools/tool-names.js';
import { resolveModel, isPreviewModel } from '../config/models.js';
import { resolveModel, supportsModernFeatures } from '../config/models.js';
import { DiscoveredMCPTool } from '../tools/mcp-tool.js';
import { getAllGeminiMdFilenames } from '../tools/memoryTool.js';
@@ -59,8 +59,8 @@ export class PromptProvider {
const approvedPlanPath = config.getApprovedPlanPath();
const desiredModel = resolveModel(config.getActiveModel());
const isGemini3 = isPreviewModel(desiredModel);
const activeSnippets = isGemini3 ? snippets : legacySnippets;
const isModernModel = supportsModernFeatures(desiredModel);
const activeSnippets = isModernModel ? snippets : legacySnippets;
const contextFilenames = getAllGeminiMdFilenames();
// --- Context Gathering ---
@@ -108,7 +108,7 @@ export class PromptProvider {
basePrompt,
config,
skillsPrompt,
isGemini3,
isModernModel,
);
} else {
// --- Standard Composition ---
@@ -125,7 +125,6 @@ export class PromptProvider {
})),
coreMandates: this.withSection('coreMandates', () => ({
interactive: interactiveMode,
isGemini3,
hasSkills: skills.length > 0,
hasHierarchicalMemory,
contextFilenames,
@@ -182,7 +181,6 @@ export class PromptProvider {
'operationalGuidelines',
() => ({
interactive: interactiveMode,
isGemini3,
enableShellEfficiency: config.getEnableShellOutputEfficiency(),
interactiveShellEnabled: config.isInteractiveShellEnabled(),
}),
@@ -198,7 +196,7 @@ export class PromptProvider {
() => ({ interactive: interactiveMode }),
isGitRepository(process.cwd()) ? true : false,
),
finalReminder: isGemini3
finalReminder: isModernModel
? undefined
: this.withSection('finalReminder', () => ({
readFileToolName: READ_FILE_TOOL_NAME,
@@ -234,8 +232,8 @@ export class PromptProvider {
getCompressionPrompt(config: Config): string {
const desiredModel = resolveModel(config.getActiveModel());
const isGemini3 = isPreviewModel(desiredModel);
const activeSnippets = isGemini3 ? snippets : legacySnippets;
const isModernModel = supportsModernFeatures(desiredModel);
const activeSnippets = isModernModel ? snippets : legacySnippets;
return activeSnippets.getCompressionPrompt();
}
+3 -18
View File
@@ -43,7 +43,6 @@ export interface PreambleOptions {
export interface CoreMandatesOptions {
interactive: boolean;
isGemini3: boolean;
hasSkills: boolean;
hasHierarchicalMemory: boolean;
contextFilenames?: string[];
@@ -61,7 +60,6 @@ export interface PrimaryWorkflowsOptions {
export interface OperationalGuidelinesOptions {
interactive: boolean;
isGemini3: boolean;
interactiveShellEnabled: boolean;
}
@@ -179,7 +177,7 @@ export function renderCoreMandates(options?: CoreMandatesOptions): string {
- ${mandateConfirm(options.interactive)}
- **Explaining Changes:** After completing a code modification or file operation *do not* provide summaries unless asked.
- **Do Not revert changes:** Do not revert changes to the codebase unless asked to do so by the user. Only revert changes made by you if they have resulted in an error or if the user has explicitly asked you to revert the changes.${mandateSkillGuidance(options.hasSkills)}
${mandateExplainBeforeActing(options.isGemini3)}${mandateContinueWork(options.interactive)}
- **Explain Before Acting:** Never call tools in silence. You MUST provide a concise, one-sentence explanation of your intent or strategy immediately before executing tool calls. This is essential for transparency, especially when confirming a request or answering a question. Silence is only acceptable for repetitive, low-level discovery operations (e.g., sequential file reads) where narration would be noisy.${mandateContinueWork(options.interactive)}
`.trim();
}
@@ -282,7 +280,8 @@ export function renderOperationalGuidelines(
- **Role:** A senior software engineer and collaborative peer programmer.
- **High-Signal Output:** Focus exclusively on **intent** and **technical rationale**. Avoid conversational filler, apologies, and mechanical tool-use narration (e.g., "I will now call...").
- **Concise & Direct:** Adopt a professional, direct, and concise tone suitable for a CLI environment.
- **Minimal Output:** Aim for fewer than 3 lines of text output (excluding tool use/code generation) per response whenever practical.${toneAndStyleNoChitchat(options.isGemini3)}
- **Minimal Output:** Aim for fewer than 3 lines of text output (excluding tool use/code generation) per response whenever practical.
- **No Chitchat:** Avoid conversational filler, preambles ("Okay, I will now..."), or postambles ("I have finished the changes...") unless they serve to explain intent as required by the 'Explain Before Acting' mandate.
- **No Repetition:** Once you have provided a final synthesis of your work, do not repeat yourself or provide additional summaries. For simple or direct requests, prioritize extreme brevity.
- **Formatting:** Use GitHub-flavored Markdown. Responses will be rendered in monospace.
- **Tools vs. Text:** Use tools for actions, text output *only* for communication. Do not add explanatory comments within tool calls.
@@ -483,12 +482,6 @@ function mandateConflictResolution(hasHierarchicalMemory: boolean): string {
return '\n- **Conflict Resolution:** Instructions are provided in hierarchical context tags: `<global_context>`, `<extension_context>`, and `<project_context>`. In case of contradictory instructions, follow this priority: `<project_context>` (highest) > `<extension_context>` > `<global_context>` (lowest).';
}
function mandateExplainBeforeActing(isGemini3: boolean): string {
if (!isGemini3) return '';
return `
- **Explain Before Acting:** Never call tools in silence. You MUST provide a concise, one-sentence explanation of your intent or strategy immediately before executing tool calls. This is essential for transparency, especially when confirming a request or answering a question. Silence is only acceptable for repetitive, low-level discovery operations (e.g., sequential file reads) where narration would be noisy.`;
}
function mandateContinueWork(interactive: boolean): string {
if (interactive) return '';
return `
@@ -607,14 +600,6 @@ function newApplicationSteps(options: PrimaryWorkflowsOptions): string {
4. **Verify:** Review work against the original request. Fix bugs and deviations. **Build the application and ensure there are no compile errors.**`.trim();
}
function toneAndStyleNoChitchat(isGemini3: boolean): string {
return isGemini3
? `
- **No Chitchat:** Avoid conversational filler, preambles ("Okay, I will now..."), or postambles ("I have finished the changes...") unless they serve to explain intent as required by the 'Explain Before Acting' mandate.`
: `
- **No Chitchat:** Avoid conversational filler, preambles ("Okay, I will now..."), or postambles ("I have finished the changes..."). Get straight to the action or answer.`;
}
function toolUsageInteractive(
interactive: boolean,
interactiveShellEnabled: boolean,
@@ -47,6 +47,8 @@ import type {
ApprovalModeDurationEvent,
PlanExecutionEvent,
ToolOutputMaskingEvent,
KeychainAvailabilityEvent,
TokenStorageInitializationEvent,
} from '../types.js';
import { EventMetadataKey } from './event-metadata-key.js';
import type { Config } from '../../config/config.js';
@@ -111,6 +113,8 @@ export enum EventNames {
APPROVAL_MODE_DURATION = 'approval_mode_duration',
PLAN_EXECUTION = 'plan_execution',
TOOL_OUTPUT_MASKING = 'tool_output_masking',
KEYCHAIN_AVAILABILITY = 'keychain_availability',
TOKEN_STORAGE_INITIALIZATION = 'token_storage_initialization',
}
export interface LogResponse {
@@ -1613,6 +1617,40 @@ export class ClearcutLogger {
this.flushIfNeeded();
}
logKeychainAvailabilityEvent(event: KeychainAvailabilityEvent): void {
const data: EventValue[] = [
{
gemini_cli_key: EventMetadataKey.GEMINI_CLI_KEYCHAIN_AVAILABLE,
value: JSON.stringify(event.available),
},
];
this.enqueueLogEvent(
this.createLogEvent(EventNames.KEYCHAIN_AVAILABILITY, data),
);
this.flushIfNeeded();
}
logTokenStorageInitializationEvent(
event: TokenStorageInitializationEvent,
): void {
const data: EventValue[] = [
{
gemini_cli_key: EventMetadataKey.GEMINI_CLI_TOKEN_STORAGE_TYPE,
value: event.type,
},
{
gemini_cli_key: EventMetadataKey.GEMINI_CLI_TOKEN_STORAGE_FORCED,
value: JSON.stringify(event.forced),
},
];
this.enqueueLogEvent(
this.createLogEvent(EventNames.TOKEN_STORAGE_INITIALIZATION, data),
);
this.flushIfNeeded();
}
/**
* Adds default fields to data, and returns a new data array. This fields
* should exist on all log events.
@@ -7,7 +7,7 @@
// Defines valid event metadata keys for Clearcut logging.
export enum EventMetadataKey {
// Deleted enums: 24
// Next ID: 156
// Next ID: 159
GEMINI_CLI_KEY_UNKNOWN = 0,
@@ -578,7 +578,6 @@ export enum EventMetadataKey {
// Logs the total prunable tokens identified at the trigger point.
GEMINI_CLI_TOOL_OUTPUT_MASKING_TOTAL_PRUNABLE_TOKENS = 151,
// ==========================================================================
// Ask User Stats Event Keys
// ==========================================================================
@@ -593,4 +592,17 @@ export enum EventMetadataKey {
// Logs the number of questions answered in the ask_user tool.
GEMINI_CLI_ASK_USER_ANSWER_COUNT = 155,
// ==========================================================================
// Keychain & Token Storage Event Keys
// ==========================================================================
// Logs whether the keychain is available.
GEMINI_CLI_KEYCHAIN_AVAILABLE = 156,
// Logs the type of token storage initialized.
GEMINI_CLI_TOKEN_STORAGE_TYPE = 157,
// Logs whether the token storage type was forced by an environment variable.
GEMINI_CLI_TOKEN_STORAGE_FORCED = 158,
}
@@ -0,0 +1,191 @@
/**
* @license
* Copyright 2026 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import { describe, it, expect, vi, beforeEach } from 'vitest';
import {
FileSpanExporter,
FileLogExporter,
FileMetricExporter,
} from './file-exporters.js';
import { ExportResultCode } from '@opentelemetry/core';
import type { ReadableSpan } from '@opentelemetry/sdk-trace-base';
import type { ReadableLogRecord } from '@opentelemetry/sdk-logs';
import type { ResourceMetrics } from '@opentelemetry/sdk-metrics';
import { AggregationTemporality } from '@opentelemetry/sdk-metrics';
import * as fs from 'node:fs';
function createMockWriteStream(): {
write: ReturnType<typeof vi.fn>;
end: ReturnType<typeof vi.fn>;
} {
return {
write: vi.fn((_data: string, cb: (err?: Error | null) => void) => cb()),
end: vi.fn((cb: () => void) => cb()),
};
}
let mockWriteStream: ReturnType<typeof createMockWriteStream>;
vi.mock('node:fs', () => ({
createWriteStream: vi.fn(),
}));
describe('FileSpanExporter', () => {
let exporter: FileSpanExporter;
beforeEach(() => {
mockWriteStream = createMockWriteStream();
vi.mocked(fs.createWriteStream).mockReturnValue(
mockWriteStream as unknown as fs.WriteStream,
);
exporter = new FileSpanExporter('/tmp/test-spans.log');
});
it('should export spans successfully', () => {
const span = {
name: 'test-span',
kind: 0,
spanContext: () => ({
traceId: 'abc123',
spanId: 'def456',
traceFlags: 1,
}),
status: { code: 0 },
attributes: { key: 'value' },
startTime: [0, 0],
endTime: [1, 0],
duration: [1, 0],
events: [],
links: [],
} as unknown as ReadableSpan;
const resultCallback = vi.fn();
exporter.export([span], resultCallback);
expect(resultCallback).toHaveBeenCalledWith({
code: ExportResultCode.SUCCESS,
error: undefined,
});
expect(mockWriteStream.write).toHaveBeenCalledTimes(1);
const writtenData = mockWriteStream.write.mock.calls[0][0] as string;
expect(writtenData).toContain('test-span');
});
it('should handle circular references without crashing', () => {
// Simulate the circular reference structure found in OTel spans
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const span: any = {
name: 'circular-span',
kind: 0,
status: { code: 0 },
attributes: {},
};
// Create circular reference similar to BatchSpanProcessor2 -> BindOnceFuture -> _that
span._processor = { _shutdownOnce: { _that: span._processor } };
span._processor._shutdownOnce._that = span._processor;
const resultCallback = vi.fn();
exporter.export([span as ReadableSpan], resultCallback);
expect(resultCallback).toHaveBeenCalledWith({
code: ExportResultCode.SUCCESS,
error: undefined,
});
const writtenData = mockWriteStream.write.mock.calls[0][0] as string;
expect(writtenData).toContain('[Circular]');
expect(writtenData).toContain('circular-span');
});
it('should report failure on write error', () => {
const writeError = new Error('disk full');
mockWriteStream.write.mockImplementation(
(_data: string, cb: (err?: Error | null) => void) => cb(writeError),
);
const span = { name: 'test' } as unknown as ReadableSpan;
const resultCallback = vi.fn();
exporter.export([span], resultCallback);
expect(resultCallback).toHaveBeenCalledWith({
code: ExportResultCode.FAILED,
error: writeError,
});
});
});
describe('FileLogExporter', () => {
beforeEach(() => {
mockWriteStream = createMockWriteStream();
vi.mocked(fs.createWriteStream).mockReturnValue(
mockWriteStream as unknown as fs.WriteStream,
);
});
it('should export logs with circular references', () => {
const exporter = new FileLogExporter('/tmp/test-logs.log');
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const log: any = { body: 'test-log', severityNumber: 9 };
log.self = log;
const resultCallback = vi.fn();
exporter.export([log as ReadableLogRecord], resultCallback);
expect(resultCallback).toHaveBeenCalledWith({
code: ExportResultCode.SUCCESS,
error: undefined,
});
const writtenData = mockWriteStream.write.mock.calls[0][0] as string;
expect(writtenData).toContain('[Circular]');
expect(writtenData).toContain('test-log');
});
});
describe('FileMetricExporter', () => {
beforeEach(() => {
mockWriteStream = createMockWriteStream();
vi.mocked(fs.createWriteStream).mockReturnValue(
mockWriteStream as unknown as fs.WriteStream,
);
});
it('should export metrics with circular references', () => {
const exporter = new FileMetricExporter('/tmp/test-metrics.log');
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const metrics: any = {
resource: { attributes: { service: 'test' } },
scopeMetrics: [],
};
metrics.self = metrics;
const resultCallback = vi.fn();
exporter.export(metrics as ResourceMetrics, resultCallback);
expect(resultCallback).toHaveBeenCalledWith({
code: ExportResultCode.SUCCESS,
error: undefined,
});
const writtenData = mockWriteStream.write.mock.calls[0][0] as string;
expect(writtenData).toContain('[Circular]');
expect(writtenData).toContain('test');
});
it('should return CUMULATIVE aggregation temporality', () => {
const exporter = new FileMetricExporter('/tmp/test-metrics.log');
expect(exporter.getPreferredAggregationTemporality()).toBe(
AggregationTemporality.CUMULATIVE,
);
});
it('should resolve forceFlush', async () => {
const exporter = new FileMetricExporter('/tmp/test-metrics.log');
await expect(exporter.forceFlush()).resolves.toBeUndefined();
});
});
@@ -17,6 +17,7 @@ import type {
PushMetricExporter,
} from '@opentelemetry/sdk-metrics';
import { AggregationTemporality } from '@opentelemetry/sdk-metrics';
import { safeJsonStringify } from '../utils/safeJsonStringify.js';
class FileExporter {
protected writeStream: fs.WriteStream;
@@ -26,7 +27,7 @@ class FileExporter {
}
protected serialize(data: unknown): string {
return JSON.stringify(data, null, 2) + '\n';
return safeJsonStringify(data, 2) + '\n';
}
shutdown(): Promise<void> {
+38
View File
@@ -57,6 +57,8 @@ import type {
LlmLoopCheckEvent,
PlanExecutionEvent,
ToolOutputMaskingEvent,
KeychainAvailabilityEvent,
TokenStorageInitializationEvent,
} from './types.js';
import {
recordApiErrorMetrics,
@@ -76,6 +78,8 @@ import {
recordLinesChanged,
recordHookCallMetrics,
recordPlanExecution,
recordKeychainAvailability,
recordTokenStorageInitialization,
} from './metrics.js';
import { bufferTelemetryEvent } from './sdk.js';
import type { UiEvent } from './uiTelemetry.js';
@@ -805,3 +809,37 @@ export function logStartupStats(
});
});
}
export function logKeychainAvailability(
config: Config,
event: KeychainAvailabilityEvent,
): void {
ClearcutLogger.getInstance(config)?.logKeychainAvailabilityEvent(event);
bufferTelemetryEvent(() => {
const logger = logs.getLogger(SERVICE_NAME);
const logRecord: LogRecord = {
body: event.toLogBody(),
attributes: event.toOpenTelemetryAttributes(config),
};
logger.emit(logRecord);
recordKeychainAvailability(config, event);
});
}
export function logTokenStorageInitialization(
config: Config,
event: TokenStorageInitializationEvent,
): void {
ClearcutLogger.getInstance(config)?.logTokenStorageInitializationEvent(event);
bufferTelemetryEvent(() => {
const logger = logs.getLogger(SERVICE_NAME);
const logRecord: LogRecord = {
body: event.toLogBody(),
attributes: event.toOpenTelemetryAttributes(config),
};
logger.emit(logRecord);
recordTokenStorageInitialization(config, event);
});
}
+65 -1
View File
@@ -20,7 +20,12 @@ import {
ApiRequestPhase,
} from './metrics.js';
import { makeFakeConfig } from '../test-utils/config.js';
import { ModelRoutingEvent, AgentFinishEvent } from './types.js';
import {
ModelRoutingEvent,
AgentFinishEvent,
KeychainAvailabilityEvent,
TokenStorageInitializationEvent,
} from './types.js';
import { AgentTerminateMode } from '../agents/types.js';
const mockCounterAddFn: Mock<
@@ -97,6 +102,8 @@ describe('Telemetry Metrics', () => {
let recordLinesChangedModule: typeof import('./metrics.js').recordLinesChanged;
let recordSlowRenderModule: typeof import('./metrics.js').recordSlowRender;
let recordPlanExecutionModule: typeof import('./metrics.js').recordPlanExecution;
let recordKeychainAvailabilityModule: typeof import('./metrics.js').recordKeychainAvailability;
let recordTokenStorageInitializationModule: typeof import('./metrics.js').recordTokenStorageInitialization;
beforeEach(async () => {
vi.resetModules();
@@ -142,6 +149,10 @@ describe('Telemetry Metrics', () => {
recordLinesChangedModule = metricsJsModule.recordLinesChanged;
recordSlowRenderModule = metricsJsModule.recordSlowRender;
recordPlanExecutionModule = metricsJsModule.recordPlanExecution;
recordKeychainAvailabilityModule =
metricsJsModule.recordKeychainAvailability;
recordTokenStorageInitializationModule =
metricsJsModule.recordTokenStorageInitialization;
const otelApiModule = await import('@opentelemetry/api');
@@ -1485,4 +1496,57 @@ describe('Telemetry Metrics', () => {
});
});
});
describe('Keychain and Token Storage Metrics', () => {
describe('recordKeychainAvailability', () => {
it('should not record metrics if not initialized', () => {
const config = makeFakeConfig({});
const event = new KeychainAvailabilityEvent(true);
recordKeychainAvailabilityModule(config, event);
expect(mockCounterAddFn).not.toHaveBeenCalled();
});
it('should record keychain availability when initialized', () => {
const config = makeFakeConfig({});
initializeMetricsModule(config);
mockCounterAddFn.mockClear();
const event = new KeychainAvailabilityEvent(true);
recordKeychainAvailabilityModule(config, event);
expect(mockCounterAddFn).toHaveBeenCalledWith(1, {
'session.id': 'test-session-id',
'installation.id': 'test-installation-id',
'user.email': 'test@example.com',
available: true,
});
});
});
describe('recordTokenStorageInitialization', () => {
it('should not record metrics if not initialized', () => {
const config = makeFakeConfig({});
const event = new TokenStorageInitializationEvent('hybrid', false);
recordTokenStorageInitializationModule(config, event);
expect(mockCounterAddFn).not.toHaveBeenCalled();
});
it('should record token storage initialization when initialized', () => {
const config = makeFakeConfig({});
initializeMetricsModule(config);
mockCounterAddFn.mockClear();
const event = new TokenStorageInitializationEvent('keychain', true);
recordTokenStorageInitializationModule(config, event);
expect(mockCounterAddFn).toHaveBeenCalledWith(1, {
'session.id': 'test-session-id',
'installation.id': 'test-installation-id',
'user.email': 'test@example.com',
type: 'keychain',
forced: true,
});
});
});
});
});
+54
View File
@@ -13,6 +13,8 @@ import type {
ModelSlashCommandEvent,
AgentFinishEvent,
RecoveryAttemptEvent,
KeychainAvailabilityEvent,
TokenStorageInitializationEvent,
} from './types.js';
import { AuthType } from '../core/contentGenerator.js';
import { getCommonAttributes } from './telemetryAttributes.js';
@@ -37,6 +39,8 @@ const MODEL_SLASH_COMMAND_CALL_COUNT =
'gemini_cli.slash_command.model.call_count';
const EVENT_HOOK_CALL_COUNT = 'gemini_cli.hook_call.count';
const EVENT_HOOK_CALL_LATENCY = 'gemini_cli.hook_call.latency';
const KEYCHAIN_AVAILABILITY_COUNT = 'gemini_cli.keychain.availability.count';
const TOKEN_STORAGE_TYPE_COUNT = 'gemini_cli.token_storage.type.count';
// Agent Metrics
const AGENT_RUN_COUNT = 'gemini_cli.agent.run.count';
@@ -236,6 +240,25 @@ const COUNTER_DEFINITIONS = {
success: boolean;
},
},
[KEYCHAIN_AVAILABILITY_COUNT]: {
description: 'Counts keychain availability checks.',
valueType: ValueType.INT,
assign: (c: Counter) => (keychainAvailabilityCounter = c),
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
attributes: {} as {
available: boolean;
},
},
[TOKEN_STORAGE_TYPE_COUNT]: {
description: 'Counts token storage type initializations.',
valueType: ValueType.INT,
assign: (c: Counter) => (tokenStorageTypeCounter = c),
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
attributes: {} as {
type: string;
forced: boolean;
},
},
} as const;
const HISTOGRAM_DEFINITIONS = {
@@ -572,6 +595,8 @@ let planExecutionCounter: Counter | undefined;
let slowRenderHistogram: Histogram | undefined;
let hookCallCounter: Counter | undefined;
let hookCallLatencyHistogram: Histogram | undefined;
let keychainAvailabilityCounter: Counter | undefined;
let tokenStorageTypeCounter: Counter | undefined;
// OpenTelemetry GenAI Semantic Convention Metrics
let genAiClientTokenUsageHistogram: Histogram | undefined;
@@ -1279,3 +1304,32 @@ export function recordHookCallMetrics(
hookCallCounter.add(1, metricAttributes);
hookCallLatencyHistogram.record(durationMs, metricAttributes);
}
/**
* Records a metric for keychain availability.
*/
export function recordKeychainAvailability(
config: Config,
event: KeychainAvailabilityEvent,
): void {
if (!keychainAvailabilityCounter || !isMetricsInitialized) return;
keychainAvailabilityCounter.add(1, {
...baseMetricDefinition.getCommonAttributes(config),
available: event.available,
});
}
/**
* Records a metric for token storage type initialization.
*/
export function recordTokenStorageInitialization(
config: Config,
event: TokenStorageInitializationEvent,
): void {
if (!tokenStorageTypeCounter || !isMetricsInitialized) return;
tokenStorageTypeCounter.add(1, {
...baseMetricDefinition.getCommonAttributes(config),
type: event.type,
forced: event.forced,
});
}
+49
View File
@@ -53,6 +53,15 @@ import {
import { TelemetryTarget } from './index.js';
import { debugLogger } from '../utils/debugLogger.js';
import { authEvents } from '../code_assist/oauth2.js';
import { coreEvents, CoreEvent } from '../utils/events.js';
import {
logKeychainAvailability,
logTokenStorageInitialization,
} from './loggers.js';
import type {
KeychainAvailabilityEvent,
TokenStorageInitializationEvent,
} from './types.js';
// For troubleshooting, set the log level to DiagLogLevel.DEBUG
class DiagLoggerAdapter {
@@ -86,6 +95,12 @@ let telemetryInitialized = false;
let callbackRegistered = false;
let authListener: ((newCredentials: JWTInput) => Promise<void>) | undefined =
undefined;
let keychainAvailabilityListener:
| ((event: KeychainAvailabilityEvent) => void)
| undefined = undefined;
let tokenStorageTypeListener:
| ((event: TokenStorageInitializationEvent) => void)
| undefined = undefined;
const telemetryBuffer: Array<() => void | Promise<void>> = [];
let activeTelemetryEmail: string | undefined;
@@ -196,6 +211,26 @@ export async function initializeTelemetry(
'session.id': config.getSessionId(),
});
if (!keychainAvailabilityListener) {
keychainAvailabilityListener = (event: KeychainAvailabilityEvent) => {
logKeychainAvailability(config, event);
};
coreEvents.on(
CoreEvent.TelemetryKeychainAvailability,
keychainAvailabilityListener,
);
}
if (!tokenStorageTypeListener) {
tokenStorageTypeListener = (event: TokenStorageInitializationEvent) => {
logTokenStorageInitialization(config, event);
};
coreEvents.on(
CoreEvent.TelemetryTokenStorageType,
tokenStorageTypeListener,
);
}
const otlpEndpoint = config.getTelemetryOtlpEndpoint();
const otlpProtocol = config.getTelemetryOtlpProtocol();
const telemetryTarget = config.getTelemetryTarget();
@@ -376,6 +411,20 @@ export async function shutdownTelemetry(
authEvents.off('post_auth', authListener);
authListener = undefined;
}
if (keychainAvailabilityListener) {
coreEvents.off(
CoreEvent.TelemetryKeychainAvailability,
keychainAvailabilityListener,
);
keychainAvailabilityListener = undefined;
}
if (tokenStorageTypeListener) {
coreEvents.off(
CoreEvent.TelemetryTokenStorageType,
tokenStorageTypeListener,
);
tokenStorageTypeListener = undefined;
}
callbackRegistered = false;
activeTelemetryEmail = undefined;
}
+57
View File
@@ -2111,3 +2111,60 @@ export class HookCallEvent implements BaseTelemetryEvent {
return `Hook call ${hookId} ${status} in ${this.duration_ms}ms`;
}
}
export const EVENT_KEYCHAIN_AVAILABILITY = 'gemini_cli.keychain.availability';
export class KeychainAvailabilityEvent implements BaseTelemetryEvent {
'event.name': 'keychain_availability';
'event.timestamp': string;
available: boolean;
constructor(available: boolean) {
this['event.name'] = 'keychain_availability';
this['event.timestamp'] = new Date().toISOString();
this.available = available;
}
toOpenTelemetryAttributes(config: Config): LogAttributes {
const attributes: LogAttributes = {
...getCommonAttributes(config),
'event.name': EVENT_KEYCHAIN_AVAILABILITY,
'event.timestamp': this['event.timestamp'],
available: this.available,
};
return attributes;
}
toLogBody(): string {
return `Keychain availability: ${this.available}`;
}
}
export const EVENT_TOKEN_STORAGE_INITIALIZATION =
'gemini_cli.token_storage.initialization';
export class TokenStorageInitializationEvent implements BaseTelemetryEvent {
'event.name': 'token_storage_initialization';
'event.timestamp': string;
type: string;
forced: boolean;
constructor(type: string, forced: boolean) {
this['event.name'] = 'token_storage_initialization';
this['event.timestamp'] = new Date().toISOString();
this.type = type;
this.forced = forced;
}
toOpenTelemetryAttributes(config: Config): LogAttributes {
return {
...getCommonAttributes(config),
'event.name': EVENT_TOKEN_STORAGE_INITIALIZATION,
'event.timestamp': this['event.timestamp'],
type: this.type,
forced: this.forced,
};
}
toLogBody(): string {
return `Token storage initialized. Type: ${this.type}. Forced: ${this.forced}`;
}
}
+16
View File
@@ -9,6 +9,10 @@ import type { AgentDefinition } from '../agents/types.js';
import type { McpClient } from '../tools/mcp-client.js';
import type { ExtensionEvents } from './extensionLoader.js';
import type { EditorType } from './editor.js';
import type {
TokenStorageInitializationEvent,
KeychainAvailabilityEvent,
} from '../telemetry/types.js';
/**
* Defines the severity level for user-facing feedback.
@@ -168,6 +172,8 @@ export enum CoreEvent {
EditorSelected = 'editor-selected',
SlashCommandConflicts = 'slash-command-conflicts',
QuotaChanged = 'quota-changed',
TelemetryKeychainAvailability = 'telemetry-keychain-availability',
TelemetryTokenStorageType = 'telemetry-token-storage-type',
}
/**
@@ -198,6 +204,8 @@ export interface CoreEvents extends ExtensionEvents {
[CoreEvent.RequestEditorSelection]: never[];
[CoreEvent.EditorSelected]: [EditorSelectedPayload];
[CoreEvent.SlashCommandConflicts]: [SlashCommandConflictsPayload];
[CoreEvent.TelemetryKeychainAvailability]: [KeychainAvailabilityEvent];
[CoreEvent.TelemetryTokenStorageType]: [TokenStorageInitializationEvent];
}
type EventBacklogItem = {
@@ -367,6 +375,14 @@ export class CoreEventEmitter extends EventEmitter<CoreEvents> {
);
}
}
emitTelemetryKeychainAvailability(event: KeychainAvailabilityEvent): void {
this._emitOrQueue(CoreEvent.TelemetryKeychainAvailability, event);
}
emitTelemetryTokenStorageType(event: TokenStorageInitializationEvent): void {
this._emitOrQueue(CoreEvent.TelemetryTokenStorageType, event);
}
}
export const coreEvents = new CoreEventEmitter();