mirror of
https://github.com/google-gemini/gemini-cli.git
synced 2026-08-01 12:41:00 -07:00
Compare commits
15 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 25baa158aa | |||
| 366f1df120 | |||
| e5ff2023ad | |||
| bf9ca33c18 | |||
| bbf6800778 | |||
| b38e0984b9 | |||
| ddd28f6431 | |||
| 6ed1878c5f | |||
| 39d36108d7 | |||
| 80f0cbd798 | |||
| 7d165e77f0 | |||
| c57a28f48a | |||
| a83ca11035 | |||
| 15ef1cd797 | |||
| 5a74f7a2eb |
@@ -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}}.
|
||||
"""
|
||||
@@ -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}}.
|
||||
"""
|
||||
@@ -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'.
|
||||
|
||||
|
||||
@@ -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.
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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">
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,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"
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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' && (
|
||||
|
||||
@@ -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: [
|
||||
{
|
||||
|
||||
@@ -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`] = `
|
||||
"[40m[30m▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀[39m[49m
|
||||
[40m [97m> [7m[[27mPasted Text: 10 lines][39m [49m
|
||||
[40m[30m▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄[39m[49m"
|
||||
`;
|
||||
|
||||
exports[`InputPrompt > mouse interaction > should toggle paste expansion on double-click 6`] = `
|
||||
"[40m[30m▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀[39m[49m
|
||||
[40m [97m> [39m[7ml[27mine1 [49m
|
||||
[40m line2 [49m
|
||||
[40m line3 [49m
|
||||
[40m line4 [49m
|
||||
[40m line5 [49m
|
||||
[40m line6 [49m
|
||||
[40m line7 [49m
|
||||
[40m line8 [49m
|
||||
[40m line9 [49m
|
||||
[40m line10 [49m
|
||||
[40m[30m▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄[39m[49m"
|
||||
`;
|
||||
|
||||
exports[`InputPrompt > mouse interaction > should toggle paste expansion on double-click 7`] = `
|
||||
"[40m[30m▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀[39m[49m
|
||||
[40m [97m> [7m[[27mPasted Text: 10 lines][39m [49m
|
||||
[40m[30m▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄[39m[49m"
|
||||
`;
|
||||
|
||||
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();
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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),
|
||||
);
|
||||
});
|
||||
|
||||
@@ -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 ---
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
|
||||
@@ -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> {
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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}`;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
|
||||
Reference in New Issue
Block a user