Compare commits

...

47 Commits

Author SHA1 Message Date
gemini-cli-robot c1b110a618 chore(release): v0.26.0 2026-01-28 01:47:46 +00:00
gemini-cli-robot a380b4219c chore(release): v0.26.0-preview.5 2026-01-28 01:12:54 +00:00
Abhi 31c6fef1e8 feat(skills): promote skills settings to stable (#17713) 2026-01-27 16:31:32 -08:00
A.K.M. Adib d8e9db3761 address feedback 2026-01-27 18:24:42 -05:00
A.K.M. Adib 43846f4e3b address feedback 2026-01-27 18:24:38 -05:00
Jack Wotherspoon 2a3c879782 feat: add clearContext to AfterAgent hooks (#16574) 2026-01-27 09:32:10 -08:00
Jacob Richman 958cc45937 Fix so rewind starts at the bottom and loadHistory refreshes static content. (#17335) 2026-01-27 09:27:36 -08:00
Adib234 9c667cf7ba feat: implement /rewind command (#15720) 2026-01-27 09:14:23 -08:00
gemini-cli-robot c593a29647 chore(release): v0.26.0-preview.4 2026-01-25 17:57:54 +00:00
gemini-cli-robot cebe386d79 fix(patch): cherry-pick 12a5490 to release/v0.26.0-preview.3-pr-17395 [CONFLICTS] (#17424)
Co-authored-by: Adib234 <30782825+Adib234@users.noreply.github.com>
Co-authored-by: Sandy Tao <sandytao520@icloud.com>
Co-authored-by: A.K.M. Adib <adibakm@google.com>
2026-01-25 08:59:09 -05:00
gemini-cli-robot 1c207e2f82 chore(release): v0.26.0-preview.3 2026-01-22 22:27:22 +00:00
gemini-cli-robot ee87c98f43 fix(patch): cherry-pick addb57c to release/v0.26.0-preview.2-pr-17331 [CONFLICTS] (#17345)
Co-authored-by: Tommaso Sciortino <sciortino@gmail.com>
2026-01-22 14:04:49 -08:00
gemini-cli-robot 75b5eeeb12 chore(release): v0.26.0-preview.2 2026-01-22 19:03:19 +00:00
gemini-cli-robot 0fa9a54088 fix(patch): cherry-pick 87a0db2 to release/v0.26.0-preview.1-pr-17308 [CONFLICTS] (#17317)
Co-authored-by: Shreya Keshive <shreyakeshive@google.com>
2026-01-22 18:34:24 +00:00
gemini-cli-robot 603e66b2ea chore(release): v0.26.0-preview.1 2026-01-22 01:02:56 +00:00
gemini-cli-robot dc8fc75ac0 fix(patch): cherry-pick 61040d0 to release/v0.26.0-preview.0-pr-17247 to patch version v0.26.0-preview.0 and create version 0.26.0-preview.1 (#17258)
Co-authored-by: Abhi <43648792+abhipatel12@users.noreply.github.com>
2026-01-22 00:38:07 +00:00
gemini-cli-robot 2b58605227 chore(release): v0.26.0-preview.0 2026-01-21 04:56:03 +00:00
Andrew Garrett 97aac696fb Fix mcp tool lookup in tool registry (#17054)
Co-authored-by: google-labs-jules[bot] <161369871+google-labs-jules[bot]@users.noreply.github.com>
2026-01-21 04:18:33 +00:00
Jacob Richman 93ae7772fd Fix bug where System scopes weren't migrated. (#17174) 2026-01-21 04:07:17 +00:00
Tommaso Sciortino 9866eb0551 fix bad fallback logic external editor logic (#17166) 2026-01-21 02:48:44 +00:00
Panagiotis Mantafounis 55c2783e6a fix(cli): display 'http' type on mcp list (#16915)
Co-authored-by: Jack Wotherspoon <jackwoth@google.com>
2026-01-21 02:37:19 +00:00
Tommaso Sciortino 2455f939a3 fix(cli): resolve home/end keybinding conflict (#17124) 2026-01-21 02:15:18 +00:00
Jacob Richman 995ae42f53 Avoid spurious warnings about unexpected renders triggered by appEvents and coreEvents. (#17160) 2026-01-21 00:46:10 +00:00
Adib234 e1fd5be429 Add Esc-Esc to clear prompt when it's not empty (#17131) 2026-01-21 00:32:26 +00:00
Gaurav 3b626e7c61 Add interactive ValidationDialog for handling 403 VALIDATION_REQUIRED errors. (#16231) 2026-01-21 00:23:01 +00:00
Tommaso Sciortino aceb06a587 fix(cli): fix newline support broken in previous PR (#17159) 2026-01-21 00:20:51 +00:00
joshualitt 211d2c5fdd feat(core): Ensure all properties in hooks object are event names. (#16870) 2026-01-20 22:47:31 +00:00
christine betts c9061a1cfe Remove missing sidebar item (#17145) 2026-01-20 22:03:35 +00:00
David Soria Parra b288f124b2 fix(cli): send gemini-cli version as mcp client version (#13407)
Co-authored-by: Taylor Mullen <ntaylormullen@google.com>
2026-01-20 22:01:18 +00:00
imadraude 645e2ec041 fix(cli): resolve Ctrl+Enter and Ctrl+J newline issues (#17021) 2026-01-20 21:56:33 +00:00
N. Taylor Mullen 67d69084e9 docs: update project context and pr-creator workflow (#17119) 2026-01-20 21:20:04 +00:00
Sehoon Shon ed0b0fae49 fix(core): resolve auto model in default strategy (#17116) 2026-01-20 21:03:34 +00:00
Sandy Tao f0f705d3ca feat(core): unify agent enabled and disabled flags (#17127) 2026-01-20 21:00:43 +00:00
joshualitt f42b4c80ac feat(core): Add initial eval for generalist agent. (#16856) 2026-01-20 20:32:48 +00:00
David Zammit b99e841021 Fixes Windows crash: "Cannot resize a pty that has already exited" (#15757)
Co-authored-by: Gal Zahavi <38544478+galz10@users.noreply.github.com>
2026-01-20 20:14:28 +00:00
Adam Weidman a16d5983cf feat: Add flash lite utility fallback chain (#17056) 2026-01-20 19:43:43 +00:00
Adib234 e5745f16cb feat(plan): telemetry to track adoption and usage of plan mode (#16863) 2026-01-20 18:57:34 +00:00
Christian Gunderman 12b0fe1cc2 Demote the subagent test to nightly (#17105) 2026-01-20 18:18:16 +00:00
Pyush Sinha 5b8a23979f fix(core): update telemetry token count after session resume (#15491)
Co-authored-by: Tommaso Sciortino <sciortino@gmail.com>
2026-01-20 18:01:39 +00:00
Shreya Keshive b71fe94e0a feat(admin): apply admin settings to gemini skills/mcp/extensions commands (#17102) 2026-01-20 17:52:11 +00:00
Vedant Mahajan e92f60b4fc fix: migrate BeforeModel and AfterModel hooks to HookSystem (#16599)
Co-authored-by: Tommaso Sciortino <sciortino@gmail.com>
2026-01-20 17:46:54 +00:00
Sandy Tao 15f26175b8 fix(core): Provide compact, actionable errors for agent delegation failures (#16493)
Co-authored-by: Tommaso Sciortino <sciortino@gmail.com>
2026-01-20 17:30:59 +00:00
christine betts 85b17166a5 Revert "Revert "Update extension examples"" (#16445) 2026-01-20 17:14:46 +00:00
Christian Gunderman 88df6210eb Test coverage for hook exit code cases (#17041) 2026-01-20 17:13:15 +00:00
Shreya Keshive 943481ccc0 feat(admin): set admin.skills.enabled based on advancedFeaturesEnabled setting (#17095) 2026-01-20 16:57:47 +00:00
Coco Sheng 79076d1d52 [A2A] Disable checkpointing if git is not installed (#16896) 2026-01-20 16:38:38 +00:00
christine betts 166e04a8dd Fix mcp instructions (#16439) 2026-01-20 16:10:21 +00:00
163 changed files with 5503 additions and 1562 deletions
+8 -1
View File
@@ -35,7 +35,14 @@ Follow these steps to create a Pull Request:
- **Related Issues**: Link any issues fixed or related to this PR (e.g.,
"Fixes #123").
4. **Create PR**: Use the `gh` CLI to create the PR. To avoid shell escaping
4. **Preflight Check**: Before creating the PR, run the workspace preflight
script to ensure all build, lint, and test checks pass.
```bash
npm run preflight
```
If any checks fail, address the issues before proceeding to create the PR.
5. **Create PR**: Use the `gh` CLI to create the PR. To avoid shell escaping
issues with multi-line Markdown, write the description to a temporary file
first.
```bash
+72 -420
View File
@@ -1,420 +1,72 @@
## Building and running
Before submitting any changes, it is crucial to validate them by running the
full preflight check. This command will build the repository, run all tests,
check for type errors, and lint the code.
To run the full suite of checks, execute the following command:
```bash
npm run preflight
```
This single command ensures that your changes meet all the quality gates of the
project. While you can run the individual steps (`build`, `test`, `typecheck`,
`lint`) separately, it is highly recommended to use `npm run preflight` to
ensure a comprehensive validation.
## Running Tests in Workspaces\*\*: To run a specific test file within a
workspace, use the command:
`npm test -w <workspace-name> -- <path/to/test-file.test.ts>`. **CRITICAL**: The
`<path/to/test-file.test.ts>` MUST be relative to the workspace directory root,
NOT the project root.
- _Example (Core package)_:
`npm test -w @google/gemini-cli-core -- src/routing/modelRouterService.test.ts`
- _Common workspaces_: `@google/gemini-cli`, `@google/gemini-cli-core`.
## Writing Tests
This project uses **Vitest** as its primary testing framework. When writing
tests, aim to follow existing patterns. Key conventions include:
### Test Structure and Framework
- **Framework**: All tests are written using Vitest (`describe`, `it`, `expect`,
`vi`).
- **File Location**: Test files (`*.test.ts` for logic, `*.test.tsx` for React
components) are co-located with the source files they test.
- **Configuration**: Test environments are defined in `vitest.config.ts` files.
- **Setup/Teardown**: Use `beforeEach` and `afterEach`. Commonly,
`vi.resetAllMocks()` is called in `beforeEach` and `vi.restoreAllMocks()` in
`afterEach`.
### Mocking (`vi` from Vitest)
- **ES Modules**: Mock with
`vi.mock('module-name', async (importOriginal) => { ... })`. Use
`importOriginal` for selective mocking.
- _Example_:
`vi.mock('os', async (importOriginal) => { const actual = await importOriginal(); return { ...actual, homedir: vi.fn() }; });`
- **Mocking Order**: For critical dependencies (e.g., `os`, `fs`) that affect
module-level constants, place `vi.mock` at the _very top_ of the test file,
before other imports.
- **Hoisting**: Use `const myMock = vi.hoisted(() => vi.fn());` if a mock
function needs to be defined before its use in a `vi.mock` factory.
- **Mock Functions**: Create with `vi.fn()`. Define behavior with
`mockImplementation()`, `mockResolvedValue()`, or `mockRejectedValue()`.
- **Spying**: Use `vi.spyOn(object, 'methodName')`. Restore spies with
`mockRestore()` in `afterEach`.
### Commonly Mocked Modules
- **Node.js built-ins**: `fs`, `fs/promises`, `os` (especially `os.homedir()`),
`path`, `child_process` (`execSync`, `spawn`).
- **External SDKs**: `@google/genai`, `@modelcontextprotocol/sdk`.
- **Internal Project Modules**: Dependencies from other project packages are
often mocked.
### React Component Testing (CLI UI - Ink)
- Use `render()` from `ink-testing-library`.
- Assert output with `lastFrame()`.
- Wrap components in necessary `Context.Provider`s.
- Mock custom React hooks and complex child components using `vi.mock()`.
### Asynchronous Testing
- Use `async/await`.
- For timers, use `vi.useFakeTimers()`, `vi.advanceTimersByTimeAsync()`,
`vi.runAllTimersAsync()`.
- Test promise rejections with `await expect(promise).rejects.toThrow(...)`.
### General Guidance
- When adding tests, first examine existing tests to understand and conform to
established conventions.
- Pay close attention to the mocks at the top of existing test files; they
reveal critical dependencies and how they are managed in a test environment.
## Git Repo
The main branch for this project is called "main"
## JavaScript/TypeScript
When contributing to this React, Node, and TypeScript codebase, please
prioritize the use of plain JavaScript objects with accompanying TypeScript
interface or type declarations over JavaScript class syntax. This approach
offers significant advantages, especially concerning interoperability with React
and overall code maintainability.
### Preferring Plain Objects over Classes
JavaScript classes, by their nature, are designed to encapsulate internal state
and behavior. While this can be useful in some object-oriented paradigms, it
often introduces unnecessary complexity and friction when working with React's
component-based architecture. Here's why plain objects are preferred:
- Seamless React Integration: React components thrive on explicit props and
state management. Classes' tendency to store internal state directly within
instances can make prop and state propagation harder to reason about and
maintain. Plain objects, on the other hand, are inherently immutable (when
used thoughtfully) and can be easily passed as props, simplifying data flow
and reducing unexpected side effects.
- Reduced Boilerplate and Increased Conciseness: Classes often promote the use
of constructors, this binding, getters, setters, and other boilerplate that
can unnecessarily bloat code. TypeScript interface and type declarations
provide powerful static type checking without the runtime overhead or
verbosity of class definitions. This allows for more succinct and readable
code, aligning with JavaScript's strengths in functional programming.
- Enhanced Readability and Predictability: Plain objects, especially when their
structure is clearly defined by TypeScript interfaces, are often easier to
read and understand. Their properties are directly accessible, and there's no
hidden internal state or complex inheritance chains to navigate. This
predictability leads to fewer bugs and a more maintainable codebase.
- Simplified Immutability: While not strictly enforced, plain objects encourage
an immutable approach to data. When you need to modify an object, you
typically create a new one with the desired changes, rather than mutating the
original. This pattern aligns perfectly with React's reconciliation process
and helps prevent subtle bugs related to shared mutable state.
- Better Serialization and Deserialization: Plain JavaScript objects are
naturally easy to serialize to JSON and deserialize back, which is a common
requirement in web development (e.g., for API communication or local storage).
Classes, with their methods and prototypes, can complicate this process.
### Embracing ES Module Syntax for Encapsulation
Rather than relying on Java-esque private or public class members, which can be
verbose and sometimes limit flexibility, we strongly prefer leveraging ES module
syntax (`import`/`export`) for encapsulating private and public APIs.
- Clearer Public API Definition: With ES modules, anything that is exported is
part of the public API of that module, while anything not exported is
inherently private to that module. This provides a very clear and explicit way
to define what parts of your code are meant to be consumed by other modules.
- Enhanced Testability (Without Exposing Internals): By default, unexported
functions or variables are not accessible from outside the module. This
encourages you to test the public API of your modules, rather than their
internal implementation details. If you find yourself needing to spy on or
stub an unexported function for testing purposes, it's often a "code smell"
indicating that the function might be a good candidate for extraction into its
own separate, testable module with a well-defined public API. This promotes a
more robust and maintainable testing strategy.
- Reduced Coupling: Explicitly defined module boundaries through import/export
help reduce coupling between different parts of your codebase. This makes it
easier to refactor, debug, and understand individual components in isolation.
### Avoiding `any` Types and Type Assertions; Preferring `unknown`
TypeScript's power lies in its ability to provide static type checking, catching
potential errors before your code runs. To fully leverage this, it's crucial to
avoid the `any` type and be judicious with type assertions.
- **The Dangers of `any`**: Using any effectively opts out of TypeScript's type
checking for that particular variable or expression. While it might seem
convenient in the short term, it introduces significant risks:
- **Loss of Type Safety**: You lose all the benefits of type checking, making
it easy to introduce runtime errors that TypeScript would otherwise have
caught.
- **Reduced Readability and Maintainability**: Code with `any` types is harder
to understand and maintain, as the expected type of data is no longer
explicitly defined.
- **Masking Underlying Issues**: Often, the need for any indicates a deeper
problem in the design of your code or the way you're interacting with
external libraries. It's a sign that you might need to refine your types or
refactor your code.
- **Preferring `unknown` over `any`**: When you absolutely cannot determine the
type of a value at compile time, and you're tempted to reach for any, consider
using unknown instead. unknown is a type-safe counterpart to any. While a
variable of type unknown can hold any value, you must perform type narrowing
(e.g., using typeof or instanceof checks, or a type assertion) before you can
perform any operations on it. This forces you to handle the unknown type
explicitly, preventing accidental runtime errors.
```ts
function processValue(value: unknown) {
if (typeof value === 'string') {
// value is now safely a string
console.log(value.toUpperCase());
} else if (typeof value === 'number') {
// value is now safely a number
console.log(value * 2);
}
// Without narrowing, you cannot access properties or methods on 'value'
// console.log(value.someProperty); // Error: Object is of type 'unknown'.
}
```
- **Type Assertions (`as Type`) - Use with Caution**: Type assertions tell the
TypeScript compiler, "Trust me, I know what I'm doing; this is definitely of
this type." While there are legitimate use cases (e.g., when dealing with
external libraries that don't have perfect type definitions, or when you have
more information than the compiler), they should be used sparingly and with
extreme caution.
- **Bypassing Type Checking**: Like `any`, type assertions bypass TypeScript's
safety checks. If your assertion is incorrect, you introduce a runtime error
that TypeScript would not have warned you about.
- **Code Smell in Testing**: A common scenario where `any` or type assertions
might be tempting is when trying to test "private" implementation details
(e.g., spying on or stubbing an unexported function within a module). This
is a strong indication of a "code smell" in your testing strategy and
potentially your code structure. Instead of trying to force access to
private internals, consider whether those internal details should be
refactored into a separate module with a well-defined public API. This makes
them inherently testable without compromising encapsulation.
### Type narrowing `switch` clauses
Use the `checkExhaustive` helper in the default clause of a switch statement.
This will ensure that all of the possible options within the value or
enumeration are used.
This helper method can be found in `packages/cli/src/utils/checks.ts`
### Embracing JavaScript's Array Operators
To further enhance code cleanliness and promote safe functional programming
practices, leverage JavaScript's rich set of array operators as much as
possible. Methods like `.map()`, `.filter()`, `.reduce()`, `.slice()`,
`.sort()`, and others are incredibly powerful for transforming and manipulating
data collections in an immutable and declarative way.
Using these operators:
- Promotes Immutability: Most array operators return new arrays, leaving the
original array untouched. This functional approach helps prevent unintended
side effects and makes your code more predictable.
- Improves Readability: Chaining array operators often lead to more concise and
expressive code than traditional for loops or imperative logic. The intent of
the operation is clear at a glance.
- Facilitates Functional Programming: These operators are cornerstones of
functional programming, encouraging the creation of pure functions that take
inputs and produce outputs without causing side effects. This paradigm is
highly beneficial for writing robust and testable code that pairs well with
React.
By consistently applying these principles, we can maintain a codebase that is
not only efficient and performant but also a joy to work with, both now and in
the future.
## React (mirrored and adjusted from [react-mcp-server](https://github.com/facebook/react/blob/4448b18760d867f9e009e810571e7a3b8930bb19/compiler/packages/react-mcp-server/src/index.ts#L376C1-L441C94))
### Role
You are a React assistant that helps users write more efficient and optimizable
React code. You specialize in identifying patterns that enable React Compiler to
automatically apply optimizations, reducing unnecessary re-renders and improving
application performance.
### Follow these guidelines in all code you produce and suggest
Use functional components with Hooks: Do not generate class components or use
old lifecycle methods. Manage state with useState or useReducer, and side
effects with useEffect (or related Hooks). Always prefer functions and Hooks for
any new component logic.
Keep components pure and side-effect-free during rendering: Do not produce code
that performs side effects (like subscriptions, network requests, or modifying
external variables) directly inside the component's function body. Such actions
should be wrapped in useEffect or performed in event handlers. Ensure your
render logic is a pure function of props and state.
Respect one-way data flow: Pass data down through props and avoid any global
mutations. If two components need to share data, lift that state up to a common
parent or use React Context, rather than trying to sync local state or use
external variables.
Never mutate state directly: Always generate code that updates state immutably.
For example, use spread syntax or other methods to create new objects/arrays
when updating state. Do not use assignments like state.someValue = ... or array
mutations like array.push() on state variables. Use the state setter (setState
from useState, etc.) to update state.
Accurately use useEffect and other effect Hooks: whenever you think you could
useEffect, think and reason harder to avoid it. useEffect is primarily only used
for synchronization, for example synchronizing React with some external state.
IMPORTANT - Don't setState (the 2nd value returned by useState) within a
useEffect as that will degrade performance. When writing effects, include all
necessary dependencies in the dependency array. Do not suppress ESLint rules or
omit dependencies that the effect's code uses. Structure the effect callbacks to
handle changing values properly (e.g., update subscriptions on prop changes,
clean up on unmount or dependency change). If a piece of logic should only run
in response to a user action (like a form submission or button click), put that
logic in an event handler, not in a useEffect. Where possible, useEffects should
return a cleanup function.
Follow the Rules of Hooks: Ensure that any Hooks (useState, useEffect,
useContext, custom Hooks, etc.) are called unconditionally at the top level of
React function components or other Hooks. Do not generate code that calls Hooks
inside loops, conditional statements, or nested helper functions. Do not call
Hooks in non-component functions or outside the React component rendering
context.
Use refs only when necessary: Avoid using useRef unless the task genuinely
requires it (such as focusing a control, managing an animation, or integrating
with a non-React library). Do not use refs to store application state that
should be reactive. If you do use refs, never write to or read from ref.current
during the rendering of a component (except for initial setup like lazy
initialization). Any ref usage should not affect the rendered output directly.
Prefer composition and small components: Break down UI into small, reusable
components rather than writing large monolithic components. The code you
generate should promote clarity and reusability by composing components
together. Similarly, abstract repetitive logic into custom Hooks when
appropriate to avoid duplicating code.
Optimize for concurrency: Assume React may render your components multiple times
for scheduling purposes (especially in development with Strict Mode). Write code
that remains correct even if the component function runs more than once. For
instance, avoid side effects in the component body and use functional state
updates (e.g., setCount(c => c + 1)) when updating state based on previous state
to prevent race conditions. Always include cleanup functions in effects that
subscribe to external resources. Don't write useEffects for "do this when this
changes" side effects. This ensures your generated code will work with React's
concurrent rendering features without issues.
Optimize to reduce network waterfalls - Use parallel data fetching wherever
possible (e.g., start multiple requests at once rather than one after another).
Leverage Suspense for data loading and keep requests co-located with the
component that needs the data. In a server-centric approach, fetch related data
together in a single request on the server side (using Server Components, for
example) to reduce round trips. Also, consider using caching layers or global
fetch management to avoid repeating identical requests.
Rely on React Compiler - useMemo, useCallback, and React.memo can be omitted if
React Compiler is enabled. Avoid premature optimization with manual memoization.
Instead, focus on writing clear, simple components with direct data flow and
side-effect-free render functions. Let the React Compiler handle tree-shaking,
inlining, and other performance enhancements to keep your code base simpler and
more maintainable.
Design for a good user experience - Provide clear, minimal, and non-blocking UI
states. When data is loading, show lightweight placeholders (e.g., skeleton
screens) rather than intrusive spinners everywhere. Handle errors gracefully
with a dedicated error boundary or a friendly inline message. Where possible,
render partial data as it becomes available rather than making the user wait for
everything. Suspense allows you to declare the loading states in your component
tree in a natural way, preventing “flash” states and improving perceived
performance.
### Process
1. Analyze the user's code for optimization opportunities:
- Check for React anti-patterns that prevent compiler optimization
- Look for component structure issues that limit compiler effectiveness
- Think about each suggestion you are making and consult React docs for best
practices
2. Provide actionable guidance:
- Explain specific code changes with clear reasoning
- Show before/after examples when suggesting changes
- Only suggest changes that meaningfully improve optimization potential
### Optimization Guidelines
- State updates should be structured to enable granular updates
- Side effects should be isolated and dependencies clearly defined
## Documentation guidelines
When working in the `/docs` directory, follow the guidelines in this section:
- **Role:** You are an expert technical writer and AI assistant for contributors
to Gemini CLI. Produce professional, accurate, and consistent documentation to
guide users of Gemini CLI.
- **Technical Accuracy:** Do not invent facts, commands, code, API names, or
output. All technical information specific to Gemini CLI must be based on code
found within this directory and its subdirectories.
- **Style Authority:** Your source for writing guidance and style is the
"Documentation contribution process" section in the root directory's
`CONTRIBUTING.md` file, as well as any guidelines provided this section.
- **Information Architecture Consideration:** Before proposing documentation
changes, consider the information architecture. If a change adds significant
new content to existing documents, evaluate if creating a new, more focused
page or changes to `sidebar.json` would provide a better user experience.
- **Proactive User Consideration:** The user experience should be a primary
concern when making changes to documentation. Aim to fill gaps in existing
knowledge whenever possible while keeping documentation concise and easy for
users to understand. If changes might hinder user understanding or
accessibility, proactively raise these concerns and propose alternatives.
## Comments policy
Only write high-value comments if at all. Avoid talking to the user through
comments.
## Logging and Error Handling
- **Avoid Console Statements:** Do not use `console.log`, `console.error`, or
similar methods directly.
- **Non-User-Facing Logs:** For developer-facing debug messages, use
`debugLogger` (from `@google/gemini-cli-core`).
- **User-Facing Feedback:** To surface errors or warnings to the user, use
`coreEvents.emitFeedback` (from `@google/gemini-cli-core`).
## General requirements
- If there is something you do not understand or is ambiguous, seek confirmation
or clarification from the user before making changes based on assumptions.
- Use hyphens instead of underscores in flag names (e.g. `my-flag` instead of
`my_flag`).
- Always refer to Gemini CLI as `Gemini CLI`, never `the Gemini CLI`.
# Gemini CLI Project Context
Gemini CLI is an open-source AI agent that brings the power of Gemini directly
into the terminal. It is designed to be a terminal-first, extensible, and
powerful tool for developers.
## Project Overview
- **Purpose:** Provide a seamless terminal interface for Gemini models,
supporting code understanding, generation, automation, and integration via MCP
(Model Context Protocol).
- **Main Technologies:**
- **Runtime:** Node.js (>=20.0.0, recommended ~20.19.0 for development)
- **Language:** TypeScript
- **UI Framework:** React (using [Ink](https://github.com/vadimdemedes/ink)
for CLI rendering)
- **Testing:** Vitest
- **Bundling:** esbuild
- **Linting/Formatting:** ESLint, Prettier
- **Architecture:** Monorepo structure using npm workspaces.
- `packages/cli`: User-facing terminal UI, input processing, and display
rendering.
- `packages/core`: Backend logic, Gemini API orchestration, prompt
construction, and tool execution.
- `packages/core/src/tools/`: Built-in tools for file system, shell, and web
operations.
- `packages/a2a-server`: Experimental Agent-to-Agent server.
- `packages/vscode-ide-companion`: VS Code extension pairing with the CLI.
## Building and Running
- **Install Dependencies:** `npm install`
- **Build All:** `npm run build:all` (Builds packages, sandbox, and VS Code
companion)
- **Build Packages:** `npm run build`
- **Run in Development:** `npm run start`
- **Run in Debug Mode:** `npm run debug` (Enables Node.js inspector)
- **Bundle Project:** `npm run bundle`
- **Clean Artifacts:** `npm run clean`
## Testing and Quality
- **Test Commands:**
- **Unit (All):** `npm run test`
- **Integration (E2E):** `npm run test:e2e`
- **Workspace-Specific:** `npm test -w <pkg> -- <path>` (Note: `<path>` must
be relative to the workspace root, e.g.,
`-w @google/gemini-cli-core -- src/routing/modelRouterService.test.ts`)
- **Full Validation:** `npm run preflight` (Heaviest check; runs clean, install,
build, lint, type check, and tests. Recommended before submitting PRs.)
- **Individual Checks:** `npm run lint` / `npm run format` / `npm run typecheck`
## Development Conventions
- **Contributions:** Follow the process outlined in `CONTRIBUTING.md`. Requires
signing the Google CLA.
- **Pull Requests:** Keep PRs small, focused, and linked to an existing issue.
- **Commit Messages:** Follow the
[Conventional Commits](https://www.conventionalcommits.org/) standard.
- **Coding Style:** Adhere to existing patterns in `packages/cli` (React/Ink)
and `packages/core` (Backend logic).
- **Imports:** Use specific imports and avoid restricted relative imports
between packages (enforced by ESLint).
## Documentation
- Located in the `docs/` directory.
- Architecture overview: `docs/architecture.md`.
- Contribution guide: `CONTRIBUTING.md`.
- Documentation is organized via `docs/sidebar.json`.
- Follows the
[Google Developer Documentation Style Guide](https://developers.google.com/style).
+13 -11
View File
@@ -19,8 +19,8 @@ available combinations.
| Action | Keys |
| ------------------------------------------- | ------------------------------------------------------------ |
| Move the cursor to the start of the line. | `Ctrl + A`<br />`Home` |
| Move the cursor to the end of the line. | `Ctrl + E`<br />`End` |
| Move the cursor to the start of the line. | `Ctrl + A`<br />`Home (no Ctrl, no Shift)` |
| Move the cursor to the end of the line. | `Ctrl + E`<br />`End (no Ctrl, no Shift)` |
| Move the cursor up one line. | `Up Arrow (no Ctrl, no Cmd)` |
| Move the cursor down one line. | `Down Arrow (no Ctrl, no Cmd)` |
| Move the cursor one character to the left. | `Left Arrow (no Ctrl, no Cmd)`<br />`Ctrl + B` |
@@ -44,14 +44,14 @@ available combinations.
#### Scrolling
| Action | Keys |
| ------------------------ | -------------------- |
| Scroll content up. | `Shift + Up Arrow` |
| Scroll content down. | `Shift + Down Arrow` |
| Scroll to the top. | `Home` |
| Scroll to the bottom. | `End` |
| Scroll up by one page. | `Page Up` |
| Scroll down by one page. | `Page Down` |
| Action | Keys |
| ------------------------ | --------------------------------- |
| Scroll content up. | `Shift + Up Arrow` |
| Scroll content down. | `Shift + Down Arrow` |
| Scroll to the top. | `Ctrl + Home`<br />`Shift + Home` |
| Scroll to the bottom. | `Ctrl + End`<br />`Shift + End` |
| Scroll up by one page. | `Page Up` |
| Scroll down by one page. | `Page Down` |
#### History & Search
@@ -62,6 +62,7 @@ available combinations.
| Start reverse search through history. | `Ctrl + R` |
| Submit the selected reverse-search match. | `Enter (no Ctrl)` |
| Accept a suggestion while reverse searching. | `Tab` |
| Browse and rewind previous interactions. | `Double Esc` |
#### Navigation
@@ -117,7 +118,8 @@ available combinations.
- `!` on an empty prompt: Enter or exit shell mode.
- `\` (at end of a line) + `Enter`: Insert a newline without leaving single-line
mode.
- `Esc` pressed twice quickly: Browse and rewind previous interactions.
- `Esc` pressed twice quickly: Clear the input prompt if it is not empty,
otherwise browse and rewind previous interactions.
- `Up Arrow` / `Down Arrow`: When the cursor is at the top or bottom of a
single-line input, navigate backward or forward through prompt history.
- `Number keys (1-9, multi-digit)` inside selection dialogs: Jump directly to
+5
View File
@@ -17,6 +17,11 @@ policies.
may prompt you to switch to a fallback model (by default always prompts
you).
Some internal utility calls (such as prompt completion and classification)
use a silent fallback chain for `gemini-2.5-flash-lite` and will fall back
to `gemini-2.5-flash` and `gemini-2.5-pro` without prompting or changing the
configured model.
3. **Model switch:** If approved, or if the policy allows for silent fallback,
the CLI will use an available fallback model for the current turn or the
remainder of the session.
+14 -12
View File
@@ -113,19 +113,21 @@ they appear in the UI.
### Experimental
| UI Label | Setting | Description | Default |
| ----------------------------------- | ------------------------------------------------------- | ----------------------------------------------------------------------------------- | ------- |
| Agent Skills | `experimental.skills` | Enable Agent Skills (experimental). | `false` |
| Enable Codebase Investigator | `experimental.codebaseInvestigatorSettings.enabled` | Enable the Codebase Investigator agent. | `true` |
| Codebase Investigator Max Num Turns | `experimental.codebaseInvestigatorSettings.maxNumTurns` | Maximum number of turns for the Codebase Investigator agent. | `10` |
| Use OSC 52 Paste | `experimental.useOSC52Paste` | Use OSC 52 sequence for pasting instead of clipboardy (useful for remote sessions). | `false` |
| Enable CLI Help Agent | `experimental.cliHelpAgentSettings.enabled` | Enable the CLI Help Agent. | `true` |
| Plan | `experimental.plan` | Enable planning features (Plan Mode and tools). | `false` |
| UI Label | Setting | Description | Default |
| ---------------- | ---------------------------- | ----------------------------------------------------------------------------------- | ------- |
| Use OSC 52 Paste | `experimental.useOSC52Paste` | Use OSC 52 sequence for pasting instead of clipboardy (useful for remote sessions). | `false` |
| Plan | `experimental.plan` | Enable planning features (Plan Mode and tools). | `false` |
### Hooks
### Skills
| UI Label | Setting | Description | Default |
| ------------------ | --------------------- | ------------------------------------------------ | ------- |
| Hook Notifications | `hooks.notifications` | Show visual indicators when hooks are executing. | `true` |
| UI Label | Setting | Description | Default |
| ------------------- | ---------------- | -------------------- | ------- |
| Enable Agent Skills | `skills.enabled` | Enable Agent Skills. | `true` |
### HooksConfig
| UI Label | Setting | Description | Default |
| ------------------ | --------------------------- | ------------------------------------------------ | ------- |
| Hook Notifications | `hooksConfig.notifications` | Show visual indicators when hooks are executing. | `true` |
<!-- SETTINGS-AUTOGEN:END -->
+15
View File
@@ -18,6 +18,7 @@ Learn how to enable and setup OpenTelemetry for Gemini CLI.
- [Logs and metrics](#logs-and-metrics)
- [Logs](#logs)
- [Sessions](#sessions)
- [Approval Mode](#approval-mode)
- [Tools](#tools)
- [Files](#files)
- [API](#api)
@@ -315,6 +316,20 @@ Captures startup configuration and user prompt submissions.
- `prompt` (string; excluded if `telemetry.logPrompts` is `false`)
- `auth_type` (string)
#### Approval Mode
Tracks changes and duration of approval modes.
- `approval_mode_switch`: Approval mode was changed.
- **Attributes**:
- `from_mode` (string)
- `to_mode` (string)
- `approval_mode_duration`: Duration spent in an approval mode.
- **Attributes**:
- `mode` (string)
- `duration_ms` (int)
#### Tools
Captures tool executions, output truncation, and Edit behavior.
+4
View File
@@ -68,6 +68,10 @@ If you are using the default "pro" model and the CLI detects that you are being
rate-limited, it automatically switches to the "flash" model for the current
session. This allows you to continue working without interruption.
Internal utility calls that use `gemini-2.5-flash-lite` (for example, prompt
completion and classification) silently fall back to `gemini-2.5-flash` and
`gemini-2.5-pro` when quota is exhausted, without changing the configured model.
## File discovery service
The file discovery service is responsible for finding files in the project that
+13 -6
View File
@@ -851,7 +851,7 @@ their corresponding top-level category object in your `settings.json` file.
- **Requires restart:** Yes
- **`experimental.skills`** (boolean):
- **Description:** Enable Agent Skills (experimental).
- **Description:** [Deprecated] Enable Agent Skills (experimental).
- **Default:** `false`
- **Requires restart:** Yes
@@ -899,27 +899,34 @@ their corresponding top-level category object in your `settings.json` file.
#### `skills`
- **`skills.enabled`** (boolean):
- **Description:** Enable Agent Skills.
- **Default:** `true`
- **Requires restart:** Yes
- **`skills.disabled`** (array):
- **Description:** List of disabled skills.
- **Default:** `[]`
- **Requires restart:** Yes
#### `hooks`
#### `hooksConfig`
- **`hooks.enabled`** (boolean):
- **`hooksConfig.enabled`** (boolean):
- **Description:** Canonical toggle for the hooks system. When disabled, no
hooks will be executed.
- **Default:** `false`
- **Default:** `true`
- **`hooks.disabled`** (array):
- **`hooksConfig.disabled`** (array):
- **Description:** List of hook names (commands) that should be disabled.
Hooks in this list will not execute even if configured.
- **Default:** `[]`
- **`hooks.notifications`** (boolean):
- **`hooksConfig.notifications`** (boolean):
- **Description:** Show visual indicators when hooks are executing.
- **Default:** `true`
#### `hooks`
- **`hooks.BeforeTool`** (array):
- **Description:** Hooks that execute before tool execution. Can intercept,
validate, or modify tool calls.
+211 -7
View File
@@ -106,13 +106,217 @@ If the hook exits with `0`, the CLI attempts to parse `stdout` as JSON.
### `hookSpecificOutput` Reference
| Field | Supported Events | Description |
| :------------------ | :----------------------------------------- | :-------------------------------------------------------------------------------- |
| `additionalContext` | `SessionStart`, `BeforeAgent`, `AfterTool` | Appends text directly to the agent's context. |
| `llm_request` | `BeforeModel` | A `Partial<LLMRequest>` to override parameters of the outgoing call. |
| `llm_response` | `BeforeModel` | A **full** `LLMResponse` to bypass the model and provide a synthetic result. |
| `llm_response` | `AfterModel` | A `Partial<LLMResponse>` to modify the model's response before the agent sees it. |
| `toolConfig` | `BeforeToolSelection` | Object containing `mode` (`AUTO`/`ANY`/`NONE`) and `allowedFunctionNames`. |
### Matchers and tool names
For `BeforeTool` and `AfterTool` events, the `matcher` field in your settings is
compared against the name of the tool being executed.
- **Built-in Tools**: You can match any built-in tool (e.g., `read_file`,
`run_shell_command`). See the [Tools Reference](/docs/tools) for a full list
of available tool names.
- **MCP Tools**: Tools from MCP servers follow the naming pattern
`mcp__<server_name>__<tool_name>`.
- **Regex Support**: Matchers support regular expressions (e.g.,
`matcher: "read_.*"` matches all file reading tools).
### `BeforeTool`
Fires before a tool is invoked. Used for argument validation, security checks,
and parameter rewriting.
- **Input Fields**:
- `tool_name`: (`string`) The name of the tool being called.
- `tool_input`: (`object`) The raw arguments generated by the model.
- `mcp_context`: (`object`) Optional metadata for MCP-based tools.
- **Relevant Output Fields**:
- `decision`: Set to `"deny"` (or `"block"`) to prevent the tool from
executing.
- `reason`: Required if denied. This text is sent **to the agent** as a tool
error, allowing it to respond or retry.
- `hookSpecificOutput.tool_input`: An object that **merges with and
overrides** the model's arguments before execution.
- `continue`: Set to `false` to **kill the entire agent loop** immediately.
- **Exit Code 2 (Block Tool)**: Prevents execution. Uses `stderr` as the
`reason` sent to the agent. **The turn continues.**
### `AfterTool`
Fires after a tool executes. Used for result auditing, context injection, or
hiding sensitive output from the agent.
- **Input Fields**:
- `tool_name`: (`string`)
- `tool_input`: (`object`) The original arguments.
- `tool_response`: (`object`) The result containing `llmContent`,
`returnDisplay`, and optional `error`.
- `mcp_context`: (`object`)
- **Relevant Output Fields**:
- `decision`: Set to `"deny"` to hide the real tool output from the agent.
- `reason`: Required if denied. This text **replaces** the tool result sent
back to the model.
- `hookSpecificOutput.additionalContext`: Text that is **appended** to the
tool result for the agent.
- `continue`: Set to `false` to **kill the entire agent loop** immediately.
- **Exit Code 2 (Block Result)**: Hides the tool result. Uses `stderr` as the
replacement content sent to the agent. **The turn continues.**
---
## Agent hooks
### `BeforeAgent`
Fires after a user submits a prompt, but before the agent begins planning. Used
for prompt validation or injecting dynamic context.
- **Input Fields**:
- `prompt`: (`string`) The original text submitted by the user.
- **Relevant Output Fields**:
- `hookSpecificOutput.additionalContext`: Text that is **appended** to the
prompt for this turn only.
- `decision`: Set to `"deny"` to block the turn and **discard the user's
message** (it will not appear in history).
- `continue`: Set to `false` to block the turn but **save the message to
history**.
- `reason`: Required if denied or stopped.
- **Exit Code 2 (Block Turn)**: Aborts the turn and erases the prompt from
context. Same as `decision: "deny"`.
### `AfterAgent`
Fires once per turn after the model generates its final response. Primary use
case is response validation and automatic retries.
- **Input Fields**:
- `prompt`: (`string`) The user's original request.
- `prompt_response`: (`string`) The final text generated by the agent.
- `stop_hook_active`: (`boolean`) Indicates if this hook is already running as
part of a retry sequence.
- **Relevant Output Fields**:
- `decision`: Set to `"deny"` to **reject the response** and force a retry.
- `reason`: Required if denied. This text is sent **to the agent as a new
prompt** to request a correction.
- `continue`: Set to `false` to **stop the session** without retrying.
- `clearContext`: If `true`, clears conversation history (LLM memory) while
preserving UI display.
- **Exit Code 2 (Retry)**: Rejects the response and triggers an automatic retry
turn using `stderr` as the feedback prompt.
---
## Model hooks
### `BeforeModel`
Fires before sending a request to the LLM. Operates on a stable, SDK-agnostic
request format.
- **Input Fields**:
- `llm_request`: (`object`) Contains `model`, `messages`, and `config`
(generation params).
- **Relevant Output Fields**:
- `hookSpecificOutput.llm_request`: An object that **overrides** parts of the
outgoing request (e.g., changing models or temperature).
- `hookSpecificOutput.llm_response`: A **Synthetic Response** object. If
provided, the CLI skips the LLM call entirely and uses this as the response.
- `decision`: Set to `"deny"` to block the request and abort the turn.
- **Exit Code 2 (Block Turn)**: Aborts the turn and skips the LLM call. Uses
`stderr` as the error message.
### `BeforeToolSelection`
Fires before the LLM decides which tools to call. Used to filter the available
toolset or force specific tool modes.
- **Input Fields**:
- `llm_request`: (`object`) Same format as `BeforeModel`.
- **Relevant Output Fields**:
- `hookSpecificOutput.toolConfig.mode`: (`"AUTO" | "ANY" | "NONE"`)
- `"NONE"`: Disables all tools (Wins over other hooks).
- `"ANY"`: Forces at least one tool call.
- `hookSpecificOutput.toolConfig.allowedFunctionNames`: (`string[]`) Whitelist
of tool names.
- **Union Strategy**: Multiple hooks' whitelists are **combined**.
- **Limitations**: Does **not** support `decision`, `continue`, or
`systemMessage`.
### `AfterModel`
Fires immediately after an LLM response chunk is received. Used for real-time
redaction or PII filtering.
- **Input Fields**:
- `llm_request`: (`object`) The original request.
- `llm_response`: (`object`) The model's response (or a single chunk during
streaming).
- **Relevant Output Fields**:
- `hookSpecificOutput.llm_response`: An object that **replaces** the model's
response chunk.
- `decision`: Set to `"deny"` to discard the response chunk and block the
turn.
- `continue`: Set to `false` to **kill the entire agent loop** immediately.
- **Note on Streaming**: Fired for **every chunk** generated by the model.
Modifying the response only affects the current chunk.
- **Exit Code 2 (Block Response)**: Aborts the turn and discards the model's
output. Uses `stderr` as the error message.
---
## Lifecycle & system hooks
### `SessionStart`
Fires on application startup, resuming a session, or after a `/clear` command.
Used for loading initial context.
- **Input fields**:
- `source`: (`"startup" | "resume" | "clear"`)
- **Relevant output fields**:
- `hookSpecificOutput.additionalContext`: (`string`)
- **Interactive**: Injected as the first turn in history.
- **Non-interactive**: Prepended to the user's prompt.
- `systemMessage`: Shown at the start of the session.
- **Advisory only**: `continue` and `decision` fields are **ignored**. Startup
is never blocked.
### `SessionEnd`
Fires when the CLI exits or a session is cleared. Used for cleanup or final
telemetry.
- **Input Fields**:
- `reason`: (`"exit" | "clear" | "logout" | "prompt_input_exit" | "other"`)
- **Relevant Output Fields**:
- `systemMessage`: Displayed to the user during shutdown.
- **Best Effort**: The CLI **will not wait** for this hook to complete and
ignores all flow-control fields (`continue`, `decision`).
### `Notification`
Fires when the CLI emits a system alert (e.g., Tool Permissions). Used for
external logging or cross-platform alerts.
- **Input Fields**:
- `notification_type`: (`"ToolPermission"`)
- `message`: Summary of the alert.
- `details`: JSON object with alert-specific metadata (e.g., tool name, file
path).
- **Relevant Output Fields**:
- `systemMessage`: Displayed alongside the system alert.
- **Observability Only**: This hook **cannot** block alerts or grant permissions
automatically. Flow-control fields are ignored.
### `PreCompress`
Fires before the CLI summarizes history to save tokens. Used for logging or
state saving.
- **Input Fields**:
- `trigger`: (`"auto" | "manual"`)
- **Relevant Output Fields**:
- `systemMessage`: Displayed to the user before compression.
- **Advisory Only**: Fired asynchronously. It **cannot** block or modify the
compression process. Flow-control fields are ignored.
---
-4
View File
@@ -80,10 +80,6 @@
"label": "Model selection",
"slug": "docs/cli/model"
},
{
"label": "Rewind",
"slug": "docs/cli/rewind"
},
{
"label": "Sandbox",
"slug": "docs/cli/sandbox"
+10
View File
@@ -304,6 +304,16 @@ export default tseslint.config(
'@typescript-eslint/no-require-imports': 'off',
},
},
// Examples should have access to standard globals like fetch
{
files: ['packages/cli/src/commands/extensions/examples/**/*.js'],
languageOptions: {
globals: {
...globals.node,
fetch: 'readonly',
},
},
},
// extra settings for scripts that we run directly with node
{
files: ['packages/vscode-ide-companion/scripts/**/*.js'],
+48
View File
@@ -0,0 +1,48 @@
/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import { describe, expect } from 'vitest';
import { evalTest } from './test-helper.js';
import path from 'node:path';
import fs from 'node:fs/promises';
describe('generalist_agent', () => {
evalTest('ALWAYS_PASSES', {
name: 'should be able to use generalist agent by explicitly asking the main agent to invoke it',
params: {
settings: {
agents: {
overrides: {
generalist: { enabled: true },
},
},
},
},
prompt:
'Please use the generalist agent to create a file called "generalist_test_file.txt" containing exactly the following text: success',
assert: async (rig) => {
// 1) Verify the generalist agent was invoked via delegate_to_agent
const foundToolCall = await rig.waitForToolCall(
'delegate_to_agent',
undefined,
(args) => {
const parsed = JSON.parse(args);
return parsed.agent_name === 'generalist';
},
);
expect(
foundToolCall,
'Expected to find a delegate_to_agent tool call for generalist agent',
).toBeTruthy();
// 2) Verify the file was created as expected
const filePath = path.join(rig.testDir!, 'generalist_test_file.txt');
const content = await fs.readFile(filePath, 'utf-8');
expect(content.trim()).toBe('success');
},
});
});
+1 -1
View File
@@ -31,7 +31,7 @@ describe('subagent eval test cases', () => {
*
* This tests the system prompt's subagent specific clauses.
*/
evalTest('ALWAYS_PASSES', {
evalTest('USUALLY_PASSES', {
name: 'should delegate to user provided agent with relevant expertise',
params: {
settings: {
+87 -3
View File
@@ -53,8 +53,10 @@ describe('Hooks Agent Flow', () => {
await rig.setup('should inject additional context via BeforeAgent hook', {
settings: {
hooks: {
hooksConfig: {
enabled: true,
},
hooks: {
BeforeAgent: [
{
hooks: [
@@ -116,8 +118,10 @@ describe('Hooks Agent Flow', () => {
await rig.setup('should receive prompt and response in AfterAgent hook', {
settings: {
hooks: {
hooksConfig: {
enabled: true,
},
hooks: {
AfterAgent: [
{
hooks: [
@@ -151,6 +155,84 @@ describe('Hooks Agent Flow', () => {
// The fake response contains "Hello World"
expect(afterAgentLog?.hookCall.stdout).toContain('Hello World');
});
it('should process clearContext in AfterAgent hook output', async () => {
await rig.setup('should process clearContext in AfterAgent hook output', {
fakeResponsesPath: join(
import.meta.dirname,
'hooks-system.after-agent.responses',
),
});
// BeforeModel hook to track message counts across LLM calls
const messageCountFile = join(rig.testDir!, 'message-counts.json');
const beforeModelScript = `
const fs = require('fs');
const input = JSON.parse(fs.readFileSync(0, 'utf-8'));
const messageCount = input.llm_request?.contents?.length || 0;
let counts = [];
try { counts = JSON.parse(fs.readFileSync('${messageCountFile}', 'utf-8')); } catch (e) {}
counts.push(messageCount);
fs.writeFileSync('${messageCountFile}', JSON.stringify(counts));
console.log(JSON.stringify({ decision: 'allow' }));
`;
const beforeModelScriptPath = join(
rig.testDir!,
'before_model_counter.cjs',
);
writeFileSync(beforeModelScriptPath, beforeModelScript);
await rig.setup('should process clearContext in AfterAgent hook output', {
settings: {
hooks: {
enabled: true,
BeforeModel: [
{
hooks: [
{
type: 'command',
command: `node "${beforeModelScriptPath}"`,
timeout: 5000,
},
],
},
],
AfterAgent: [
{
hooks: [
{
type: 'command',
command: `node -e "console.log(JSON.stringify({decision: 'block', reason: 'Security policy triggered', hookSpecificOutput: {hookEventName: 'AfterAgent', clearContext: true}}))"`,
timeout: 5000,
},
],
},
],
},
},
});
const result = await rig.run({ args: 'Hello test' });
const hookTelemetryFound = await rig.waitForTelemetryEvent('hook_call');
expect(hookTelemetryFound).toBeTruthy();
const hookLogs = rig.readHookLogs();
const afterAgentLog = hookLogs.find(
(log) => log.hookCall.hook_event_name === 'AfterAgent',
);
expect(afterAgentLog).toBeDefined();
expect(afterAgentLog?.hookCall.stdout).toContain('clearContext');
expect(afterAgentLog?.hookCall.stdout).toContain('true');
expect(result).toContain('Security policy triggered');
// Verify context was cleared: second call should not have more messages than first
const countsRaw = rig.readFile('message-counts.json');
const counts = JSON.parse(countsRaw) as number[];
expect(counts.length).toBeGreaterThanOrEqual(2);
expect(counts[1]).toBeLessThanOrEqual(counts[0]);
});
});
describe('Multi-step Loops', () => {
@@ -163,8 +245,10 @@ describe('Hooks Agent Flow', () => {
'hooks-agent-flow-multistep.responses',
),
settings: {
hooks: {
hooksConfig: {
enabled: true,
},
hooks: {
BeforeAgent: [
{
hooks: [
+364 -140
View File
@@ -24,7 +24,7 @@ describe('Hooks System Integration', () => {
describe('Command Hooks - Blocking Behavior', () => {
it('should block tool execution when hook returns block decision', async () => {
await rig.setup(
rig.setup(
'should block tool execution when hook returns block decision',
{
fakeResponsesPath: join(
@@ -32,8 +32,10 @@ describe('Hooks System Integration', () => {
'hooks-system.block-tool.responses',
),
settings: {
hooks: {
hooksConfig: {
enabled: true,
},
hooks: {
BeforeTool: [
{
matcher: 'write_file',
@@ -75,8 +77,67 @@ describe('Hooks System Integration', () => {
expect(hookTelemetryFound).toBeTruthy();
});
it('should block tool execution and use stderr as reason when hook exits with code 2', async () => {
rig.setup(
'should block tool execution and use stderr as reason when hook exits with code 2',
{
fakeResponsesPath: join(
import.meta.dirname,
'hooks-system.block-tool.responses',
),
settings: {
hooksConfig: {
enabled: true,
},
hooks: {
BeforeTool: [
{
matcher: 'write_file',
hooks: [
{
type: 'command',
// Exit with code 2 and write reason to stderr
command:
'node -e "process.stderr.write(\'File writing blocked by security policy\'); process.exit(2)"',
timeout: 5000,
},
],
},
],
},
},
},
);
const result = await rig.run({
args: 'Create a file called test.txt with content "Hello World"',
});
// The hook should block the write_file tool
const toolLogs = rig.readToolLogs();
const writeFileCalls = toolLogs.filter(
(t) =>
t.toolRequest.name === 'write_file' && t.toolRequest.success === true,
);
// Tool should not be called due to blocking hook
expect(writeFileCalls).toHaveLength(0);
// Result should mention the blocking reason from stderr
expect(result).toContain('File writing blocked by security policy');
// Verify hook telemetry shows exit code 2 and stderr
const hookLogs = rig.readHookLogs();
const blockHook = hookLogs.find((log) => log.hookCall.exit_code === 2);
expect(blockHook).toBeDefined();
expect(blockHook?.hookCall.stderr).toContain(
'File writing blocked by security policy',
);
expect(blockHook?.hookCall.success).toBe(false);
});
it('should allow tool execution when hook returns allow decision', async () => {
await rig.setup(
rig.setup(
'should allow tool execution when hook returns allow decision',
{
fakeResponsesPath: join(
@@ -84,8 +145,10 @@ describe('Hooks System Integration', () => {
'hooks-system.allow-tool.responses',
),
settings: {
hooks: {
hooksConfig: {
enabled: true,
},
hooks: {
BeforeTool: [
{
matcher: 'write_file',
@@ -126,14 +189,16 @@ describe('Hooks System Integration', () => {
it('should add additional context from AfterTool hooks', async () => {
const command =
"node -e \"console.log(JSON.stringify({hookSpecificOutput: {hookEventName: 'AfterTool', additionalContext: 'Security scan: File content appears safe'}}))\"";
await rig.setup('should add additional context from AfterTool hooks', {
rig.setup('should add additional context from AfterTool hooks', {
fakeResponsesPath: join(
import.meta.dirname,
'hooks-system.after-tool-context.responses',
),
settings: {
hooks: {
hooksConfig: {
enabled: true,
},
hooks: {
AfterTool: [
{
matcher: 'read_file',
@@ -178,7 +243,7 @@ describe('Hooks System Integration', () => {
it('should modify LLM requests with BeforeModel hooks', async () => {
// Create a hook script that replaces the LLM request with a modified version
// Note: Providing messages in the hook output REPLACES the entire conversation
await rig.setup('should modify LLM requests with BeforeModel hooks', {
rig.setup('should modify LLM requests with BeforeModel hooks', {
fakeResponsesPath: join(
import.meta.dirname,
'hooks-system.before-model.responses',
@@ -203,10 +268,12 @@ console.log(JSON.stringify({
const scriptPath = join(rig.testDir!, 'before_model_hook.cjs');
writeFileSync(scriptPath, hookScript);
await rig.setup('should modify LLM requests with BeforeModel hooks', {
rig.setup('should modify LLM requests with BeforeModel hooks', {
settings: {
hooks: {
hooksConfig: {
enabled: true,
},
hooks: {
BeforeModel: [
{
hooks: [
@@ -248,13 +315,103 @@ console.log(JSON.stringify({
expect(hookTelemetryFound[0].hookCall.stdout).toBeDefined();
expect(hookTelemetryFound[0].hookCall.stderr).toBeDefined();
});
it('should block model execution when BeforeModel hook returns deny decision', async () => {
rig.setup(
'should block model execution when BeforeModel hook returns deny decision',
);
const hookScript = `console.log(JSON.stringify({
decision: "deny",
reason: "Model execution blocked by security policy"
}));`;
const scriptPath = join(rig.testDir!, 'before_model_deny_hook.cjs');
writeFileSync(scriptPath, hookScript);
rig.setup(
'should block model execution when BeforeModel hook returns deny decision',
{
settings: {
hooksConfig: {
enabled: true,
},
hooks: {
BeforeModel: [
{
hooks: [
{
type: 'command',
command: `node "${scriptPath}"`,
timeout: 5000,
},
],
},
],
},
},
},
);
const result = await rig.run({ args: 'Hello' });
// The hook should have blocked the request
expect(result).toContain('Model execution blocked by security policy');
// Verify no API requests were made to the LLM
const apiRequests = rig.readAllApiRequest();
expect(apiRequests).toHaveLength(0);
});
it('should block model execution when BeforeModel hook returns block decision', async () => {
rig.setup(
'should block model execution when BeforeModel hook returns block decision',
);
const hookScript = `console.log(JSON.stringify({
decision: "block",
reason: "Model execution blocked by security policy"
}));`;
const scriptPath = join(rig.testDir!, 'before_model_block_hook.cjs');
writeFileSync(scriptPath, hookScript);
rig.setup(
'should block model execution when BeforeModel hook returns block decision',
{
settings: {
hooksConfig: {
enabled: true,
},
hooks: {
BeforeModel: [
{
hooks: [
{
type: 'command',
command: `node "${scriptPath}"`,
timeout: 5000,
},
],
},
],
},
},
},
);
const result = await rig.run({ args: 'Hello' });
// The hook should have blocked the request
expect(result).toContain('Model execution blocked by security policy');
// Verify no API requests were made to the LLM
const apiRequests = rig.readAllApiRequest();
expect(apiRequests).toHaveLength(0);
});
});
describe('AfterModel Hooks - LLM Response Modification', () => {
it.skipIf(process.platform === 'win32')(
'should modify LLM responses with AfterModel hooks',
async () => {
await rig.setup('should modify LLM responses with AfterModel hooks', {
rig.setup('should modify LLM responses with AfterModel hooks', {
fakeResponsesPath: join(
import.meta.dirname,
'hooks-system.after-model.responses',
@@ -284,10 +441,12 @@ console.log(JSON.stringify({
const scriptPath = join(rig.testDir!, 'after_model_hook.cjs');
writeFileSync(scriptPath, hookScript);
await rig.setup('should modify LLM responses with AfterModel hooks', {
rig.setup('should modify LLM responses with AfterModel hooks', {
settings: {
hooks: {
hooksConfig: {
enabled: true,
},
hooks: {
AfterModel: [
{
hooks: [
@@ -319,41 +478,37 @@ console.log(JSON.stringify({
describe('BeforeToolSelection Hooks - Tool Configuration', () => {
it('should modify tool selection with BeforeToolSelection hooks', async () => {
await rig.setup(
'should modify tool selection with BeforeToolSelection hooks',
{
fakeResponsesPath: join(
import.meta.dirname,
'hooks-system.before-tool-selection.responses',
),
},
);
rig.setup('should modify tool selection with BeforeToolSelection hooks', {
fakeResponsesPath: join(
import.meta.dirname,
'hooks-system.before-tool-selection.responses',
),
});
// Create inline hook command (works on both Unix and Windows)
const hookCommand =
"node -e \"console.log(JSON.stringify({hookSpecificOutput: {hookEventName: 'BeforeToolSelection', toolConfig: {mode: 'ANY', allowedFunctionNames: ['read_file', 'run_shell_command']}}}))\"";
await rig.setup(
'should modify tool selection with BeforeToolSelection hooks',
{
settings: {
debugMode: true,
hooks: {
enabled: true,
BeforeToolSelection: [
{
hooks: [
{
type: 'command',
command: hookCommand,
timeout: 5000,
},
],
},
],
},
rig.setup('should modify tool selection with BeforeToolSelection hooks', {
settings: {
debugMode: true,
hooksConfig: {
enabled: true,
},
hooks: {
BeforeToolSelection: [
{
hooks: [
{
type: 'command',
command: hookCommand,
timeout: 5000,
},
],
},
],
},
},
);
});
// Create a test file
rig.createFile('new_file_data.txt', 'test data');
@@ -382,7 +537,7 @@ console.log(JSON.stringify({
describe('BeforeAgent Hooks - Prompt Augmentation', () => {
it('should augment prompts with BeforeAgent hooks', async () => {
await rig.setup('should augment prompts with BeforeAgent hooks', {
rig.setup('should augment prompts with BeforeAgent hooks', {
fakeResponsesPath: join(
import.meta.dirname,
'hooks-system.before-agent.responses',
@@ -401,10 +556,12 @@ console.log(JSON.stringify({
const scriptPath = join(rig.testDir!, 'before_agent_hook.cjs');
writeFileSync(scriptPath, hookScript);
await rig.setup('should augment prompts with BeforeAgent hooks', {
rig.setup('should augment prompts with BeforeAgent hooks', {
settings: {
hooks: {
hooksConfig: {
enabled: true,
},
hooks: {
BeforeAgent: [
{
hooks: [
@@ -438,7 +595,7 @@ console.log(JSON.stringify({
const hookCommand =
'node -e "console.log(JSON.stringify({suppressOutput: false, systemMessage: \'Permission request logged by security hook\'}))"';
await rig.setup('should handle notification hooks for tool permissions', {
rig.setup('should handle notification hooks for tool permissions', {
fakeResponsesPath: join(
import.meta.dirname,
'hooks-system.notification.responses',
@@ -449,8 +606,10 @@ console.log(JSON.stringify({
approval: 'ASK', // Disable YOLO mode to show permission prompts
confirmationRequired: ['run_shell_command'],
},
hooks: {
hooksConfig: {
enabled: true,
},
hooks: {
Notification: [
{
matcher: 'ToolPermission',
@@ -534,14 +693,16 @@ console.log(JSON.stringify({
const hook2Command =
"node -e \"console.log(JSON.stringify({decision: 'allow', hookSpecificOutput: {hookEventName: 'BeforeAgent', additionalContext: 'Step 2: Security check completed.'}}))\"";
await rig.setup('should execute hooks sequentially when configured', {
rig.setup('should execute hooks sequentially when configured', {
fakeResponsesPath: join(
import.meta.dirname,
'hooks-system.sequential-execution.responses',
),
settings: {
hooks: {
hooksConfig: {
enabled: true,
},
hooks: {
BeforeAgent: [
{
sequential: true,
@@ -594,7 +755,7 @@ console.log(JSON.stringify({
describe('Hook Input/Output Validation', () => {
it('should provide correct input format to hooks', async () => {
await rig.setup('should provide correct input format to hooks', {
rig.setup('should provide correct input format to hooks', {
fakeResponsesPath: join(
import.meta.dirname,
'hooks-system.input-validation.responses',
@@ -618,10 +779,12 @@ try {
const scriptPath = join(rig.testDir!, 'input_validation_hook.cjs');
writeFileSync(scriptPath, hookScript);
await rig.setup('should provide correct input format to hooks', {
rig.setup('should provide correct input format to hooks', {
settings: {
hooks: {
hooksConfig: {
enabled: true,
},
hooks: {
BeforeTool: [
{
hooks: [
@@ -653,6 +816,52 @@ try {
const hookTelemetryFound = await rig.waitForTelemetryEvent('hook_call');
expect(hookTelemetryFound).toBeTruthy();
});
it('should treat mixed stdout (text + JSON) as system message and allow execution when exit code is 0', async () => {
rig.setup(
'should treat mixed stdout (text + JSON) as system message and allow execution when exit code is 0',
{
fakeResponsesPath: join(
import.meta.dirname,
'hooks-system.allow-tool.responses',
),
settings: {
hooksConfig: {
enabled: true,
},
hooks: {
BeforeTool: [
{
matcher: 'write_file',
hooks: [
{
type: 'command',
// Output plain text then JSON.
// This breaks JSON parsing, so it falls back to 'allow' with the whole stdout as systemMessage.
command:
"node -e \"console.log('Pollution'); console.log(JSON.stringify({decision: 'deny', reason: 'Should be ignored'}))\"",
timeout: 5000,
},
],
},
],
},
},
},
);
const result = await rig.run({
args: 'Create a file called approved.txt with content "Approved content"',
});
// The hook logic fails to parse JSON, so it allows the tool.
const foundWriteFile = await rig.waitForToolCall('write_file');
expect(foundWriteFile).toBeTruthy();
// The entire stdout (including the JSON part) becomes the systemMessage
expect(result).toContain('Pollution');
expect(result).toContain('Should be ignored');
});
});
describe('Multiple Event Types', () => {
@@ -665,14 +874,16 @@ try {
const beforeAgentCommand =
"node -e \"console.log(JSON.stringify({decision: 'allow', hookSpecificOutput: {hookEventName: 'BeforeAgent', additionalContext: 'BeforeAgent: User request processed'}}))\"";
await rig.setup('should handle hooks for all major event types', {
rig.setup('should handle hooks for all major event types', {
fakeResponsesPath: join(
import.meta.dirname,
'hooks-system.multiple-events.responses',
),
settings: {
hooks: {
hooksConfig: {
enabled: true,
},
hooks: {
BeforeAgent: [
{
hooks: [
@@ -768,7 +979,7 @@ try {
describe('Hook Error Handling', () => {
it('should handle hook failures gracefully', async () => {
await rig.setup('should handle hook failures gracefully', {
rig.setup('should handle hook failures gracefully', {
fakeResponsesPath: join(
import.meta.dirname,
'hooks-system.error-handling.responses',
@@ -782,10 +993,12 @@ try {
const workingCommand =
"node -e \"console.log(JSON.stringify({decision: 'allow', reason: 'Working hook succeeded'}))\"";
await rig.setup('should handle hook failures gracefully', {
rig.setup('should handle hook failures gracefully', {
settings: {
hooks: {
hooksConfig: {
enabled: true,
},
hooks: {
BeforeTool: [
{
hooks: [
@@ -830,14 +1043,16 @@ try {
const hookCommand =
"node -e \"console.log(JSON.stringify({decision: 'allow', reason: 'Telemetry test hook'}))\"";
await rig.setup('should generate telemetry events for hook executions', {
rig.setup('should generate telemetry events for hook executions', {
fakeResponsesPath: join(
import.meta.dirname,
'hooks-system.telemetry.responses',
),
settings: {
hooks: {
hooksConfig: {
enabled: true,
},
hooks: {
BeforeTool: [
{
hooks: [
@@ -871,14 +1086,16 @@ try {
const sessionStartCommand =
"node -e \"console.log(JSON.stringify({decision: 'allow', systemMessage: 'Session starting on startup'}))\"";
await rig.setup('should fire SessionStart hook on app startup', {
rig.setup('should fire SessionStart hook on app startup', {
fakeResponsesPath: join(
import.meta.dirname,
'hooks-system.session-startup.responses',
),
settings: {
hooks: {
hooksConfig: {
enabled: true,
},
hooks: {
SessionStart: [
{
matcher: 'startup',
@@ -936,7 +1153,7 @@ console.log(JSON.stringify({
}
}));`;
await rig.setup('should fire SessionStart hook and inject context', {
rig.setup('should fire SessionStart hook and inject context', {
fakeResponsesPath: join(
import.meta.dirname,
'hooks-system.session-startup.responses',
@@ -946,10 +1163,12 @@ console.log(JSON.stringify({
const scriptPath = join(rig.testDir!, 'session_start_context_hook.cjs');
writeFileSync(scriptPath, hookScript);
await rig.setup('should fire SessionStart hook and inject context', {
rig.setup('should fire SessionStart hook and inject context', {
settings: {
hooks: {
hooksConfig: {
enabled: true,
},
hooks: {
SessionStart: [
{
matcher: 'startup',
@@ -1011,7 +1230,7 @@ console.log(JSON.stringify({
}
}));`;
await rig.setup(
rig.setup(
'should fire SessionStart hook and display systemMessage in interactive mode',
{
fakeResponsesPath: join(
@@ -1027,12 +1246,14 @@ console.log(JSON.stringify({
);
writeFileSync(scriptPath, hookScript);
await rig.setup(
rig.setup(
'should fire SessionStart hook and display systemMessage in interactive mode',
{
settings: {
hooks: {
hooksConfig: {
enabled: true,
},
hooks: {
SessionStart: [
{
matcher: 'startup',
@@ -1091,7 +1312,7 @@ console.log(JSON.stringify({
const sessionStartCommand =
"node -e \"console.log(JSON.stringify({decision: 'allow', systemMessage: 'Session starting after clear'}))\"";
await rig.setup(
rig.setup(
'should fire SessionEnd and SessionStart hooks on /clear command',
{
fakeResponsesPath: join(
@@ -1099,8 +1320,10 @@ console.log(JSON.stringify({
'hooks-system.session-clear.responses',
),
settings: {
hooks: {
hooksConfig: {
enabled: true,
},
hooks: {
SessionEnd: [
{
matcher: '*',
@@ -1265,14 +1488,16 @@ console.log(JSON.stringify({
const preCompressCommand =
"node -e \"console.log(JSON.stringify({decision: 'allow', systemMessage: 'PreCompress hook executed for automatic compression'}))\"";
await rig.setup('should fire PreCompress hook on automatic compression', {
rig.setup('should fire PreCompress hook on automatic compression', {
fakeResponsesPath: join(
import.meta.dirname,
'hooks-system.compress-auto.responses',
),
settings: {
hooks: {
hooksConfig: {
enabled: true,
},
hooks: {
PreCompress: [
{
matcher: 'auto',
@@ -1330,14 +1555,16 @@ console.log(JSON.stringify({
const sessionEndCommand =
"node -e \"console.log(JSON.stringify({decision: 'allow', systemMessage: 'SessionEnd hook executed on exit'}))\"";
await rig.setup('should fire SessionEnd hook on graceful exit', {
rig.setup('should fire SessionEnd hook on graceful exit', {
fakeResponsesPath: join(
import.meta.dirname,
'hooks-system.session-startup.responses',
),
settings: {
hooks: {
hooksConfig: {
enabled: true,
},
hooks: {
SessionEnd: [
{
matcher: 'exit',
@@ -1412,7 +1639,7 @@ console.log(JSON.stringify({
describe('Hook Disabling', () => {
it('should not execute hooks disabled in settings file', async () => {
await rig.setup('should not execute hooks disabled in settings file', {
rig.setup('should not execute hooks disabled in settings file', {
fakeResponsesPath: join(
import.meta.dirname,
'hooks-system.disabled-via-settings.responses',
@@ -1432,10 +1659,13 @@ console.log(JSON.stringify({decision: "block", systemMessage: "Disabled hook sho
writeFileSync(enabledPath, enabledHookScript);
writeFileSync(disabledPath, disabledHookScript);
await rig.setup('should not execute hooks disabled in settings file', {
rig.setup('should not execute hooks disabled in settings file', {
settings: {
hooks: {
hooksConfig: {
enabled: true,
disabled: [`node "${disabledPath}"`], // Disable the second hook
},
hooks: {
BeforeTool: [
{
hooks: [
@@ -1452,7 +1682,6 @@ console.log(JSON.stringify({decision: "block", systemMessage: "Disabled hook sho
],
},
],
disabled: [`node "${disabledPath}"`], // Disable the second hook
},
},
});
@@ -1487,15 +1716,12 @@ console.log(JSON.stringify({decision: "block", systemMessage: "Disabled hook sho
});
it('should respect disabled hooks across multiple operations', async () => {
await rig.setup(
'should respect disabled hooks across multiple operations',
{
fakeResponsesPath: join(
import.meta.dirname,
'hooks-system.disabled-via-command.responses',
),
},
);
rig.setup('should respect disabled hooks across multiple operations', {
fakeResponsesPath: join(
import.meta.dirname,
'hooks-system.disabled-via-command.responses',
),
});
// Create two hook scripts - one that will be disabled, one that won't
const activeHookScript = `const fs = require('fs');
@@ -1510,33 +1736,32 @@ console.log(JSON.stringify({decision: "block", systemMessage: "Disabled hook sho
writeFileSync(activePath, activeHookScript);
writeFileSync(disabledPath, disabledHookScript);
await rig.setup(
'should respect disabled hooks across multiple operations',
{
settings: {
hooks: {
enabled: true,
BeforeTool: [
{
hooks: [
{
type: 'command',
command: `node "${activePath}"`,
timeout: 5000,
},
{
type: 'command',
command: `node "${disabledPath}"`,
timeout: 5000,
},
],
},
],
disabled: [`node "${disabledPath}"`], // Disable the second hook
},
rig.setup('should respect disabled hooks across multiple operations', {
settings: {
hooksConfig: {
enabled: true,
disabled: [`node "${disabledPath}"`], // Disable the second hook,
},
hooks: {
BeforeTool: [
{
hooks: [
{
type: 'command',
command: `node "${activePath}"`,
timeout: 5000,
},
{
type: 'command',
command: `node "${disabledPath}"`,
timeout: 5000,
},
],
},
],
},
},
);
});
// First run - only active hook should execute
const result1 = await rig.run({
@@ -1587,9 +1812,7 @@ console.log(JSON.stringify({decision: "block", systemMessage: "Disabled hook sho
describe('BeforeTool Hooks - Input Override', () => {
it('should override tool input parameters via BeforeTool hook', async () => {
// 1. First setup to get the test directory and prepare the hook script
await rig.setup(
'should override tool input parameters via BeforeTool hook',
);
rig.setup('should override tool input parameters via BeforeTool hook');
// Create a hook script that overrides the tool input
const hookOutput = {
@@ -1616,32 +1839,31 @@ console.log(JSON.stringify({decision: "block", systemMessage: "Disabled hook sho
const commandPath = scriptPath.replace(/\\/g, '/');
// 2. Full setup with settings and fake responses
await rig.setup(
'should override tool input parameters via BeforeTool hook',
{
fakeResponsesPath: join(
import.meta.dirname,
'hooks-system.input-modification.responses',
),
settings: {
hooks: {
enabled: true,
BeforeTool: [
{
matcher: 'write_file',
hooks: [
{
type: 'command',
command: `node "${commandPath}"`,
timeout: 5000,
},
],
},
],
},
rig.setup('should override tool input parameters via BeforeTool hook', {
fakeResponsesPath: join(
import.meta.dirname,
'hooks-system.input-modification.responses',
),
settings: {
hooksConfig: {
enabled: true,
},
hooks: {
BeforeTool: [
{
matcher: 'write_file',
hooks: [
{
type: 'command',
command: `node "${commandPath}"`,
timeout: 5000,
},
],
},
],
},
},
);
});
// Run the agent. The fake response will attempt to call write_file with
// file_path="original.txt" and content="original content"
@@ -1698,19 +1920,21 @@ console.log(JSON.stringify({decision: "block", systemMessage: "Disabled hook sho
hookOutput,
)}));`;
await rig.setup('should stop agent execution via BeforeTool hook');
rig.setup('should stop agent execution via BeforeTool hook');
const scriptPath = join(rig.testDir!, 'before_tool_stop_hook.js');
writeFileSync(scriptPath, hookScript);
const commandPath = scriptPath.replace(/\\/g, '/');
await rig.setup('should stop agent execution via BeforeTool hook', {
rig.setup('should stop agent execution via BeforeTool hook', {
fakeResponsesPath: join(
import.meta.dirname,
'hooks-system.before-tool-stop.responses',
),
settings: {
hooks: {
hooksConfig: {
enabled: true,
},
hooks: {
BeforeTool: [
{
matcher: 'write_file',
+22 -33
View File
@@ -1,12 +1,12 @@
{
"name": "@google/gemini-cli",
"version": "0.26.0-nightly.20260115.6cb3ae4e0",
"version": "0.26.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "@google/gemini-cli",
"version": "0.26.0-nightly.20260115.6cb3ae4e0",
"version": "0.26.0",
"workspaces": [
"packages/*"
],
@@ -2474,7 +2474,6 @@
"integrity": "sha512-t54CUOsFMappY1Jbzb7fetWeO0n6K0k/4+/ZpkS+3Joz8I4VcvY9OiEBFRYISqaI2fq5sCiPtAjRDOzVYG8m+Q==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@octokit/auth-token": "^6.0.0",
"@octokit/graphql": "^9.0.2",
@@ -2655,7 +2654,6 @@
"resolved": "https://registry.npmjs.org/@opentelemetry/api/-/api-1.9.0.tgz",
"integrity": "sha512-3giAOQvZiH5F9bMlMiv8+GSPMeqg0dbaeo58/0SlA9sxSqZhnUtxzX9/2FzyhS9sWQf5S0GJE0AKBrFqjpeYcg==",
"license": "Apache-2.0",
"peer": true,
"engines": {
"node": ">=8.0.0"
}
@@ -2689,7 +2687,6 @@
"resolved": "https://registry.npmjs.org/@opentelemetry/core/-/core-2.0.1.tgz",
"integrity": "sha512-MaZk9SJIDgo1peKevlbhP6+IwIiNPNmswNL4AF0WaQJLbHXjr9SrZMgS12+iqr9ToV4ZVosCcc0f8Rg67LXjxw==",
"license": "Apache-2.0",
"peer": true,
"dependencies": {
"@opentelemetry/semantic-conventions": "^1.29.0"
},
@@ -3058,7 +3055,6 @@
"resolved": "https://registry.npmjs.org/@opentelemetry/resources/-/resources-2.0.1.tgz",
"integrity": "sha512-dZOB3R6zvBwDKnHDTB4X1xtMArB/d324VsbiPkX/Yu0Q8T2xceRthoIVFhJdvgVM2QhGVUyX9tzwiNxGtoBJUw==",
"license": "Apache-2.0",
"peer": true,
"dependencies": {
"@opentelemetry/core": "2.0.1",
"@opentelemetry/semantic-conventions": "^1.29.0"
@@ -3092,7 +3088,6 @@
"resolved": "https://registry.npmjs.org/@opentelemetry/sdk-metrics/-/sdk-metrics-2.0.1.tgz",
"integrity": "sha512-wf8OaJoSnujMAHWR3g+/hGvNcsC16rf9s1So4JlMiFaFHiE4HpIA3oUh+uWZQ7CNuK8gVW/pQSkgoa5HkkOl0g==",
"license": "Apache-2.0",
"peer": true,
"dependencies": {
"@opentelemetry/core": "2.0.1",
"@opentelemetry/resources": "2.0.1"
@@ -3145,7 +3140,6 @@
"resolved": "https://registry.npmjs.org/@opentelemetry/sdk-trace-base/-/sdk-trace-base-2.0.1.tgz",
"integrity": "sha512-xYLlvk/xdScGx1aEqvxLwf6sXQLXCjk3/1SQT9X9AoN5rXRhkdvIFShuNNmtTEPRBqcsMbS4p/gJLNI2wXaDuQ==",
"license": "Apache-2.0",
"peer": true,
"dependencies": {
"@opentelemetry/core": "2.0.1",
"@opentelemetry/resources": "2.0.1",
@@ -4358,7 +4352,6 @@
"integrity": "sha512-6mDvHUFSjyT2B2yeNx2nUgMxh9LtOWvkhIU3uePn2I2oyNymUAX1NIsdgviM4CH+JSrp2D2hsMvJOkxY+0wNRA==",
"devOptional": true,
"license": "MIT",
"peer": true,
"dependencies": {
"csstype": "^3.0.2"
}
@@ -4636,7 +4629,6 @@
"integrity": "sha512-6sMvZePQrnZH2/cJkwRpkT7DxoAWh+g6+GFRK6bV3YQo7ogi3SX5rgF6099r5Q53Ma5qeT7LGmOmuIutF4t3lA==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@typescript-eslint/scope-manager": "8.35.0",
"@typescript-eslint/types": "8.35.0",
@@ -5641,7 +5633,6 @@
"resolved": "https://registry.npmjs.org/acorn/-/acorn-8.15.0.tgz",
"integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==",
"license": "MIT",
"peer": true,
"bin": {
"acorn": "bin/acorn"
},
@@ -6086,7 +6077,8 @@
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/array-flatten/-/array-flatten-1.1.1.tgz",
"integrity": "sha512-PCVAQswWemu6UdxsDFFX/+gVeYqKAod3D3UVm91jHwynguOwAvYPhx8nNlM++NqRcK6CxxpUafjmhIdKiHibqg==",
"license": "MIT"
"license": "MIT",
"peer": true
},
"node_modules/array-includes": {
"version": "3.1.9",
@@ -7370,6 +7362,7 @@
"resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-0.5.4.tgz",
"integrity": "sha512-FveZTNuGw04cxlAiWbzi6zTAL/lhehaWbTtgluJh4/E95DqMwTmha3KZN1aAWA8cFIhHzMZUvLevkw5Rqk+tSQ==",
"license": "MIT",
"peer": true,
"dependencies": {
"safe-buffer": "5.2.1"
},
@@ -8689,7 +8682,6 @@
"integrity": "sha512-GsGizj2Y1rCWDu6XoEekL3RLilp0voSePurjZIkxL3wlm5o5EC9VpgaP7lrCvjnkuLvzFBQWB3vWB3K5KQTveQ==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@eslint-community/eslint-utils": "^4.2.0",
"@eslint-community/regexpp": "^4.12.1",
@@ -9292,6 +9284,7 @@
"resolved": "https://registry.npmjs.org/cookie/-/cookie-0.7.1.tgz",
"integrity": "sha512-6DnInpx7SJ2AK3+CTUE/ZM0vWTUboZCegxhC2xiIydHR9jNuTAASBrfEpHhiGOZw/nX51bHt6YQl8jsGo4y/0w==",
"license": "MIT",
"peer": true,
"engines": {
"node": ">= 0.6"
}
@@ -9301,6 +9294,7 @@
"resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz",
"integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==",
"license": "MIT",
"peer": true,
"dependencies": {
"ms": "2.0.0"
}
@@ -9310,6 +9304,7 @@
"resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.1.tgz",
"integrity": "sha512-RwNA9Z/7PrK06rYLIzFMlaF+l73iwpzsqRIFgbMLbTcLD6cOao82TaWefPXQvB2fOC4AjuYSEndS7N/mTCbkdQ==",
"license": "MIT",
"peer": true,
"engines": {
"node": ">= 0.8"
}
@@ -9563,6 +9558,7 @@
"resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-1.3.1.tgz",
"integrity": "sha512-6BN9trH7bp3qvnrRyzsBz+g3lZxTNZTbVO2EV1CS0WIcDbawYVdYvGflME/9QP0h0pYlCDBCTjYa9nZzMDpyxQ==",
"license": "MIT",
"peer": true,
"dependencies": {
"debug": "2.6.9",
"encodeurl": "~2.0.0",
@@ -9581,6 +9577,7 @@
"resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz",
"integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==",
"license": "MIT",
"peer": true,
"dependencies": {
"ms": "2.0.0"
}
@@ -9589,13 +9586,15 @@
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz",
"integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==",
"license": "MIT"
"license": "MIT",
"peer": true
},
"node_modules/finalhandler/node_modules/statuses": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.1.tgz",
"integrity": "sha512-RwNA9Z/7PrK06rYLIzFMlaF+l73iwpzsqRIFgbMLbTcLD6cOao82TaWefPXQvB2fOC4AjuYSEndS7N/mTCbkdQ==",
"license": "MIT",
"peer": true,
"engines": {
"node": ">= 0.8"
}
@@ -10878,7 +10877,6 @@
"resolved": "https://registry.npmjs.org/@jrichman/ink/-/ink-6.4.7.tgz",
"integrity": "sha512-QHyxhNF5VonF5cRmdAJD/UPucB9nRx3FozWMjQrDGfBxfAL9lpyu72/MlFPgloS1TMTGsOt7YN6dTPPA6mh0Aw==",
"license": "MIT",
"peer": true,
"dependencies": {
"@alcalzone/ansi-tokenize": "^0.2.1",
"ansi-escapes": "^7.0.0",
@@ -14063,7 +14061,8 @@
"version": "0.1.12",
"resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-0.1.12.tgz",
"integrity": "sha512-RA1GjUVMnvYFxuqovrEqZoxxW5NUZqbwKtYz/Tt7nXerk0LbLblQmrsgdeOxV5SFHf0UDggjS/bSeOZwt1pmEQ==",
"license": "MIT"
"license": "MIT",
"peer": true
},
"node_modules/path-type": {
"version": "3.0.0",
@@ -14640,7 +14639,6 @@
"resolved": "https://registry.npmjs.org/react/-/react-19.2.0.tgz",
"integrity": "sha512-tmbWg6W31tQLeB5cdIBOicJDJRR2KzXsV7uSK9iNfLWQ5bIZfxuPEHp7M8wiHyHnn0DD1i7w3Zmin0FtkrwoCQ==",
"license": "MIT",
"peer": true,
"engines": {
"node": ">=0.10.0"
}
@@ -14651,7 +14649,6 @@
"integrity": "sha512-ePrwPfxAnB+7hgnEr8vpKxL9cmnp7F322t8oqcPshbIQQhDKgFDW4tjhF2wjVbdXF9O/nyuy3sQWd9JGpiLPvA==",
"devOptional": true,
"license": "MIT",
"peer": true,
"dependencies": {
"shell-quote": "^1.6.1",
"ws": "^7"
@@ -16911,7 +16908,6 @@
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz",
"integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
"license": "MIT",
"peer": true,
"engines": {
"node": ">=12"
},
@@ -17135,8 +17131,7 @@
"resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz",
"integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==",
"dev": true,
"license": "0BSD",
"peer": true
"license": "0BSD"
},
"node_modules/tsx": {
"version": "4.20.3",
@@ -17144,7 +17139,6 @@
"integrity": "sha512-qjbnuR9Tr+FJOMBqJCW5ehvIo/buZq7vH7qD7JziU98h6l3qGy0a/yPFjwO+y0/T7GFpNgNAvEcPPVfyT8rrPQ==",
"devOptional": true,
"license": "MIT",
"peer": true,
"dependencies": {
"esbuild": "~0.25.0",
"get-tsconfig": "^4.7.5"
@@ -17328,7 +17322,6 @@
"integrity": "sha512-p1diW6TqL9L07nNxvRMM7hMMw4c5XOo/1ibL4aAIGmSAt9slTE1Xgw5KWuof2uTOvCg9BY7ZRi+GaF+7sfgPeQ==",
"devOptional": true,
"license": "Apache-2.0",
"peer": true,
"bin": {
"tsc": "bin/tsc",
"tsserver": "bin/tsserver"
@@ -17491,6 +17484,7 @@
"resolved": "https://registry.npmjs.org/utils-merge/-/utils-merge-1.0.1.tgz",
"integrity": "sha512-pMZTvIkT1d+TFGvDOqodOclx0QWkkgi6Tdoa8gC8ffGAAqz9pzPTZWAybbsHHoED/ztMtkv/VoYTYyShUn81hA==",
"license": "MIT",
"peer": true,
"engines": {
"node": ">= 0.4.0"
}
@@ -17545,7 +17539,6 @@
"resolved": "https://registry.npmjs.org/vite/-/vite-7.2.2.tgz",
"integrity": "sha512-BxAKBWmIbrDgrokdGZH1IgkIk/5mMHDreLDmCJ0qpyJaAteP8NvMhkwr/ZCQNqNH97bw/dANTE9PDzqwJghfMQ==",
"license": "MIT",
"peer": true,
"dependencies": {
"esbuild": "^0.25.0",
"fdir": "^6.5.0",
@@ -17659,7 +17652,6 @@
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz",
"integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
"license": "MIT",
"peer": true,
"engines": {
"node": ">=12"
},
@@ -17672,7 +17664,6 @@
"resolved": "https://registry.npmjs.org/vitest/-/vitest-3.2.4.tgz",
"integrity": "sha512-LUCP5ev3GURDysTWiP47wRRUpLKMOfPh+yKTx3kVIEiu5KOMeqzpnYNsKyOoVrULivR8tLcks4+lga33Whn90A==",
"license": "MIT",
"peer": true,
"dependencies": {
"@types/chai": "^5.2.2",
"@vitest/expect": "3.2.4",
@@ -18377,7 +18368,6 @@
"resolved": "https://registry.npmjs.org/zod/-/zod-3.25.76.tgz",
"integrity": "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==",
"license": "MIT",
"peer": true,
"funding": {
"url": "https://github.com/sponsors/colinhacks"
}
@@ -18393,7 +18383,7 @@
},
"packages/a2a-server": {
"name": "@google/gemini-cli-a2a-server",
"version": "0.26.0-nightly.20260115.6cb3ae4e0",
"version": "0.26.0",
"dependencies": {
"@a2a-js/sdk": "^0.3.8",
"@google-cloud/storage": "^7.16.0",
@@ -18703,7 +18693,7 @@
},
"packages/cli": {
"name": "@google/gemini-cli",
"version": "0.26.0-nightly.20260115.6cb3ae4e0",
"version": "0.26.0",
"license": "Apache-2.0",
"dependencies": {
"@agentclientprotocol/sdk": "^0.12.0",
@@ -18807,7 +18797,7 @@
},
"packages/core": {
"name": "@google/gemini-cli-core",
"version": "0.26.0-nightly.20260115.6cb3ae4e0",
"version": "0.26.0",
"license": "Apache-2.0",
"dependencies": {
"@a2a-js/sdk": "^0.3.8",
@@ -18944,7 +18934,6 @@
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz",
"integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
"license": "MIT",
"peer": true,
"engines": {
"node": ">=12"
},
@@ -18967,7 +18956,7 @@
},
"packages/test-utils": {
"name": "@google/gemini-cli-test-utils",
"version": "0.26.0-nightly.20260115.6cb3ae4e0",
"version": "0.26.0",
"license": "Apache-2.0",
"dependencies": {
"@google/gemini-cli-core": "file:../core",
@@ -18984,7 +18973,7 @@
},
"packages/vscode-ide-companion": {
"name": "gemini-cli-vscode-ide-companion",
"version": "0.26.0-nightly.20260115.6cb3ae4e0",
"version": "0.26.0",
"license": "LICENSE",
"dependencies": {
"@modelcontextprotocol/sdk": "^1.23.0",
+2 -2
View File
@@ -1,6 +1,6 @@
{
"name": "@google/gemini-cli",
"version": "0.26.0-nightly.20260115.6cb3ae4e0",
"version": "0.26.0",
"engines": {
"node": ">=20.0.0"
},
@@ -14,7 +14,7 @@
"url": "git+https://github.com/google-gemini/gemini-cli.git"
},
"config": {
"sandboxImageUri": "us-docker.pkg.dev/gemini-code-dev/gemini-cli/sandbox:0.26.0-nightly.20260115.6cb3ae4e0"
"sandboxImageUri": "us-docker.pkg.dev/gemini-code-dev/gemini-cli/sandbox:0.26.0"
},
"scripts": {
"start": "cross-env NODE_ENV=development node scripts/start.js",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@google/gemini-cli-a2a-server",
"version": "0.26.0-nightly.20260115.6cb3ae4e0",
"version": "0.26.0",
"description": "Gemini CLI A2A Server",
"repository": {
"type": "git",
+4 -1
View File
@@ -575,7 +575,10 @@ export class Task {
EDIT_TOOL_NAMES.has(request.name),
);
if (restorableToolCalls.length > 0) {
if (
restorableToolCalls.length > 0 &&
this.config.getCheckpointingEnabled()
) {
const gitService = await this.config.getGitService();
if (gitService) {
const { checkpointsToWrite, toolCallToCheckpointMap, errors } =
@@ -0,0 +1,110 @@
/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import { loadConfig } from './config.js';
import type { ExtensionLoader } from '@google/gemini-cli-core';
import type { Settings } from './settings.js';
const {
mockLoadServerHierarchicalMemory,
mockConfigConstructor,
mockVerifyGitAvailability,
} = vi.hoisted(() => ({
mockLoadServerHierarchicalMemory: vi.fn().mockResolvedValue({
memoryContent: '',
fileCount: 0,
filePaths: [],
}),
mockConfigConstructor: vi.fn(),
mockVerifyGitAvailability: vi.fn(),
}));
vi.mock('@google/gemini-cli-core', async () => ({
Config: class MockConfig {
constructor(params: unknown) {
mockConfigConstructor(params);
}
initialize = vi.fn();
refreshAuth = vi.fn();
},
loadServerHierarchicalMemory: mockLoadServerHierarchicalMemory,
startupProfiler: {
flush: vi.fn(),
},
FileDiscoveryService: vi.fn(),
ApprovalMode: { DEFAULT: 'default', YOLO: 'yolo' },
AuthType: {
LOGIN_WITH_GOOGLE: 'login_with_google',
USE_GEMINI: 'use_gemini',
},
GEMINI_DIR: '.gemini',
DEFAULT_GEMINI_EMBEDDING_MODEL: 'models/embedding-001',
DEFAULT_GEMINI_MODEL: 'models/gemini-1.5-flash',
PREVIEW_GEMINI_MODEL: 'models/gemini-1.5-pro-latest',
homedir: () => '/tmp',
GitService: {
verifyGitAvailability: mockVerifyGitAvailability,
},
}));
describe('loadConfig', () => {
const mockSettings = {
checkpointing: { enabled: true },
};
const mockExtensionLoader = {
start: vi.fn(),
getExtensions: vi.fn().mockReturnValue([]),
} as unknown as ExtensionLoader;
beforeEach(() => {
vi.resetAllMocks();
process.env['GEMINI_API_KEY'] = 'test-key';
// Reset the mock return value just in case
mockLoadServerHierarchicalMemory.mockResolvedValue({
memoryContent: '',
fileCount: 0,
filePaths: [],
});
});
afterEach(() => {
delete process.env['GEMINI_API_KEY'];
delete process.env['CHECKPOINTING'];
});
it('should disable checkpointing if git is not installed', async () => {
mockVerifyGitAvailability.mockResolvedValue(false);
await loadConfig(
mockSettings as unknown as Settings,
mockExtensionLoader,
'test-task',
);
expect(mockConfigConstructor).toHaveBeenCalledWith(
expect.objectContaining({
checkpointing: false,
}),
);
});
it('should enable checkpointing if git is installed', async () => {
mockVerifyGitAvailability.mockResolvedValue(true);
await loadConfig(
mockSettings as unknown as Settings,
mockExtensionLoader,
'test-task',
);
expect(mockConfigConstructor).toHaveBeenCalledWith(
expect.objectContaining({
checkpointing: true,
}),
);
});
});
+15 -3
View File
@@ -23,6 +23,7 @@ import {
startupProfiler,
PREVIEW_GEMINI_MODEL,
homedir,
GitService,
} from '@google/gemini-cli-core';
import { logger } from '../utils/logger.js';
@@ -41,6 +42,19 @@ export async function loadConfig(
settings.folderTrust === true ||
process.env['GEMINI_FOLDER_TRUST'] === 'true';
let checkpointing = process.env['CHECKPOINTING']
? process.env['CHECKPOINTING'] === 'true'
: settings.checkpointing?.enabled;
if (checkpointing) {
if (!(await GitService.verifyGitAvailability())) {
logger.warn(
'[Config] Checkpointing is enabled but git is not installed. Disabling checkpointing.',
);
checkpointing = false;
}
}
const configParams: ConfigParameters = {
sessionId: taskId,
model: settings.general?.previewFeatures
@@ -79,9 +93,7 @@ export async function loadConfig(
folderTrust,
trustedFolder: true,
extensionLoader,
checkpointing: process.env['CHECKPOINTING']
? process.env['CHECKPOINTING'] === 'true'
: settings.checkpointing?.enabled,
checkpointing,
previewFeatures: settings.general?.previewFeatures,
interactive: true,
enableInteractiveShell: true,
+24
View File
@@ -11,6 +11,30 @@ import { FatalError, writeToStderr } from '@google/gemini-cli-core';
import { runExitCleanup } from './src/utils/cleanup.js';
// --- Global Entry Point ---
// Suppress known race condition error in node-pty on Windows
// Tracking bug: https://github.com/microsoft/node-pty/issues/827
process.on('uncaughtException', (error) => {
if (
process.platform === 'win32' &&
error instanceof Error &&
error.message === 'Cannot resize a pty that has already exited'
) {
// This error happens on Windows with node-pty when resizing a pty that has just exited.
// It is a race condition in node-pty that we cannot prevent, so we silence it.
return;
}
// For other errors, we rely on the default behavior, but since we attached a listener,
// we must manually replicate it.
if (error instanceof Error) {
writeToStderr(error.stack + '\n');
} else {
writeToStderr(String(error) + '\n');
}
process.exit(1);
});
main().catch(async (error) => {
await runExitCleanup();
+2 -2
View File
@@ -1,6 +1,6 @@
{
"name": "@google/gemini-cli",
"version": "0.26.0-nightly.20260115.6cb3ae4e0",
"version": "0.26.0",
"description": "Gemini CLI",
"license": "Apache-2.0",
"repository": {
@@ -26,7 +26,7 @@
"dist"
],
"config": {
"sandboxImageUri": "us-docker.pkg.dev/gemini-code-dev/gemini-cli/sandbox:0.26.0-nightly.20260115.6cb3ae4e0"
"sandboxImageUri": "us-docker.pkg.dev/gemini-code-dev/gemini-cli/sandbox:0.26.0"
},
"dependencies": {
"@agentclientprotocol/sdk": "^0.12.0",
+27 -9
View File
@@ -54,15 +54,33 @@ describe('extensionsCommand', () => {
extensionsCommand.builder(mockYargs);
expect(mockYargs.middleware).toHaveBeenCalled();
expect(mockYargs.command).toHaveBeenCalledWith({ command: 'install' });
expect(mockYargs.command).toHaveBeenCalledWith({ command: 'uninstall' });
expect(mockYargs.command).toHaveBeenCalledWith({ command: 'list' });
expect(mockYargs.command).toHaveBeenCalledWith({ command: 'update' });
expect(mockYargs.command).toHaveBeenCalledWith({ command: 'disable' });
expect(mockYargs.command).toHaveBeenCalledWith({ command: 'enable' });
expect(mockYargs.command).toHaveBeenCalledWith({ command: 'link' });
expect(mockYargs.command).toHaveBeenCalledWith({ command: 'new' });
expect(mockYargs.command).toHaveBeenCalledWith({ command: 'validate' });
expect(mockYargs.command).toHaveBeenCalledWith(
expect.objectContaining({ command: 'install' }),
);
expect(mockYargs.command).toHaveBeenCalledWith(
expect.objectContaining({ command: 'uninstall' }),
);
expect(mockYargs.command).toHaveBeenCalledWith(
expect.objectContaining({ command: 'list' }),
);
expect(mockYargs.command).toHaveBeenCalledWith(
expect.objectContaining({ command: 'update' }),
);
expect(mockYargs.command).toHaveBeenCalledWith(
expect.objectContaining({ command: 'disable' }),
);
expect(mockYargs.command).toHaveBeenCalledWith(
expect.objectContaining({ command: 'enable' }),
);
expect(mockYargs.command).toHaveBeenCalledWith(
expect.objectContaining({ command: 'link' }),
);
expect(mockYargs.command).toHaveBeenCalledWith(
expect.objectContaining({ command: 'new' }),
);
expect(mockYargs.command).toHaveBeenCalledWith(
expect.objectContaining({ command: 'validate' }),
);
expect(mockYargs.demandCommand).toHaveBeenCalledWith(1, expect.any(String));
expect(mockYargs.version).toHaveBeenCalledWith(false);
});
+11 -10
View File
@@ -16,6 +16,7 @@ import { newCommand } from './extensions/new.js';
import { validateCommand } from './extensions/validate.js';
import { configureCommand } from './extensions/configure.js';
import { initializeOutputListenersAndFlush } from '../gemini.js';
import { defer } from '../deferred.js';
export const extensionsCommand: CommandModule = {
command: 'extensions <command>',
@@ -24,16 +25,16 @@ export const extensionsCommand: CommandModule = {
builder: (yargs) =>
yargs
.middleware(() => initializeOutputListenersAndFlush())
.command(installCommand)
.command(uninstallCommand)
.command(listCommand)
.command(updateCommand)
.command(disableCommand)
.command(enableCommand)
.command(linkCommand)
.command(newCommand)
.command(validateCommand)
.command(configureCommand)
.command(defer(installCommand, 'extensions'))
.command(defer(uninstallCommand, 'extensions'))
.command(defer(listCommand, 'extensions'))
.command(defer(updateCommand, 'extensions'))
.command(defer(disableCommand, 'extensions'))
.command(defer(enableCommand, 'extensions'))
.command(defer(linkCommand, 'extensions'))
.command(defer(newCommand, 'extensions'))
.command(defer(validateCommand, 'extensions'))
.command(defer(configureCommand, 'extensions'))
.demandCommand(1, 'You need at least one command before continuing.')
.version(false),
handler: () => {
@@ -0,0 +1,4 @@
{
"name": "hooks-example",
"version": "1.0.0"
}
@@ -0,0 +1,14 @@
{
"hooks": {
"SessionStart": [
{
"hooks": [
{
"type": "command",
"command": "node ${extensionPath}/scripts/on-start.js"
}
]
}
]
}
}
@@ -0,0 +1,8 @@
/**
* @license
* Copyright 2026 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
console.log(
'Session Started! This is running from a script in the hooks-example extension.',
);
@@ -0,0 +1,35 @@
# MCP Server Example
This is a basic example of an MCP (Model Context Protocol) server used as a
Gemini CLI extension. It demonstrates how to expose tools and prompts to the
Gemini CLI.
## Description
The contents of this directory are a valid MCP server implementation using the
`@modelcontextprotocol/sdk`. It exposes:
- A tool `fetch_posts` that mock-fetches posts.
- A prompt `poem-writer`.
## Structure
- `example.js`: The main server entry point.
- `gemini-extension.json`: The configuration file that tells Gemini CLI how to
use this extension.
- `package.json`: Helper for dependencies.
## How to Use
1. Navigate to this directory:
```bash
cd packages/cli/src/commands/extensions/examples/mcp-server
```
2. Install dependencies:
```bash
npm install
```
This example is typically used by `gemini extensions new`.
@@ -1,135 +0,0 @@
/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
import { z } from 'zod';
// Mock the MCP server and transport
const mockRegisterTool = vi.fn();
const mockRegisterPrompt = vi.fn();
const mockConnect = vi.fn();
vi.mock('@modelcontextprotocol/sdk/server/mcp.js', () => ({
McpServer: vi.fn().mockImplementation(() => ({
registerTool: mockRegisterTool,
registerPrompt: mockRegisterPrompt,
connect: mockConnect,
})),
}));
vi.mock('@modelcontextprotocol/sdk/server/stdio.js', () => ({
StdioServerTransport: vi.fn(),
}));
describe('MCP Server Example', () => {
beforeEach(async () => {
// Dynamically import the server setup after mocks are in place
await import('./example.js');
});
afterEach(() => {
vi.clearAllMocks();
vi.resetModules();
});
it('should create an McpServer with the correct name and version', () => {
expect(McpServer).toHaveBeenCalledWith({
name: 'prompt-server',
version: '1.0.0',
});
});
it('should register the "fetch_posts" tool', () => {
expect(mockRegisterTool).toHaveBeenCalledWith(
'fetch_posts',
{
description: 'Fetches a list of posts from a public API.',
inputSchema: z.object({}).shape,
},
expect.any(Function),
);
});
it('should register the "poem-writer" prompt', () => {
expect(mockRegisterPrompt).toHaveBeenCalledWith(
'poem-writer',
{
title: 'Poem Writer',
description: 'Write a nice haiku',
argsSchema: expect.any(Object),
},
expect.any(Function),
);
});
it('should connect the server to an StdioServerTransport', () => {
expect(StdioServerTransport).toHaveBeenCalled();
expect(mockConnect).toHaveBeenCalledWith(expect.any(StdioServerTransport));
});
describe('fetch_posts tool implementation', () => {
it('should fetch posts and return a formatted response', async () => {
const mockPosts = [
{ id: 1, title: 'Post 1' },
{ id: 2, title: 'Post 2' },
];
global.fetch = vi.fn().mockResolvedValue({
json: vi.fn().mockResolvedValue(mockPosts),
});
const toolFn = mockRegisterTool.mock.calls[0][2];
const result = await toolFn();
expect(global.fetch).toHaveBeenCalledWith(
'https://jsonplaceholder.typicode.com/posts',
);
expect(result).toEqual({
content: [
{
type: 'text',
text: JSON.stringify({ posts: mockPosts }),
},
],
});
});
});
describe('poem-writer prompt implementation', () => {
it('should generate a prompt with a title', () => {
const promptFn = mockRegisterPrompt.mock.calls[0][2];
const result = promptFn({ title: 'My Poem' });
expect(result).toEqual({
messages: [
{
role: 'user',
content: {
type: 'text',
text: 'Write a haiku called My Poem. Note that a haiku is 5 syllables followed by 7 syllables followed by 5 syllables ',
},
},
],
});
});
it('should generate a prompt with a title and mood', () => {
const promptFn = mockRegisterPrompt.mock.calls[0][2];
const result = promptFn({ title: 'My Poem', mood: 'sad' });
expect(result).toEqual({
messages: [
{
role: 'user',
content: {
type: 'text',
text: 'Write a haiku with the mood sad called My Poem. Note that a haiku is 5 syllables followed by 7 syllables followed by 5 syllables ',
},
},
],
});
});
});
});
@@ -4,7 +4,7 @@
"mcpServers": {
"nodeServer": {
"command": "node",
"args": ["${extensionPath}${/}dist${/}example.js"],
"args": ["${extensionPath}${/}example.js"],
"cwd": "${extensionPath}"
}
}
@@ -4,13 +4,6 @@
"description": "Example MCP Server for Gemini CLI Extension",
"type": "module",
"main": "example.js",
"scripts": {
"build": "tsc"
},
"devDependencies": {
"typescript": "~5.4.5",
"@types/node": "^20.11.25"
},
"dependencies": {
"@modelcontextprotocol/sdk": "^1.23.0",
"zod": "^3.22.4"
@@ -1,13 +0,0 @@
{
"compilerOptions": {
"target": "ES2022",
"module": "NodeNext",
"moduleResolution": "NodeNext",
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true,
"forceConsistentCasingInFileNames": true,
"outDir": "./dist"
},
"include": ["example.ts"]
}
@@ -0,0 +1,4 @@
{
"name": "skills-example",
"version": "1.0.0"
}
@@ -0,0 +1,7 @@
---
name: greeter
description: A friendly greeter skill
---
You are a friendly greeter. When the user says "hello" or asks for a greeting,
you should reply with: "Greetings from the skills-example extension! 👋"
@@ -511,8 +511,5 @@ describe('migrate command', () => {
expect(debugLoggerLogSpy).toHaveBeenCalledWith(
'\nMigration complete! Please review the migrated hooks in .gemini/settings.json',
);
expect(debugLoggerLogSpy).toHaveBeenCalledWith(
'Note: Set hooks.enabled to true in your settings to enable the hook system.',
);
});
});
+4 -4
View File
@@ -230,7 +230,10 @@ export async function handleMigrateFromClaude() {
const settings = loadSettings(workingDir);
// Merge migrated hooks with existing hooks
const existingHooks = settings.merged.hooks as Record<string, unknown>;
const existingHooks = (settings.merged?.hooks || {}) as Record<
string,
unknown
>;
const mergedHooks = { ...existingHooks, ...migratedHooks };
// Update settings (setValue automatically saves)
@@ -241,9 +244,6 @@ export async function handleMigrateFromClaude() {
debugLogger.log(
'\nMigration complete! Please review the migrated hooks in .gemini/settings.json',
);
debugLogger.log(
'Note: Set hooks.enabled to true in your settings to enable the hook system.',
);
} catch (error) {
debugLogger.error(`Error saving migrated hooks: ${getErrorMessage(error)}`);
}
+4 -3
View File
@@ -10,6 +10,7 @@ import { addCommand } from './mcp/add.js';
import { removeCommand } from './mcp/remove.js';
import { listCommand } from './mcp/list.js';
import { initializeOutputListenersAndFlush } from '../gemini.js';
import { defer } from '../deferred.js';
export const mcpCommand: CommandModule = {
command: 'mcp',
@@ -17,9 +18,9 @@ export const mcpCommand: CommandModule = {
builder: (yargs: Argv) =>
yargs
.middleware(() => initializeOutputListenersAndFlush())
.command(addCommand)
.command(removeCommand)
.command(listCommand)
.command(defer(addCommand, 'mcp'))
.command(defer(removeCommand, 'mcp'))
.command(defer(listCommand, 'mcp'))
.demandCommand(1, 'You need at least one command before continuing.')
.version(false),
handler: () => {
+16 -1
View File
@@ -123,8 +123,13 @@ describe('mcp list command', () => {
...defaultMergedSettings,
mcpServers: {
'stdio-server': { command: '/path/to/server', args: ['arg1'] },
'sse-server': { url: 'https://example.com/sse' },
'sse-server': { url: 'https://example.com/sse', type: 'sse' },
'http-server': { httpUrl: 'https://example.com/http' },
'http-server-by-default': { url: 'https://example.com/http' },
'http-server-with-type': {
url: 'https://example.com/http',
type: 'http',
},
},
},
});
@@ -150,6 +155,16 @@ describe('mcp list command', () => {
'http-server: https://example.com/http (http) - Connected',
),
);
expect(debugLogger.log).toHaveBeenCalledWith(
expect.stringContaining(
'http-server-by-default: https://example.com/http (http) - Connected',
),
);
expect(debugLogger.log).toHaveBeenCalledWith(
expect.stringContaining(
'http-server-with-type: https://example.com/http (http) - Connected',
),
);
});
it('should display disconnected status when connection fails', async () => {
+2 -1
View File
@@ -144,7 +144,8 @@ export async function listMcpServers(): Promise<void> {
if (server.httpUrl) {
serverInfo += `${server.httpUrl} (http)`;
} else if (server.url) {
serverInfo += `${server.url} (sse)`;
const type = server.type || 'http';
serverInfo += `${server.url} (${type})`;
} else if (server.command) {
serverInfo += `${server.command} ${server.args?.join(' ') || ''} (stdio)`;
}
+13 -7
View File
@@ -38,13 +38,19 @@ describe('skillsCommand', () => {
skillsCommand.builder(mockYargs);
expect(mockYargs.middleware).toHaveBeenCalled();
expect(mockYargs.command).toHaveBeenCalledWith({ command: 'list' });
expect(mockYargs.command).toHaveBeenCalledWith({
command: 'enable <name>',
});
expect(mockYargs.command).toHaveBeenCalledWith({
command: 'disable <name>',
});
expect(mockYargs.command).toHaveBeenCalledWith(
expect.objectContaining({ command: 'list' }),
);
expect(mockYargs.command).toHaveBeenCalledWith(
expect.objectContaining({
command: 'enable <name>',
}),
);
expect(mockYargs.command).toHaveBeenCalledWith(
expect.objectContaining({
command: 'disable <name>',
}),
);
expect(mockYargs.demandCommand).toHaveBeenCalledWith(1, expect.any(String));
expect(mockYargs.version).toHaveBeenCalledWith(false);
});
+6 -5
View File
@@ -11,6 +11,7 @@ import { disableCommand } from './skills/disable.js';
import { installCommand } from './skills/install.js';
import { uninstallCommand } from './skills/uninstall.js';
import { initializeOutputListenersAndFlush } from '../gemini.js';
import { defer } from '../deferred.js';
export const skillsCommand: CommandModule = {
command: 'skills <command>',
@@ -19,11 +20,11 @@ export const skillsCommand: CommandModule = {
builder: (yargs) =>
yargs
.middleware(() => initializeOutputListenersAndFlush())
.command(listCommand)
.command(enableCommand)
.command(disableCommand)
.command(installCommand)
.command(uninstallCommand)
.command(defer(listCommand, 'skills'))
.command(defer(enableCommand, 'skills'))
.command(defer(disableCommand, 'skills'))
.command(defer(installCommand, 'skills'))
.command(defer(uninstallCommand, 'skills'))
.demandCommand(1, 'You need at least one command before continuing.')
.version(false),
handler: () => {
+7 -4
View File
@@ -37,6 +37,7 @@ import {
type HookEventName,
type OutputFormat,
GEMINI_MODEL_ALIAS_AUTO,
coreEvents,
} from '@google/gemini-cli-core';
import {
type Settings,
@@ -47,7 +48,6 @@ import {
import { loadSandboxConfig } from './sandboxConfig.js';
import { resolvePath } from '../utils/resolvePath.js';
import { appEvents } from '../utils/events.js';
import { RESUME_LATEST } from '../utils/sessionUtils.js';
import { isWorkspaceTrusted } from './trustedFolders.js';
@@ -450,7 +450,8 @@ export async function loadCliConfig(
requestSetting: promptForSetting,
workspaceDir: cwd,
enabledExtensionOverrides: argv.extensions,
eventEmitter: appEvents as EventEmitter<ExtensionEvents>,
eventEmitter: coreEvents as EventEmitter<ExtensionEvents>,
clientVersion: await getVersion(),
});
await extensionManager.loadExtensions();
@@ -653,6 +654,7 @@ export async function loadCliConfig(
return new Config({
sessionId,
clientVersion: await getVersion(),
embeddingModel: DEFAULT_GEMINI_EMBEDDING_MODEL,
sandbox: sandboxConfig,
targetDir: cwd,
@@ -744,7 +746,7 @@ export async function loadCliConfig(
truncateToolOutputThreshold: settings.tools?.truncateToolOutputThreshold,
truncateToolOutputLines: settings.tools?.truncateToolOutputLines,
enableToolOutputTruncation: settings.tools?.enableToolOutputTruncation,
eventEmitter: appEvents,
eventEmitter: coreEvents,
useWriteTodos: argv.useWriteTodos ?? settings.useWriteTodos,
output: {
format: (argv.outputFormat ?? settings.output?.format) as OutputFormat,
@@ -761,9 +763,10 @@ export async function loadCliConfig(
// TODO: loading of hooks based on workspace trust
enableHooks:
(settings.tools?.enableHooks ?? true) &&
(settings.hooks?.enabled ?? false),
(settings.hooksConfig?.enabled ?? true),
enableHooksUI: settings.tools?.enableHooks ?? true,
hooks: settings.hooks || {},
disabledHooks: settings.hooksConfig?.disabled || [],
projectHooks: projectHooks || {},
onModelChange: (model: string) => saveModelChange(loadedSettings, model),
onReload: async () => {
+6 -1
View File
@@ -76,6 +76,7 @@ interface ExtensionManagerParams {
requestSetting: ((setting: ExtensionSetting) => Promise<string>) | null;
workspaceDir: string;
eventEmitter?: EventEmitter<ExtensionEvents>;
clientVersion?: string;
}
/**
@@ -105,6 +106,7 @@ export class ExtensionManager extends ExtensionLoader {
telemetry: options.settings.telemetry,
interactive: false,
sessionId: randomUUID(),
clientVersion: options.clientVersion ?? 'unknown',
targetDir: options.workspaceDir,
cwd: options.workspaceDir,
model: '',
@@ -611,7 +613,10 @@ Would you like to attempt to install via "git clone" instead?`,
.filter((contextFilePath) => fs.existsSync(contextFilePath));
let hooks: { [K in HookEventName]?: HookDefinition[] } | undefined;
if (this.settings.tools.enableHooks && this.settings.hooks.enabled) {
if (
this.settings.tools.enableHooks &&
this.settings.hooksConfig.enabled
) {
hooks = await this.loadExtensionHooks(effectiveExtensionPath, {
extensionPath: effectiveExtensionPath,
workspacePath: this.workspaceDir,
+4 -3
View File
@@ -815,6 +815,7 @@ describe('extension tests', () => {
fs.mkdirSync(hooksDir);
const hooksConfig = {
enabled: false,
hooks: {
BeforeTool: [
{
@@ -836,7 +837,7 @@ describe('extension tests', () => {
);
const settings = loadSettings(tempWorkspaceDir).merged;
settings.hooks.enabled = true;
settings.hooksConfig.enabled = true;
extensionManager = new ExtensionManager({
workspaceDir: tempWorkspaceDir,
@@ -867,11 +868,11 @@ describe('extension tests', () => {
fs.mkdirSync(hooksDir);
fs.writeFileSync(
path.join(hooksDir, 'hooks.json'),
JSON.stringify({ hooks: { BeforeTool: [] } }),
JSON.stringify({ hooks: { BeforeTool: [] }, enabled: false }),
);
const settings = loadSettings(tempWorkspaceDir).merged;
settings.hooks.enabled = false;
settings.hooksConfig.enabled = false;
extensionManager = new ExtensionManager({
workspaceDir: tempWorkspaceDir,
+21 -3
View File
@@ -73,9 +73,27 @@ describe('keyBindings config', () => {
expect(dialogNavDown).toContainEqual({ key: 'down', shift: false });
expect(dialogNavDown).toContainEqual({ key: 'j', shift: false });
// Verify physical home/end keys
expect(defaultKeyBindings[Command.HOME]).toContainEqual({ key: 'home' });
expect(defaultKeyBindings[Command.END]).toContainEqual({ key: 'end' });
// Verify physical home/end keys for cursor movement
expect(defaultKeyBindings[Command.HOME]).toContainEqual({
key: 'home',
ctrl: false,
shift: false,
});
expect(defaultKeyBindings[Command.END]).toContainEqual({
key: 'end',
ctrl: false,
shift: false,
});
// Verify physical home/end keys for scrolling
expect(defaultKeyBindings[Command.SCROLL_HOME]).toContainEqual({
key: 'home',
ctrl: true,
});
expect(defaultKeyBindings[Command.SCROLL_END]).toContainEqual({
key: 'end',
ctrl: true,
});
});
});
+20 -4
View File
@@ -49,6 +49,7 @@ export enum Command {
REVERSE_SEARCH = 'history.search.start',
SUBMIT_REVERSE_SEARCH = 'history.search.submit',
ACCEPT_SUGGESTION_REVERSE_SEARCH = 'history.search.accept',
REWIND = 'history.rewind',
// Navigation
NAVIGATION_UP = 'nav.up',
@@ -117,8 +118,14 @@ export const defaultKeyBindings: KeyBindingConfig = {
[Command.EXIT]: [{ key: 'd', ctrl: true }],
// Cursor Movement
[Command.HOME]: [{ key: 'a', ctrl: true }, { key: 'home' }],
[Command.END]: [{ key: 'e', ctrl: true }, { key: 'end' }],
[Command.HOME]: [
{ key: 'a', ctrl: true },
{ key: 'home', ctrl: false, shift: false },
],
[Command.END]: [
{ key: 'e', ctrl: true },
{ key: 'end', ctrl: false, shift: false },
],
[Command.MOVE_UP]: [{ key: 'up', ctrl: false, command: false }],
[Command.MOVE_DOWN]: [{ key: 'down', ctrl: false, command: false }],
[Command.MOVE_LEFT]: [
@@ -162,8 +169,14 @@ export const defaultKeyBindings: KeyBindingConfig = {
// Scrolling
[Command.SCROLL_UP]: [{ key: 'up', shift: true }],
[Command.SCROLL_DOWN]: [{ key: 'down', shift: true }],
[Command.SCROLL_HOME]: [{ key: 'home' }],
[Command.SCROLL_END]: [{ key: 'end' }],
[Command.SCROLL_HOME]: [
{ key: 'home', ctrl: true },
{ key: 'home', shift: true },
],
[Command.SCROLL_END]: [
{ key: 'end', ctrl: true },
{ key: 'end', shift: true },
],
[Command.PAGE_UP]: [{ key: 'pageup' }],
[Command.PAGE_DOWN]: [{ key: 'pagedown' }],
@@ -171,6 +184,7 @@ export const defaultKeyBindings: KeyBindingConfig = {
[Command.HISTORY_UP]: [{ key: 'p', ctrl: true, shift: false }],
[Command.HISTORY_DOWN]: [{ key: 'n', ctrl: true, shift: false }],
[Command.REVERSE_SEARCH]: [{ key: 'r', ctrl: true }],
[Command.REWIND]: [{ key: 'double escape' }],
// Note: original logic ONLY checked ctrl=false, ignored meta/shift/paste
[Command.SUBMIT_REVERSE_SEARCH]: [{ key: 'return', ctrl: false }],
[Command.ACCEPT_SUGGESTION_REVERSE_SEARCH]: [{ key: 'tab' }],
@@ -301,6 +315,7 @@ export const commandCategories: readonly CommandCategory[] = [
Command.REVERSE_SEARCH,
Command.SUBMIT_REVERSE_SEARCH,
Command.ACCEPT_SUGGESTION_REVERSE_SEARCH,
Command.REWIND,
],
},
{
@@ -397,6 +412,7 @@ export const commandDescriptions: Readonly<Record<Command, string>> = {
[Command.SUBMIT_REVERSE_SEARCH]: 'Submit the selected reverse-search match.',
[Command.ACCEPT_SUGGESTION_REVERSE_SEARCH]:
'Accept a suggestion while reverse searching.',
[Command.REWIND]: 'Browse and rewind previous interactions.',
// Navigation
[Command.NAVIGATION_UP]: 'Move selection up in lists.',
+68
View File
@@ -1961,6 +1961,57 @@ describe('Settings Loading and Merging', () => {
}),
);
});
it('should migrate disableUpdateNag to enableAutoUpdateNotification in system and system defaults settings', () => {
const systemSettingsContent = {
general: {
disableUpdateNag: true,
},
};
const systemDefaultsContent = {
general: {
disableUpdateNag: false,
},
};
vi.mocked(fs.existsSync).mockReturnValue(true);
(fs.readFileSync as Mock).mockImplementation(
(p: fs.PathOrFileDescriptor) => {
if (p === getSystemSettingsPath()) {
return JSON.stringify(systemSettingsContent);
}
if (p === getSystemDefaultsPath()) {
return JSON.stringify(systemDefaultsContent);
}
return '{}';
},
);
const settings = loadSettings(MOCK_WORKSPACE_DIR);
// Verify system settings were migrated
expect(settings.system.settings.general).toHaveProperty(
'enableAutoUpdateNotification',
);
expect(
(settings.system.settings.general as Record<string, unknown>)[
'enableAutoUpdateNotification'
],
).toBe(false);
// Verify system defaults settings were migrated
expect(settings.systemDefaults.settings.general).toHaveProperty(
'enableAutoUpdateNotification',
);
expect(
(settings.systemDefaults.settings.general as Record<string, unknown>)[
'enableAutoUpdateNotification'
],
).toBe(true);
// Merged should also reflect it (system overrides defaults, but both are migrated)
expect(settings.merged.general?.enableAutoUpdateNotification).toBe(false);
});
});
describe('saveSettings', () => {
@@ -2200,6 +2251,23 @@ describe('Settings Loading and Merging', () => {
expect(loadedSettings.merged.admin?.mcp?.enabled).toBe(true);
expect(loadedSettings.merged.admin?.extensions?.enabled).toBe(false);
});
it('should set skills based on advancedFeaturesEnabled', () => {
const loadedSettings = loadSettings();
loadedSettings.setRemoteAdminSettings({
cliFeatureSetting: {
advancedFeaturesEnabled: true,
},
});
expect(loadedSettings.merged.admin.skills?.enabled).toBe(true);
loadedSettings.setRemoteAdminSettings({
cliFeatureSetting: {
advancedFeaturesEnabled: false,
},
});
expect(loadedSettings.merged.admin.skills?.enabled).toBe(false);
});
});
describe('getDefaultsFromSchema', () => {
+6
View File
@@ -363,6 +363,10 @@ export class LoadedSettings {
admin.extensions = { enabled: extensionsSetting.extensionsEnabled };
}
if (cliFeatureSetting?.advancedFeaturesEnabled !== undefined) {
admin.skills = { enabled: cliFeatureSetting.advancedFeaturesEnabled };
}
this._remoteAdminSettings = { admin };
this._merged = this.computeMergedSettings();
}
@@ -804,6 +808,8 @@ export function migrateDeprecatedSettings(
processScope(SettingScope.User);
processScope(SettingScope.Workspace);
processScope(SettingScope.System);
processScope(SettingScope.SystemDefaults);
return anyModified;
}
@@ -395,8 +395,8 @@ describe('SettingsSchema', () => {
);
});
it('should have hooks.notifications setting in schema', () => {
const setting = getSettingsSchema().hooks.properties.notifications;
it('should have hooksConfig.notifications setting in schema', () => {
const setting = getSettingsSchema().hooksConfig?.properties.notifications;
expect(setting).toBeDefined();
expect(setting.type).toBe('boolean');
expect(setting.category).toBe('Advanced');
+18 -11
View File
@@ -1454,12 +1454,12 @@ const SETTINGS_SCHEMA = {
},
skills: {
type: 'boolean',
label: 'Agent Skills',
label: 'Agent Skills (Deprecated)',
category: 'Experimental',
requiresRestart: true,
default: false,
description: 'Enable Agent Skills (experimental).',
showInDialog: true,
description: '[Deprecated] Enable Agent Skills (experimental).',
showInDialog: false,
},
codebaseInvestigatorSettings: {
type: 'object',
@@ -1615,7 +1615,6 @@ const SETTINGS_SCHEMA = {
default: true,
description: 'Enable Agent Skills.',
showInDialog: true,
ignoreInDocs: true,
},
disabled: {
type: 'array',
@@ -1631,9 +1630,9 @@ const SETTINGS_SCHEMA = {
},
},
hooks: {
hooksConfig: {
type: 'object',
label: 'Hooks',
label: 'HooksConfig',
category: 'Advanced',
requiresRestart: false,
default: {},
@@ -1646,7 +1645,7 @@ const SETTINGS_SCHEMA = {
label: 'Enable Hooks',
category: 'Advanced',
requiresRestart: false,
default: false,
default: true,
description:
'Canonical toggle for the hooks system. When disabled, no hooks will be executed.',
showInDialog: false,
@@ -1675,6 +1674,18 @@ const SETTINGS_SCHEMA = {
description: 'Show visual indicators when hooks are executing.',
showInDialog: true,
},
},
},
hooks: {
type: 'object',
label: 'Hook Events',
category: 'Advanced',
requiresRestart: false,
default: {},
description: 'Event-specific hook configurations.',
showInDialog: false,
properties: {
BeforeTool: {
type: 'array',
label: 'Before Tool Hooks',
@@ -2117,10 +2128,6 @@ export const SETTINGS_SCHEMA_DEFINITIONS: Record<
type: 'boolean',
description: 'Whether to enable the agent.',
},
disabled: {
type: 'boolean',
description: 'Whether to disable the agent.',
},
},
},
CustomTheme: {
+217
View File
@@ -0,0 +1,217 @@
/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import { describe, it, expect, vi, beforeEach } from 'vitest';
import {
runDeferredCommand,
defer,
setDeferredCommand,
type DeferredCommand,
} from './deferred.js';
import { ExitCodes } from '@google/gemini-cli-core';
import type { ArgumentsCamelCase, CommandModule } from 'yargs';
import type { MergedSettings } from './config/settings.js';
import type { MockInstance } from 'vitest';
const { mockRunExitCleanup, mockDebugLogger } = vi.hoisted(() => ({
mockRunExitCleanup: vi.fn(),
mockDebugLogger: {
log: vi.fn(),
error: vi.fn(),
},
}));
vi.mock('@google/gemini-cli-core', async () => {
const actual = await vi.importActual('@google/gemini-cli-core');
return {
...actual,
debugLogger: mockDebugLogger,
};
});
vi.mock('./utils/cleanup.js', () => ({
runExitCleanup: mockRunExitCleanup,
}));
let mockExit: MockInstance;
describe('deferred', () => {
beforeEach(() => {
vi.clearAllMocks();
mockExit = vi
.spyOn(process, 'exit')
.mockImplementation(() => undefined as never);
setDeferredCommand(undefined as unknown as DeferredCommand); // Reset deferred command
});
const createMockSettings = (adminSettings: unknown = {}): MergedSettings =>
({
admin: adminSettings,
}) as unknown as MergedSettings;
describe('runDeferredCommand', () => {
it('should do nothing if no deferred command is set', async () => {
await runDeferredCommand(createMockSettings());
expect(mockDebugLogger.log).not.toHaveBeenCalled();
expect(mockDebugLogger.error).not.toHaveBeenCalled();
expect(mockExit).not.toHaveBeenCalled();
});
it('should execute the deferred command if enabled', async () => {
const mockHandler = vi.fn();
setDeferredCommand({
handler: mockHandler,
argv: { _: [], $0: 'gemini' } as ArgumentsCamelCase,
commandName: 'mcp',
});
const settings = createMockSettings({ mcp: { enabled: true } });
await runDeferredCommand(settings);
expect(mockHandler).toHaveBeenCalled();
expect(mockRunExitCleanup).toHaveBeenCalled();
expect(mockExit).toHaveBeenCalledWith(ExitCodes.SUCCESS);
});
it('should exit with FATAL_CONFIG_ERROR if MCP is disabled', async () => {
setDeferredCommand({
handler: vi.fn(),
argv: {} as ArgumentsCamelCase,
commandName: 'mcp',
});
const settings = createMockSettings({ mcp: { enabled: false } });
await runDeferredCommand(settings);
expect(mockDebugLogger.error).toHaveBeenCalledWith(
'Error: MCP is disabled by your admin.',
);
expect(mockRunExitCleanup).toHaveBeenCalled();
expect(mockExit).toHaveBeenCalledWith(ExitCodes.FATAL_CONFIG_ERROR);
});
it('should exit with FATAL_CONFIG_ERROR if extensions are disabled', async () => {
setDeferredCommand({
handler: vi.fn(),
argv: {} as ArgumentsCamelCase,
commandName: 'extensions',
});
const settings = createMockSettings({ extensions: { enabled: false } });
await runDeferredCommand(settings);
expect(mockDebugLogger.error).toHaveBeenCalledWith(
'Error: Extensions are disabled by your admin.',
);
expect(mockRunExitCleanup).toHaveBeenCalled();
expect(mockExit).toHaveBeenCalledWith(ExitCodes.FATAL_CONFIG_ERROR);
});
it('should exit with FATAL_CONFIG_ERROR if skills are disabled', async () => {
setDeferredCommand({
handler: vi.fn(),
argv: {} as ArgumentsCamelCase,
commandName: 'skills',
});
const settings = createMockSettings({ skills: { enabled: false } });
await runDeferredCommand(settings);
expect(mockDebugLogger.error).toHaveBeenCalledWith(
'Error: Agent skills are disabled by your admin.',
);
expect(mockRunExitCleanup).toHaveBeenCalled();
expect(mockExit).toHaveBeenCalledWith(ExitCodes.FATAL_CONFIG_ERROR);
});
it('should execute if admin settings are undefined (default implicit enable)', async () => {
const mockHandler = vi.fn();
setDeferredCommand({
handler: mockHandler,
argv: {} as ArgumentsCamelCase,
commandName: 'mcp',
});
const settings = createMockSettings({}); // No admin settings
await runDeferredCommand(settings);
expect(mockHandler).toHaveBeenCalled();
expect(mockExit).toHaveBeenCalledWith(ExitCodes.SUCCESS);
});
});
describe('defer', () => {
it('should wrap a command module and defer execution', async () => {
const originalHandler = vi.fn();
const commandModule: CommandModule = {
command: 'test',
describe: 'test command',
handler: originalHandler,
};
const deferredModule = defer(commandModule);
expect(deferredModule.command).toBe(commandModule.command);
// Execute the wrapper handler
const argv = { _: [], $0: 'gemini' } as ArgumentsCamelCase;
await deferredModule.handler(argv);
// Should check that it set the deferred command, but didn't run original handler yet
expect(originalHandler).not.toHaveBeenCalled();
// Now manually run it to verify it captured correctly
await runDeferredCommand(createMockSettings());
expect(originalHandler).toHaveBeenCalledWith(argv);
expect(mockExit).toHaveBeenCalledWith(ExitCodes.SUCCESS);
});
it('should use parentCommandName if provided', async () => {
const commandModule: CommandModule = {
command: 'subcommand',
describe: 'sub command',
handler: vi.fn(),
};
const deferredModule = defer(commandModule, 'parent');
await deferredModule.handler({} as ArgumentsCamelCase);
const deferredMcp = defer(commandModule, 'mcp');
await deferredMcp.handler({} as ArgumentsCamelCase);
const mcpSettings = createMockSettings({ mcp: { enabled: false } });
await runDeferredCommand(mcpSettings);
expect(mockDebugLogger.error).toHaveBeenCalledWith(
'Error: MCP is disabled by your admin.',
);
});
it('should fallback to unknown if no parentCommandName is provided', async () => {
const mockHandler = vi.fn();
const commandModule: CommandModule = {
command: ['foo', 'infoo'],
describe: 'foo command',
handler: mockHandler,
};
const deferredModule = defer(commandModule);
await deferredModule.handler({} as ArgumentsCamelCase);
// Verify it runs even if all known commands are disabled,
// confirming it didn't capture 'mcp', 'extensions', or 'skills'
// and defaulted to 'unknown' (or something else safe).
const settings = createMockSettings({
mcp: { enabled: false },
extensions: { enabled: false },
skills: { enabled: false },
});
await runDeferredCommand(settings);
expect(mockHandler).toHaveBeenCalled();
expect(mockExit).toHaveBeenCalledWith(ExitCodes.SUCCESS);
});
});
});
+78
View File
@@ -0,0 +1,78 @@
/**
* @license
* Copyright 2026 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import type { ArgumentsCamelCase, CommandModule } from 'yargs';
import { debugLogger, ExitCodes } from '@google/gemini-cli-core';
import { runExitCleanup } from './utils/cleanup.js';
import type { MergedSettings } from './config/settings.js';
import process from 'node:process';
export interface DeferredCommand {
handler: (argv: ArgumentsCamelCase) => void | Promise<void>;
argv: ArgumentsCamelCase;
commandName: string;
}
let deferredCommand: DeferredCommand | undefined;
export function setDeferredCommand(command: DeferredCommand) {
deferredCommand = command;
}
export async function runDeferredCommand(settings: MergedSettings) {
if (!deferredCommand) {
return;
}
const adminSettings = settings.admin;
const commandName = deferredCommand.commandName;
if (commandName === 'mcp' && adminSettings?.mcp?.enabled === false) {
debugLogger.error('Error: MCP is disabled by your admin.');
await runExitCleanup();
process.exit(ExitCodes.FATAL_CONFIG_ERROR);
}
if (
commandName === 'extensions' &&
adminSettings?.extensions?.enabled === false
) {
debugLogger.error('Error: Extensions are disabled by your admin.');
await runExitCleanup();
process.exit(ExitCodes.FATAL_CONFIG_ERROR);
}
if (commandName === 'skills' && adminSettings?.skills?.enabled === false) {
debugLogger.error('Error: Agent skills are disabled by your admin.');
await runExitCleanup();
process.exit(ExitCodes.FATAL_CONFIG_ERROR);
}
await deferredCommand.handler(deferredCommand.argv);
await runExitCleanup();
process.exit(ExitCodes.SUCCESS);
}
/**
* Wraps a command's handler to defer its execution.
* It stores the handler and arguments in a singleton `deferredCommand` variable.
*/
export function defer<T = object, U = object>(
commandModule: CommandModule<T, U>,
parentCommandName?: string,
): CommandModule<T, U> {
return {
...commandModule,
handler: (argv: ArgumentsCamelCase<U>) => {
setDeferredCommand({
handler: commandModule.handler as (
argv: ArgumentsCamelCase,
) => void | Promise<void>,
argv: argv as unknown as ArgumentsCamelCase,
commandName: parentCommandName || 'unknown',
});
},
};
}
+2
View File
@@ -1085,6 +1085,8 @@ describe('gemini.tsx main function exit codes', () => {
vi.mocked(loadSandboxConfig).mockResolvedValue({} as any);
vi.mocked(loadCliConfig).mockResolvedValue({
refreshAuth: vi.fn().mockRejectedValue(new Error('Auth failed')),
getRemoteAdminSettings: vi.fn().mockReturnValue(undefined),
isInteractive: vi.fn().mockReturnValue(true),
} as unknown as Config);
vi.mocked(loadSettings).mockReturnValue(
createMockSettings({
+10 -2
View File
@@ -96,6 +96,7 @@ import { isAlternateBufferEnabled } from './ui/hooks/useAlternateBuffer.js';
import { setupTerminalAndTheme } from './utils/terminalTheme.js';
import { profiler } from './ui/components/DebugProfiler.js';
import { runDeferredCommand } from './deferred.js';
const SLOW_RENDER_MS = 200;
@@ -372,6 +373,7 @@ export async function main() {
// Refresh auth to fetch remote admin settings from CCPA and before entering
// the sandbox because the sandbox will interfere with the Oauth2 web
// redirect.
let initialAuthFailed = false;
if (
settings.merged.security.auth.selectedType &&
!settings.merged.security.auth.useExternal
@@ -399,8 +401,7 @@ export async function main() {
}
} catch (err) {
debugLogger.error('Error authenticating:', err);
await runExitCleanup();
process.exit(ExitCodes.FATAL_AUTHENTICATION_ERROR);
initialAuthFailed = true;
}
}
@@ -410,6 +411,9 @@ export async function main() {
settings.setRemoteAdminSettings(remoteAdminSettings);
}
// Run deferred command now that we have admin settings.
await runDeferredCommand(settings.merged);
// hop into sandbox if we are outside and sandboxing is enabled
if (!process.env['SANDBOX']) {
const memoryArgs = settings.merged.advanced.autoConfigureMemory
@@ -423,6 +427,10 @@ export async function main() {
// another way to decouple refreshAuth from requiring a config.
if (sandboxConfig) {
if (initialAuthFailed) {
await runExitCleanup();
process.exit(ExitCodes.FATAL_AUTHENTICATION_ERROR);
}
let stdinData = '';
if (!process.stdin.isTTY) {
stdinData = await readStdin();
@@ -27,6 +27,7 @@ import { directoryCommand } from '../ui/commands/directoryCommand.js';
import { editorCommand } from '../ui/commands/editorCommand.js';
import { extensionsCommand } from '../ui/commands/extensionsCommand.js';
import { helpCommand } from '../ui/commands/helpCommand.js';
import { rewindCommand } from '../ui/commands/rewindCommand.js';
import { hooksCommand } from '../ui/commands/hooksCommand.js';
import { ideCommand } from '../ui/commands/ideCommand.js';
import { initCommand } from '../ui/commands/initCommand.js';
@@ -106,6 +107,7 @@ export class BuiltinCommandLoader implements ICommandLoader {
: [extensionsCommand(this.config?.getEnableExtensionReloading())]),
helpCommand,
...(this.config?.getEnableHooksUI() ? [hooksCommand] : []),
rewindCommand,
await ideCommand(),
initCommand,
...(this.config?.getMcpEnabled() === false
+1
View File
@@ -166,6 +166,7 @@ const mockUIActions: UIActions = {
handleFinalSubmit: vi.fn(),
handleClearScreen: vi.fn(),
handleProQuotaChoice: vi.fn(),
handleValidationChoice: vi.fn(),
setQueueErrorMessage: vi.fn(),
popAllMessages: vi.fn(),
handleApiKeySubmit: vi.fn(),
+41 -3
View File
@@ -103,6 +103,7 @@ import { RELAUNCH_EXIT_CODE } from '../utils/processUtils.js';
import type { SessionInfo } from '../utils/sessionUtils.js';
import { useMessageQueue } from './hooks/useMessageQueue.js';
import { useAutoAcceptIndicator } from './hooks/useAutoAcceptIndicator.js';
import { useMcpStatus } from './hooks/useMcpStatus.js';
import { useSessionStats } from './contexts/SessionContext.js';
import { useGitBranchName } from './hooks/useGitBranchName.js';
import {
@@ -129,6 +130,7 @@ import {
} from './constants.js';
import { LoginWithGoogleRestartDialog } from './auth/LoginWithGoogleRestartDialog.js';
import { useInactivityTimer } from './hooks/useInactivityTimer.js';
import { isSlashCommand } from './utils/commandUtils.js';
function isToolExecuting(pendingHistoryItems: HistoryItemWithoutId[]) {
return pendingHistoryItems.some((item) => {
@@ -495,7 +497,12 @@ export const AppContainer = (props: AppContainerProps) => {
}
}, [authState, authContext, setAuthState]);
const { proQuotaRequest, handleProQuotaChoice } = useQuotaAndFallback({
const {
proQuotaRequest,
handleProQuotaChoice,
validationRequest,
handleValidationChoice,
} = useQuotaAndFallback({
config,
historyManager,
userTier,
@@ -684,6 +691,7 @@ Logging in with Google... Restarting Gemini CLI to continue.
toggleDebugProfiler,
dispatchExtensionStateUpdate,
addConfirmUpdateExtensionRequest,
setText: (text: string) => buffer.setText(text),
}),
[
setAuthState,
@@ -700,6 +708,7 @@ Logging in with Google... Restarting Gemini CLI to continue.
openPermissionsDialog,
addConfirmUpdateExtensionRequest,
toggleDebugProfiler,
buffer,
],
);
@@ -856,6 +865,8 @@ Logging in with Google... Restarting Gemini CLI to continue.
isActive: !embeddedShellFocused,
});
const { isMcpReady } = useMcpStatus(config);
const {
messageQueue,
addMessage,
@@ -866,6 +877,7 @@ Logging in with Google... Restarting Gemini CLI to continue.
isConfigInitialized,
streamingState,
submitQuery,
isMcpReady,
});
cancelHandlerRef.current = useCallback(
@@ -904,10 +916,31 @@ Logging in with Google... Restarting Gemini CLI to continue.
const handleFinalSubmit = useCallback(
(submittedValue: string) => {
addMessage(submittedValue);
const isSlash = isSlashCommand(submittedValue.trim());
const isIdle = streamingState === StreamingState.Idle;
if (isSlash || (isIdle && isMcpReady)) {
void submitQuery(submittedValue);
} else {
// Check messageQueue.length === 0 to only notify on the first queued item
if (isIdle && !isMcpReady && messageQueue.length === 0) {
coreEvents.emitFeedback(
'info',
'Waiting for MCP servers to initialize... Slash commands are still available and prompts will be queued.',
);
}
addMessage(submittedValue);
}
addInput(submittedValue); // Track input for up-arrow history
},
[addMessage, addInput],
[
addMessage,
addInput,
submitQuery,
isMcpReady,
streamingState,
messageQueue.length,
],
);
const handleClearScreen = useCallback(() => {
@@ -1471,6 +1504,7 @@ Logging in with Google... Restarting Gemini CLI to continue.
showPrivacyNotice ||
showIdeRestartPrompt ||
!!proQuotaRequest ||
!!validationRequest ||
isSessionBrowserOpen ||
isAuthDialogOpen ||
authState === AuthState.AwaitingApiKeyInput;
@@ -1588,6 +1622,7 @@ Logging in with Google... Restarting Gemini CLI to continue.
currentModel,
userTier,
proQuotaRequest,
validationRequest,
contextFileNames,
errorCount,
availableTerminalHeight,
@@ -1678,6 +1713,7 @@ Logging in with Google... Restarting Gemini CLI to continue.
showAutoAcceptIndicator,
userTier,
proQuotaRequest,
validationRequest,
contextFileNames,
errorCount,
availableTerminalHeight,
@@ -1747,6 +1783,7 @@ Logging in with Google... Restarting Gemini CLI to continue.
handleFinalSubmit,
handleClearScreen,
handleProQuotaChoice,
handleValidationChoice,
openSessionBrowser,
closeSessionBrowser,
handleResumeSession,
@@ -1787,6 +1824,7 @@ Logging in with Google... Restarting Gemini CLI to continue.
handleFinalSubmit,
handleClearScreen,
handleProQuotaChoice,
handleValidationChoice,
openSessionBrowser,
closeSessionBrowser,
handleResumeSession,
@@ -149,7 +149,7 @@ describe('agentsCommand', () => {
});
// Add agent to disabled overrides so validation passes
mockContext.services.settings.merged.agents.overrides['test-agent'] = {
disabled: true,
enabled: false,
};
vi.mocked(enableAgent).mockReturnValue({
@@ -264,7 +264,7 @@ describe('agentsCommand', () => {
it('should show info message if agent is already disabled', async () => {
mockConfig.getAgentRegistry().getAllAgentNames.mockReturnValue([]);
mockContext.services.settings.merged.agents.overrides['test-agent'] = {
disabled: true,
enabled: false,
};
const disableCommand = agentsCommand.subCommands?.find(
@@ -85,10 +85,10 @@ async function enableAction(
const allAgents = agentRegistry.getAllAgentNames();
const overrides = settings.merged.agents.overrides;
const disabledAgents = Object.keys(overrides).filter(
(name) => overrides[name]?.disabled === true,
(name) => overrides[name]?.enabled === false,
);
if (allAgents.includes(agentName)) {
if (allAgents.includes(agentName) && !disabledAgents.includes(agentName)) {
return {
type: 'message',
messageType: 'info',
@@ -96,7 +96,7 @@ async function enableAction(
};
}
if (!disabledAgents.includes(agentName)) {
if (!disabledAgents.includes(agentName) && !allAgents.includes(agentName)) {
return {
type: 'message',
messageType: 'error',
@@ -155,7 +155,7 @@ async function disableAction(
const allAgents = agentRegistry.getAllAgentNames();
const overrides = settings.merged.agents.overrides;
const disabledAgents = Object.keys(overrides).filter(
(name) => overrides[name]?.disabled === true,
(name) => overrides[name]?.enabled === false,
);
if (disabledAgents.includes(agentName)) {
@@ -206,7 +206,7 @@ function completeAgentsToEnable(context: CommandContext, partialArg: string) {
const overrides = settings.merged.agents.overrides;
const disabledAgents = Object.entries(overrides)
.filter(([_, override]) => override?.disabled === true)
.filter(([_, override]) => override?.enabled === false)
.map(([name]) => name);
return disabledAgents.filter((name) => name.startsWith(partialArg));
@@ -26,7 +26,7 @@ describe('hooksCommand', () => {
};
let mockSettings: {
merged: {
hooks?: {
hooksConfig?: {
disabled?: string[];
};
tools?: {
@@ -58,7 +58,7 @@ describe('hooksCommand', () => {
// Create mock settings
mockSettings = {
merged: {
hooks: {
hooksConfig: {
disabled: [],
},
},
@@ -273,7 +273,7 @@ describe('hooksCommand', () => {
it('should enable a hook and update settings', async () => {
// Update the context's settings with disabled hooks
mockContext.services.settings.merged.hooks.disabled = [
mockContext.services.settings.merged.hooksConfig.disabled = [
'test-hook',
'other-hook',
];
@@ -289,7 +289,7 @@ describe('hooksCommand', () => {
expect(mockContext.services.settings.setValue).toHaveBeenCalledWith(
expect.any(String),
'hooks.disabled',
'hooksConfig.disabled',
['other-hook'],
);
expect(mockHookSystem.setHookEnabled).toHaveBeenCalledWith(
@@ -404,7 +404,7 @@ describe('hooksCommand', () => {
});
it('should disable a hook and update settings', async () => {
mockContext.services.settings.merged.hooks.disabled = [];
mockContext.services.settings.merged.hooksConfig.disabled = [];
const disableCmd = hooksCommand.subCommands!.find(
(cmd) => cmd.name === 'disable',
@@ -417,7 +417,7 @@ describe('hooksCommand', () => {
expect(mockContext.services.settings.setValue).toHaveBeenCalledWith(
expect.any(String),
'hooks.disabled',
'hooksConfig.disabled',
['test-hook'],
);
expect(mockHookSystem.setHookEnabled).toHaveBeenCalledWith(
@@ -433,7 +433,7 @@ describe('hooksCommand', () => {
it('should synchronize with hook system even if hook is already in disabled list', async () => {
// Update the context's settings with the hook already disabled
mockContext.services.settings.merged.hooks.disabled = ['test-hook'];
mockContext.services.settings.merged.hooksConfig.disabled = ['test-hook'];
const disableCmd = hooksCommand.subCommands!.find(
(cmd) => cmd.name === 'disable',
@@ -458,7 +458,7 @@ describe('hooksCommand', () => {
});
it('should handle error when disabling hook fails', async () => {
mockContext.services.settings.merged.hooks.disabled = [];
mockContext.services.settings.merged.hooksConfig.disabled = [];
mockSettings.setValue.mockImplementationOnce(() => {
throw new Error('Failed to save settings');
});
@@ -637,7 +637,7 @@ describe('hooksCommand', () => {
expect(mockContext.services.settings.setValue).toHaveBeenCalledWith(
expect.any(String),
'hooks.disabled',
'hooksConfig.disabled',
[],
);
expect(mockHookSystem.setHookEnabled).toHaveBeenCalledWith(
@@ -761,7 +761,7 @@ describe('hooksCommand', () => {
expect(mockContext.services.settings.setValue).toHaveBeenCalledWith(
expect.any(String),
'hooks.disabled',
'hooksConfig.disabled',
['hook-1', 'hook-2', 'hook-3'],
);
expect(mockHookSystem.setHookEnabled).toHaveBeenCalledWith(
+10 -10
View File
@@ -76,7 +76,7 @@ async function enableAction(
// Get current disabled hooks from settings
const settings = context.services.settings;
const disabledHooks = settings.merged.hooks.disabled;
const disabledHooks = settings.merged.hooksConfig.disabled;
// Remove from disabled list if present
const newDisabledHooks = disabledHooks.filter(
(name: string) => name !== hookName,
@@ -87,10 +87,10 @@ async function enableAction(
const scope = settings.workspace
? SettingScope.Workspace
: SettingScope.User;
settings.setValue(scope, 'hooks.disabled', newDisabledHooks);
settings.setValue(scope, 'hooksConfig.disabled', newDisabledHooks);
// Update core config so re-initialization (e.g. extension reload) respects the change
config.updateDisabledHooks(settings.merged.hooks.disabled);
config.updateDisabledHooks(settings.merged.hooksConfig.disabled);
// Enable in hook system
hookSystem.setHookEnabled(hookName, true);
@@ -145,7 +145,7 @@ async function disableAction(
// Get current disabled hooks from settings
const settings = context.services.settings;
const disabledHooks = settings.merged.hooks.disabled;
const disabledHooks = settings.merged.hooksConfig.disabled;
// Add to disabled list if not already present
try {
if (!disabledHooks.includes(hookName)) {
@@ -154,11 +154,11 @@ async function disableAction(
const scope = settings.workspace
? SettingScope.Workspace
: SettingScope.User;
settings.setValue(scope, 'hooks.disabled', newDisabledHooks);
settings.setValue(scope, 'hooksConfig.disabled', newDisabledHooks);
}
// Update core config so re-initialization (e.g. extension reload) respects the change
config.updateDisabledHooks(settings.merged.hooks.disabled);
config.updateDisabledHooks(settings.merged.hooksConfig.disabled);
// Always disable in hook system to ensure in-memory state matches settings
hookSystem.setHookEnabled(hookName, false);
@@ -250,10 +250,10 @@ async function enableAllAction(
const scope = settings.workspace
? SettingScope.Workspace
: SettingScope.User;
settings.setValue(scope, 'hooks.disabled', []);
settings.setValue(scope, 'hooksConfig.disabled', []);
// Update core config so re-initialization (e.g. extension reload) respects the change
config.updateDisabledHooks(settings.merged.hooks.disabled);
config.updateDisabledHooks(settings.merged.hooksConfig.disabled);
for (const hook of disabledHooks) {
const hookName = getHookDisplayName(hook);
@@ -323,10 +323,10 @@ async function disableAllAction(
const scope = settings.workspace
? SettingScope.Workspace
: SettingScope.User;
settings.setValue(scope, 'hooks.disabled', allHookNames);
settings.setValue(scope, 'hooksConfig.disabled', allHookNames);
// Update core config so re-initialization (e.g. extension reload) respects the change
config.updateDisabledHooks(settings.merged.hooks.disabled);
config.updateDisabledHooks(settings.merged.hooksConfig.disabled);
for (const hook of enabledHooks) {
const hookName = getHookDisplayName(hook);
@@ -0,0 +1,351 @@
/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import { describe, it, expect, beforeEach, vi } from 'vitest';
import { rewindCommand } from './rewindCommand.js';
import { createMockCommandContext } from '../../test-utils/mockCommandContext.js';
import { waitFor } from '../../test-utils/async.js';
import { RewindOutcome } from '../components/RewindConfirmation.js';
import {
type OpenCustomDialogActionReturn,
type CommandContext,
} from './types.js';
import type { ReactElement } from 'react';
import { coreEvents } from '@google/gemini-cli-core';
// Mock dependencies
const mockRewindTo = vi.fn();
const mockRecordMessage = vi.fn();
const mockSetHistory = vi.fn();
const mockSendMessageStream = vi.fn();
const mockGetChatRecordingService = vi.fn();
const mockGetConversation = vi.fn();
const mockRemoveComponent = vi.fn();
const mockLoadHistory = vi.fn();
const mockAddItem = vi.fn();
const mockSetPendingItem = vi.fn();
const mockResetContext = vi.fn();
const mockSetInput = vi.fn();
const mockRevertFileChanges = vi.fn();
const mockGetProjectRoot = vi.fn().mockReturnValue('/mock/root');
vi.mock('@google/gemini-cli-core', async (importOriginal) => {
const actual =
await importOriginal<typeof import('@google/gemini-cli-core')>();
return {
...actual,
coreEvents: {
...actual.coreEvents,
emitFeedback: vi.fn(),
},
};
});
vi.mock('../components/RewindViewer.js', () => ({
RewindViewer: () => null,
}));
vi.mock('../hooks/useSessionBrowser.js', () => ({
convertSessionToHistoryFormats: vi.fn().mockReturnValue({
uiHistory: [
{ type: 'user', text: 'old user' },
{ type: 'gemini', text: 'old gemini' },
],
clientHistory: [{ role: 'user', parts: [{ text: 'old user' }] }],
}),
}));
vi.mock('../utils/rewindFileOps.js', () => ({
revertFileChanges: (...args: unknown[]) => mockRevertFileChanges(...args),
}));
interface RewindViewerProps {
onRewind: (
messageId: string,
newText: string,
outcome: RewindOutcome,
) => Promise<void>;
conversation: unknown;
onExit: () => void;
}
describe('rewindCommand', () => {
let mockContext: CommandContext;
beforeEach(() => {
vi.clearAllMocks();
mockGetConversation.mockReturnValue({
messages: [{ id: 'msg-1', type: 'user', content: 'hello' }],
sessionId: 'test-session',
});
mockRewindTo.mockReturnValue({
messages: [], // Mocked rewound messages
});
mockGetChatRecordingService.mockReturnValue({
getConversation: mockGetConversation,
rewindTo: mockRewindTo,
recordMessage: mockRecordMessage,
});
mockContext = createMockCommandContext({
services: {
config: {
getGeminiClient: () => ({
getChatRecordingService: mockGetChatRecordingService,
setHistory: mockSetHistory,
sendMessageStream: mockSendMessageStream,
}),
getSessionId: () => 'test-session-id',
getContextManager: () => ({ refresh: mockResetContext }),
getProjectRoot: mockGetProjectRoot,
},
},
ui: {
removeComponent: mockRemoveComponent,
loadHistory: mockLoadHistory,
addItem: mockAddItem,
setPendingItem: mockSetPendingItem,
},
}) as unknown as CommandContext;
});
it('should initialize successfully', async () => {
const result = await rewindCommand.action!(mockContext, '');
expect(result).toHaveProperty('type', 'custom_dialog');
});
it('should handle RewindOnly correctly', async () => {
// 1. Run the command to get the component
const result = (await rewindCommand.action!(
mockContext,
'',
)) as OpenCustomDialogActionReturn;
const component = result.component as ReactElement<RewindViewerProps>;
// Access onRewind from props
const onRewind = component.props.onRewind;
expect(onRewind).toBeDefined();
await onRewind('msg-id-123', 'New Prompt', RewindOutcome.RewindOnly);
await waitFor(() => {
expect(mockRevertFileChanges).not.toHaveBeenCalled();
expect(mockRewindTo).toHaveBeenCalledWith('msg-id-123');
expect(mockSetHistory).toHaveBeenCalled();
expect(mockResetContext).toHaveBeenCalled();
expect(mockLoadHistory).toHaveBeenCalledWith(
[
expect.objectContaining({ text: 'old user', id: 1 }),
expect.objectContaining({ text: 'old gemini', id: 2 }),
],
'New Prompt',
);
expect(mockRemoveComponent).toHaveBeenCalled();
});
// Verify setInput was NOT called directly (it's handled via loadHistory now)
expect(mockSetInput).not.toHaveBeenCalled();
});
it('should handle RewindAndRevert correctly', async () => {
const result = (await rewindCommand.action!(
mockContext,
'',
)) as OpenCustomDialogActionReturn;
const component = result.component as ReactElement<RewindViewerProps>;
const onRewind = component.props.onRewind;
await onRewind('msg-id-123', 'New Prompt', RewindOutcome.RewindAndRevert);
await waitFor(() => {
expect(mockRevertFileChanges).toHaveBeenCalledWith(
mockGetConversation(),
'msg-id-123',
);
expect(mockRewindTo).toHaveBeenCalledWith('msg-id-123');
expect(mockLoadHistory).toHaveBeenCalledWith(
expect.any(Array),
'New Prompt',
);
});
expect(mockSetInput).not.toHaveBeenCalled();
});
it('should handle RevertOnly correctly', async () => {
const result = (await rewindCommand.action!(
mockContext,
'',
)) as OpenCustomDialogActionReturn;
const component = result.component as ReactElement<RewindViewerProps>;
const onRewind = component.props.onRewind;
await onRewind('msg-id-123', 'New Prompt', RewindOutcome.RevertOnly);
await waitFor(() => {
expect(mockRevertFileChanges).toHaveBeenCalledWith(
mockGetConversation(),
'msg-id-123',
);
expect(mockRewindTo).not.toHaveBeenCalled();
expect(mockRemoveComponent).toHaveBeenCalled();
expect(coreEvents.emitFeedback).toHaveBeenCalledWith(
'info',
'File changes reverted.',
);
});
expect(mockSetInput).not.toHaveBeenCalled();
});
it('should handle Cancel correctly', async () => {
const result = (await rewindCommand.action!(
mockContext,
'',
)) as OpenCustomDialogActionReturn;
const component = result.component as ReactElement<RewindViewerProps>;
const onRewind = component.props.onRewind;
await onRewind('msg-id-123', 'New Prompt', RewindOutcome.Cancel);
await waitFor(() => {
expect(mockRevertFileChanges).not.toHaveBeenCalled();
expect(mockRewindTo).not.toHaveBeenCalled();
expect(mockRemoveComponent).toHaveBeenCalled();
});
expect(mockSetInput).not.toHaveBeenCalled();
});
it('should handle onExit correctly', async () => {
const result = (await rewindCommand.action!(
mockContext,
'',
)) as OpenCustomDialogActionReturn;
const component = result.component as ReactElement<RewindViewerProps>;
const onExit = component.props.onExit;
onExit();
expect(mockRemoveComponent).toHaveBeenCalled();
});
it('should handle rewind error correctly', async () => {
const result = (await rewindCommand.action!(
mockContext,
'',
)) as OpenCustomDialogActionReturn;
const component = result.component as ReactElement<RewindViewerProps>;
const onRewind = component.props.onRewind;
mockRewindTo.mockImplementation(() => {
throw new Error('Rewind Failed');
});
await onRewind('msg-1', 'Prompt', RewindOutcome.RewindOnly);
await waitFor(() => {
expect(coreEvents.emitFeedback).toHaveBeenCalledWith(
'error',
'Rewind Failed',
);
});
});
it('should handle null conversation from rewindTo', async () => {
const result = (await rewindCommand.action!(
mockContext,
'',
)) as OpenCustomDialogActionReturn;
const component = result.component as ReactElement<RewindViewerProps>;
const onRewind = component.props.onRewind;
mockRewindTo.mockReturnValue(null);
await onRewind('msg-1', 'Prompt', RewindOutcome.RewindOnly);
await waitFor(() => {
expect(coreEvents.emitFeedback).toHaveBeenCalledWith(
'error',
'Could not fetch conversation file',
);
expect(mockRemoveComponent).toHaveBeenCalled();
});
});
it('should fail if config is missing', () => {
const context = { services: {} } as CommandContext;
const result = rewindCommand.action!(context, '');
expect(result).toEqual({
type: 'message',
messageType: 'error',
content: 'Config not found',
});
});
it('should fail if client is not initialized', () => {
const context = createMockCommandContext({
services: {
config: { getGeminiClient: () => undefined },
},
}) as unknown as CommandContext;
const result = rewindCommand.action!(context, '');
expect(result).toEqual({
type: 'message',
messageType: 'error',
content: 'Client not initialized',
});
});
it('should fail if recording service is unavailable', () => {
const context = createMockCommandContext({
services: {
config: {
getGeminiClient: () => ({ getChatRecordingService: () => undefined }),
},
},
}) as unknown as CommandContext;
const result = rewindCommand.action!(context, '');
expect(result).toEqual({
type: 'message',
messageType: 'error',
content: 'Recording service unavailable',
});
});
it('should return info if no conversation found', () => {
mockGetConversation.mockReturnValue(null);
const result = rewindCommand.action!(mockContext, '');
expect(result).toEqual({
type: 'message',
messageType: 'info',
content: 'No conversation found.',
});
});
it('should return info if no user interactions found', () => {
mockGetConversation.mockReturnValue({
messages: [{ id: 'msg-1', type: 'gemini', content: 'hello' }],
sessionId: 'test-session',
});
const result = rewindCommand.action!(mockContext, '');
expect(result).toEqual({
type: 'message',
messageType: 'info',
content: 'Nothing to rewind to.',
});
});
});
@@ -0,0 +1,191 @@
/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import {
CommandKind,
type CommandContext,
type SlashCommand,
} from './types.js';
import { RewindViewer } from '../components/RewindViewer.js';
import { type HistoryItem } from '../types.js';
import { convertSessionToHistoryFormats } from '../hooks/useSessionBrowser.js';
import { revertFileChanges } from '../utils/rewindFileOps.js';
import { RewindOutcome } from '../components/RewindConfirmation.js';
import { checkExhaustive } from '../../utils/checks.js';
import type { Content } from '@google/genai';
import type {
ChatRecordingService,
GeminiClient,
} from '@google/gemini-cli-core';
import { coreEvents, debugLogger } from '@google/gemini-cli-core';
/**
* Helper function to handle the core logic of rewinding a conversation.
* This function encapsulates the steps needed to rewind the conversation,
* update the client and UI history, and clear the component.
*
* @param context The command context.
* @param client Gemini client
* @param recordingService The chat recording service.
* @param messageId The ID of the message to rewind to.
* @param newText The new text for the input field after rewinding.
*/
async function rewindConversation(
context: CommandContext,
client: GeminiClient,
recordingService: ChatRecordingService,
messageId: string,
newText: string,
) {
try {
const conversation = recordingService.rewindTo(messageId);
if (!conversation) {
const errorMsg = 'Could not fetch conversation file';
debugLogger.error(errorMsg);
context.ui.removeComponent();
coreEvents.emitFeedback('error', errorMsg);
return;
}
// Convert to UI and Client formats
const { uiHistory, clientHistory } = convertSessionToHistoryFormats(
conversation.messages,
);
client.setHistory(clientHistory as Content[]);
// Reset context manager as we are rewinding history
await context.services.config?.getContextManager()?.refresh();
// Update UI History
// We generate IDs based on index for the rewind history
const startId = 1;
const historyWithIds = uiHistory.map(
(item, idx) =>
({
...item,
id: startId + idx,
}) as HistoryItem,
);
// 1. Remove component FIRST to avoid flicker and clear the stage
context.ui.removeComponent();
// 2. Load the rewound history and set the input
context.ui.loadHistory(historyWithIds, newText);
} catch (error) {
// If an error occurs, we still want to remove the component if possible
context.ui.removeComponent();
coreEvents.emitFeedback(
'error',
error instanceof Error ? error.message : 'Unknown error during rewind',
);
}
}
export const rewindCommand: SlashCommand = {
name: 'rewind',
description: 'Jump back to a specific message and restart the conversation',
kind: CommandKind.BUILT_IN,
action: (context) => {
const config = context.services.config;
if (!config)
return {
type: 'message',
messageType: 'error',
content: 'Config not found',
};
const client = config.getGeminiClient();
if (!client)
return {
type: 'message',
messageType: 'error',
content: 'Client not initialized',
};
const recordingService = client.getChatRecordingService();
if (!recordingService)
return {
type: 'message',
messageType: 'error',
content: 'Recording service unavailable',
};
const conversation = recordingService.getConversation();
if (!conversation)
return {
type: 'message',
messageType: 'info',
content: 'No conversation found.',
};
const hasUserInteractions = conversation.messages.some(
(msg) => msg.type === 'user',
);
if (!hasUserInteractions) {
return {
type: 'message',
messageType: 'info',
content: 'Nothing to rewind to.',
};
}
return {
type: 'custom_dialog',
component: (
<RewindViewer
conversation={conversation}
onExit={() => {
context.ui.removeComponent();
}}
onRewind={async (messageId, newText, outcome) => {
switch (outcome) {
case RewindOutcome.Cancel:
context.ui.removeComponent();
return;
case RewindOutcome.RevertOnly:
if (conversation) {
await revertFileChanges(conversation, messageId);
}
context.ui.removeComponent();
coreEvents.emitFeedback('info', 'File changes reverted.');
return;
case RewindOutcome.RewindAndRevert:
if (conversation) {
await revertFileChanges(conversation, messageId);
}
await rewindConversation(
context,
client,
recordingService,
messageId,
newText,
);
return;
case RewindOutcome.RewindOnly:
await rewindConversation(
context,
client,
recordingService,
messageId,
newText,
);
return;
default:
checkExhaustive(outcome);
}
}}
/>
),
};
},
};
+2 -1
View File
@@ -66,8 +66,9 @@ export interface CommandContext {
* Loads a new set of history items, replacing the current history.
*
* @param history The array of history items to load.
* @param postLoadInput Optional text to set in the input buffer after loading history.
*/
loadHistory: UseHistoryManagerReturn['loadHistory'];
loadHistory: (history: HistoryItem[], postLoadInput?: string) => void;
/** Toggles a special display mode. */
toggleCorgiMode: () => void;
toggleDebugProfiler: () => void;
@@ -100,7 +100,7 @@ const createMockUIState = (overrides: Partial<UIState> = {}): UIState =>
showErrorDetails: false,
constrainHeight: false,
isInputActive: true,
buffer: '',
buffer: { text: '' },
inputWidth: 80,
suggestionsWidth: 40,
userMessages: [],
@@ -389,6 +389,7 @@ describe('Composer', () => {
it('shows escape prompt when showEscapePrompt is true', () => {
const uiState = createMockUIState({
showEscapePrompt: true,
history: [{ id: 1, type: 'user', text: 'test' }],
});
const { lastFrame } = renderComposer(uiState);
@@ -6,6 +6,7 @@
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import { appEvents, AppEvent } from '../../utils/events.js';
import { coreEvents } from '@google/gemini-cli-core';
import {
profiler,
DebugProfiler,
@@ -16,6 +17,7 @@ import { render } from '../../test-utils/render.js';
import { useUIState, type UIState } from '../contexts/UIStateContext.js';
import { FixedDeque } from 'mnemonist';
import { debugState } from '../debug.js';
import { act } from 'react';
vi.mock('../contexts/UIStateContext.js', () => ({
useUIState: vi.fn(),
@@ -266,4 +268,40 @@ describe('DebugProfiler Component', () => {
expect(output).toContain('5 (idle)');
expect(output).toContain('2 (flicker)');
});
it('should report an action when a CoreEvent is emitted', async () => {
vi.mocked(useUIState).mockReturnValue({
showDebugProfiler: true,
constrainHeight: false,
} as unknown as UIState);
const reportActionSpy = vi.spyOn(profiler, 'reportAction');
const { unmount } = render(<DebugProfiler />);
act(() => {
coreEvents.emitModelChanged('new-model');
});
expect(reportActionSpy).toHaveBeenCalled();
unmount();
});
it('should report an action when an AppEvent is emitted', async () => {
vi.mocked(useUIState).mockReturnValue({
showDebugProfiler: true,
constrainHeight: false,
} as unknown as UIState);
const reportActionSpy = vi.spyOn(profiler, 'reportAction');
const { unmount } = render(<DebugProfiler />);
act(() => {
appEvents.emit(AppEvent.SelectionWarning);
});
expect(reportActionSpy).toHaveBeenCalled();
unmount();
});
});
@@ -11,7 +11,7 @@ import { theme } from '../semantic-colors.js';
import { useUIState } from '../contexts/UIStateContext.js';
import { debugState } from '../debug.js';
import { appEvents, AppEvent } from '../../utils/events.js';
import { debugLogger } from '@google/gemini-cli-core';
import { coreEvents, CoreEvent, debugLogger } from '@google/gemini-cli-core';
// Frames that render at least this far before or after an action are considered
// idle frames.
@@ -160,9 +160,29 @@ export const DebugProfiler = () => {
stdin.on('data', handler);
stdout.on('resize', handler);
// Register handlers for all core and app events to ensure they are
// considered "actions" and don't trigger spurious idle frame warnings.
// These events are expected to trigger UI renders.
for (const eventName of Object.values(CoreEvent)) {
coreEvents.on(eventName, handler);
}
for (const eventName of Object.values(AppEvent)) {
appEvents.on(eventName, handler);
}
return () => {
stdin.off('data', handler);
stdout.off('resize', handler);
for (const eventName of Object.values(CoreEvent)) {
coreEvents.off(eventName, handler);
}
for (const eventName of Object.values(AppEvent)) {
appEvents.off(eventName, handler);
}
profiler.profilersActive--;
};
}, []);
@@ -17,6 +17,7 @@ import { ApiAuthDialog } from '../auth/ApiAuthDialog.js';
import { EditorSettingsDialog } from './EditorSettingsDialog.js';
import { PrivacyNotice } from '../privacy/PrivacyNotice.js';
import { ProQuotaDialog } from './ProQuotaDialog.js';
import { ValidationDialog } from './ValidationDialog.js';
import { runExitCleanup } from '../../utils/cleanup.js';
import { RELAUNCH_EXIT_CODE } from '../../utils/processUtils.js';
import { SessionBrowser } from './SessionBrowser.js';
@@ -68,6 +69,16 @@ export const DialogManager = ({
/>
);
}
if (uiState.validationRequest) {
return (
<ValidationDialog
validationLink={uiState.validationRequest.validationLink}
validationDescription={uiState.validationRequest.validationDescription}
learnMoreUrl={uiState.validationRequest.learnMoreUrl}
onChoice={uiActions.handleValidationChoice}
/>
);
}
if (uiState.shouldShowIdePrompt) {
return (
<IdeIntegrationNudge
@@ -1893,10 +1893,37 @@ describe('InputPrompt', () => {
unmount();
});
it('should submit /rewind on double ESC', async () => {
it('should submit /rewind on double ESC when buffer is empty', async () => {
const onEscapePromptChange = vi.fn();
props.onEscapePromptChange = onEscapePromptChange;
props.buffer.setText('');
vi.mocked(props.buffer.setText).mockClear();
const { stdin, unmount } = renderWithProviders(
<InputPrompt {...props} />,
{
uiState: {
history: [{ id: 1, type: 'user', text: 'test' }],
},
},
);
await act(async () => {
stdin.write('\x1B\x1B');
vi.advanceTimersByTime(100);
});
await waitFor(() => {
expect(props.onSubmit).toHaveBeenCalledWith('/rewind');
});
unmount();
});
it('should clear the buffer on esc esc if it has text', async () => {
const onEscapePromptChange = vi.fn();
props.onEscapePromptChange = onEscapePromptChange;
props.buffer.setText('some text');
vi.mocked(props.buffer.setText).mockClear();
const { stdin, unmount } = renderWithProviders(
<InputPrompt {...props} />,
@@ -1906,7 +1933,8 @@ describe('InputPrompt', () => {
stdin.write('\x1B\x1B');
vi.advanceTimersByTime(100);
expect(props.onSubmit).toHaveBeenCalledWith('/rewind');
expect(props.buffer.setText).toHaveBeenCalledWith('');
expect(props.onSubmit).not.toHaveBeenCalledWith('/rewind');
});
unmount();
});
+17 -7
View File
@@ -27,7 +27,7 @@ import { useKeypress } from '../hooks/useKeypress.js';
import { keyMatchers, Command } from '../keyMatchers.js';
import type { CommandContext, SlashCommand } from '../commands/types.js';
import type { Config } from '@google/gemini-cli-core';
import { ApprovalMode, debugLogger } from '@google/gemini-cli-core';
import { ApprovalMode, coreEvents, debugLogger } from '@google/gemini-cli-core';
import {
parseInputForHighlighting,
parseSegmentsFromTokens,
@@ -138,7 +138,7 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
const kittyProtocol = useKittyKeyboardProtocol();
const isShellFocused = useShellFocusState();
const { setEmbeddedShellFocused } = useUIActions();
const { mainAreaWidth, activePtyId } = useUIState();
const { mainAreaWidth, activePtyId, history } = useUIState();
const [justNavigatedHistory, setJustNavigatedHistory] = useState(false);
const escPressCount = useRef(0);
const [showEscapePrompt, setShowEscapePrompt] = useState(false);
@@ -495,7 +495,7 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
return;
}
// Handle double ESC for rewind
// Handle double ESC
if (escPressCount.current === 0) {
escPressCount.current = 1;
setShowEscapePrompt(true);
@@ -505,11 +505,20 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
escapeTimerRef.current = setTimeout(() => {
resetEscapeState();
}, 500);
} else {
// Second ESC triggers rewind
resetEscapeState();
onSubmit('/rewind');
return;
}
// Second ESC
resetEscapeState();
if (buffer.text.length > 0) {
buffer.setText('');
resetCompletionState();
return;
} else if (history.length > 0) {
onSubmit('/rewind');
return;
}
coreEvents.emitFeedback('info', 'Nothing to rewind to');
return;
}
@@ -880,6 +889,7 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
onSubmit,
activePtyId,
setEmbeddedShellFocused,
history,
],
);
@@ -239,6 +239,7 @@ describe('RewindViewer', () => {
// Select
act(() => {
stdin.write('\x1b[A'); // Move up from 'Stay at current position'
stdin.write('\r');
});
expect(lastFrame()).toMatchSnapshot('confirmation-dialog');
@@ -252,17 +253,16 @@ describe('RewindViewer', () => {
it.each([
{
description: 'removes reference markers',
prompt:
'some command @file\n--- Content from referenced files ---\nContent from file:\nblah blah\n--- End of content ---',
prompt: `some command @file\n--- Content from referenced files ---\nContent from file:\nblah blah\n--- End of content ---`,
},
{
description: 'strips expanded MCP resource content',
prompt:
'read @server3:mcp://demo-resource hello\n' +
'--- Content from referenced files ---\n' +
`--- Content from referenced files ---\n` +
'\nContent from @server3:mcp://demo-resource:\n' +
'This is the content of the demo resource.\n' +
'--- End of content ---',
`--- End of content ---`,
},
])('$description', async ({ prompt }) => {
const conversation = createConversation([
@@ -281,6 +281,7 @@ describe('RewindViewer', () => {
// Select
act(() => {
stdin.write('\x1b[A'); // Move up from 'Stay at current position'
stdin.write('\r'); // Select
});
+114 -37
View File
@@ -5,7 +5,7 @@
*/
import type React from 'react';
import { useMemo } from 'react';
import { useMemo, useState } from 'react';
import { Box, Text } from 'ink';
import { useUIState } from '../contexts/UIStateContext.js';
import {
@@ -19,8 +19,9 @@ import { useKeypress } from '../hooks/useKeypress.js';
import { useRewind } from '../hooks/useRewind.js';
import { RewindConfirmation, RewindOutcome } from './RewindConfirmation.js';
import { stripReferenceContent } from '../utils/formatters.js';
import { MaxSizedBox } from './shared/MaxSizedBox.js';
import { keyMatchers, Command } from '../keyMatchers.js';
import { CliSpinner } from './CliSpinner.js';
import { ExpandableText } from './shared/ExpandableText.js';
interface RewindViewerProps {
conversation: ConversationRecord;
@@ -29,7 +30,7 @@ interface RewindViewerProps {
messageId: string,
newText: string,
outcome: RewindOutcome,
) => void;
) => Promise<void>;
}
const MAX_LINES_PER_BOX = 2;
@@ -39,6 +40,7 @@ export const RewindViewer: React.FC<RewindViewerProps> = ({
onExit,
onRewind,
}) => {
const [isRewinding, setIsRewinding] = useState(false);
const { terminalWidth, terminalHeight } = useUIState();
const {
selectedMessageId,
@@ -48,28 +50,58 @@ export const RewindViewer: React.FC<RewindViewerProps> = ({
clearSelection,
} = useRewind(conversation);
const [highlightedMessageId, setHighlightedMessageId] = useState<
string | null
>(null);
const [expandedMessageId, setExpandedMessageId] = useState<string | null>(
null,
);
const interactions = useMemo(
() => conversation.messages.filter((msg) => msg.type === 'user'),
[conversation.messages],
);
const items = useMemo(
() =>
interactions
.map((msg, idx) => ({
key: `${msg.id || 'msg'}-${idx}`,
value: msg,
index: idx,
}))
.reverse(),
[interactions],
);
const items = useMemo(() => {
const interactionItems = interactions.map((msg, idx) => ({
key: `${msg.id || 'msg'}-${idx}`,
value: msg,
index: idx,
}));
// Add "Current Position" as the last item
return [
...interactionItems,
{
key: 'current-position',
value: {
id: 'current-position',
type: 'user',
content: 'Stay at current position',
timestamp: new Date().toISOString(),
} as MessageRecord,
index: interactionItems.length,
},
];
}, [interactions]);
useKeypress(
(key) => {
if (!selectedMessageId) {
if (keyMatchers[Command.ESCAPE](key)) {
onExit();
return;
}
if (keyMatchers[Command.EXPAND_SUGGESTION](key)) {
if (
highlightedMessageId &&
highlightedMessageId !== 'current-position'
) {
setExpandedMessageId(highlightedMessageId);
}
}
if (keyMatchers[Command.COLLAPSE_SUGGESTION](key)) {
setExpandedMessageId(null);
}
}
},
@@ -89,6 +121,28 @@ export const RewindViewer: React.FC<RewindViewerProps> = ({
const maxItemsToShow = Math.max(1, Math.floor(listHeight / 4));
if (selectedMessageId) {
if (isRewinding) {
return (
<Box
borderStyle="round"
borderColor={theme.border.default}
padding={1}
width={terminalWidth}
flexDirection="row"
>
<Box>
<CliSpinner />
</Box>
<Text>Rewinding...</Text>
</Box>
);
}
if (selectedMessageId === 'current-position') {
onExit();
return null;
}
const selectedMessage = interactions.find(
(m) => m.id === selectedMessageId,
);
@@ -97,7 +151,7 @@ export const RewindViewer: React.FC<RewindViewerProps> = ({
stats={confirmationStats}
terminalWidth={terminalWidth}
timestamp={selectedMessage?.timestamp}
onConfirm={(outcome) => {
onConfirm={async (outcome) => {
if (outcome === RewindOutcome.Cancel) {
clearSelection();
} else {
@@ -109,7 +163,8 @@ export const RewindViewer: React.FC<RewindViewerProps> = ({
? partToString(userPrompt.content)
: '';
const cleanedText = stripReferenceContent(originalUserText);
onRewind(selectedMessageId, cleanedText, outcome);
setIsRewinding(true);
await onRewind(selectedMessageId, cleanedText, outcome);
}
}
}}
@@ -133,17 +188,48 @@ export const RewindViewer: React.FC<RewindViewerProps> = ({
<Box flexDirection="column" flexGrow={1}>
<BaseSelectionList
items={items}
initialIndex={items.length - 1}
isFocused={true}
showNumbers={false}
wrapAround={false}
onSelect={(item: MessageRecord) => {
const userPrompt = item;
if (userPrompt && userPrompt.id) {
selectMessage(userPrompt.id);
if (userPrompt.id === 'current-position') {
onExit();
} else {
selectMessage(userPrompt.id);
}
}
}}
onHighlight={(item: MessageRecord) => {
if (item.id) {
setHighlightedMessageId(item.id);
// Collapse when moving selection
setExpandedMessageId(null);
}
}}
maxItemsToShow={maxItemsToShow}
renderItem={(itemWrapper, { isSelected }) => {
const userPrompt = itemWrapper.value;
if (userPrompt.id === 'current-position') {
return (
<Box flexDirection="column" marginBottom={1}>
<Text
color={
isSelected ? theme.status.success : theme.text.primary
}
>
{partToString(userPrompt.content)}
</Text>
<Text color={theme.text.secondary}>
Cancel rewind and stay here
</Text>
</Box>
);
}
const stats = getStats(userPrompt);
const firstFileName = stats?.details?.at(0)?.fileName;
const originalUserText = userPrompt.content
@@ -154,25 +240,15 @@ export const RewindViewer: React.FC<RewindViewerProps> = ({
return (
<Box flexDirection="column" marginBottom={1}>
<Box>
<MaxSizedBox
maxWidth={terminalWidth - 4}
maxHeight={isSelected ? undefined : MAX_LINES_PER_BOX + 1}
overflowDirection="bottom"
>
{cleanedText.split('\n').map((line, i) => (
<Box key={i}>
<Text
color={
isSelected
? theme.status.success
: theme.text.primary
}
>
{line}
</Text>
</Box>
))}
</MaxSizedBox>
<ExpandableText
label={cleanedText}
isExpanded={expandedMessageId === userPrompt.id}
textColor={
isSelected ? theme.status.success : theme.text.primary
}
maxWidth={(terminalWidth - 4) * MAX_LINES_PER_BOX}
maxLines={MAX_LINES_PER_BOX}
/>
</Box>
{stats ? (
<Box flexDirection="row">
@@ -203,7 +279,8 @@ export const RewindViewer: React.FC<RewindViewerProps> = ({
<Box marginTop={1}>
<Text color={theme.text.secondary}>
(Use Enter to select a message, Esc to close)
(Use Enter to select a message, Esc to close, Right/Left to
expand/collapse)
</Text>
</Box>
</Box>
@@ -11,6 +11,7 @@ import { StatusDisplay } from './StatusDisplay.js';
import { UIStateContext, type UIState } from '../contexts/UIStateContext.js';
import { ConfigContext } from '../contexts/ConfigContext.js';
import { SettingsContext } from '../contexts/SettingsContext.js';
import type { TextBuffer } from './shared/text-buffer.js';
// Mock child components to simplify testing
vi.mock('./ContextSummaryDisplay.js', () => ({
@@ -23,8 +24,13 @@ vi.mock('./HookStatusDisplay.js', () => ({
HookStatusDisplay: () => <Text>Mock Hook Status Display</Text>,
}));
// Use a type that allows partial buffer for mocking purposes
type UIStateOverrides = Partial<Omit<UIState, 'buffer'>> & {
buffer?: Partial<TextBuffer>;
};
// Create mock context providers
const createMockUIState = (overrides: Partial<UIState> = {}): UIState =>
const createMockUIState = (overrides: UIStateOverrides = {}): UIState =>
({
ctrlCPressedOnce: false,
warningMessage: null,
@@ -35,6 +41,8 @@ const createMockUIState = (overrides: Partial<UIState> = {}): UIState =>
ideContextState: null,
geminiMdFileCount: 0,
contextFileNames: [],
buffer: { text: '' },
history: [{ id: 1, type: 'user', text: 'test' }],
...overrides,
}) as UIState;
@@ -52,7 +60,7 @@ const createMockConfig = (overrides = {}) => ({
const createMockSettings = (merged = {}) => ({
merged: {
hooks: { notifications: true },
hooksConfig: { notifications: true },
ui: { hideContextSummary: false },
...merged,
},
@@ -147,9 +155,22 @@ describe('StatusDisplay', () => {
expect(lastFrame()).toMatchSnapshot();
});
it('renders Escape prompt', () => {
it('renders Escape prompt when buffer is empty', () => {
const uiState = createMockUIState({
showEscapePrompt: true,
buffer: { text: '' },
});
const { lastFrame } = renderStatusDisplay(
{ hideContextSummary: false },
uiState,
);
expect(lastFrame()).toMatchSnapshot();
});
it('renders Escape prompt when buffer is NOT empty', () => {
const uiState = createMockUIState({
showEscapePrompt: true,
buffer: { text: 'some text' },
});
const { lastFrame } = renderStatusDisplay(
{ hideContextSummary: false },
@@ -185,7 +206,7 @@ describe('StatusDisplay', () => {
activeHooks: [{ name: 'hook', eventName: 'event' }],
});
const settings = createMockSettings({
hooks: { notifications: false },
hooksConfig: { notifications: false },
});
const { lastFrame } = renderStatusDisplay(
{ hideContextSummary: false },
@@ -45,14 +45,28 @@ export const StatusDisplay: React.FC<StatusDisplayProps> = ({
}
if (uiState.showEscapePrompt) {
return <Text color={theme.text.secondary}>Press Esc again to rewind.</Text>;
const isPromptEmpty = uiState.buffer.text.length === 0;
const hasHistory = uiState.history.length > 0;
if (isPromptEmpty && !hasHistory) {
return null;
}
return (
<Text color={theme.text.secondary}>
Press Esc again to {isPromptEmpty ? 'rewind' : 'clear prompt'}.
</Text>
);
}
if (uiState.queueErrorMessage) {
return <Text color={theme.status.error}>{uiState.queueErrorMessage}</Text>;
}
if (uiState.activeHooks.length > 0 && settings.merged.hooks.notifications) {
if (
uiState.activeHooks.length > 0 &&
settings.merged.hooksConfig.notifications
) {
return <HookStatusDisplay activeHooks={uiState.activeHooks} />;
}
@@ -6,7 +6,7 @@
import { Box, Text } from 'ink';
import { theme } from '../semantic-colors.js';
import { PrepareLabel, MAX_WIDTH } from './PrepareLabel.js';
import { ExpandableText, MAX_WIDTH } from './shared/ExpandableText.js';
import { CommandKind } from '../commands/types.js';
import { Colors } from '../colors.js';
export interface Suggestion {
@@ -85,7 +85,7 @@ export function SuggestionsDisplay({
const textColor = isActive ? theme.text.accent : theme.text.secondary;
const isLong = suggestion.value.length >= MAX_WIDTH;
const labelElement = (
<PrepareLabel
<ExpandableText
label={suggestion.value}
matchedIndex={suggestion.matchedIndex}
userInput={userInput}
@@ -0,0 +1,195 @@
/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import { render } from '../../test-utils/render.js';
import { act } from 'react';
import {
describe,
it,
expect,
vi,
beforeEach,
afterEach,
type Mock,
} from 'vitest';
import { ValidationDialog } from './ValidationDialog.js';
import { RadioButtonSelect } from './shared/RadioButtonSelect.js';
// Mock the child components and utilities
vi.mock('./shared/RadioButtonSelect.js', () => ({
RadioButtonSelect: vi.fn(),
}));
vi.mock('./CliSpinner.js', () => ({
CliSpinner: vi.fn(() => null),
}));
const mockOpenBrowserSecurely = vi.fn();
const mockShouldLaunchBrowser = vi.fn();
vi.mock('@google/gemini-cli-core', async (importOriginal) => {
const actual =
await importOriginal<typeof import('@google/gemini-cli-core')>();
return {
...actual,
openBrowserSecurely: (...args: unknown[]) =>
mockOpenBrowserSecurely(...args),
shouldLaunchBrowser: () => mockShouldLaunchBrowser(),
};
});
vi.mock('../hooks/useKeypress.js', () => ({
useKeypress: vi.fn(),
}));
describe('ValidationDialog', () => {
const mockOnChoice = vi.fn();
beforeEach(() => {
vi.clearAllMocks();
mockShouldLaunchBrowser.mockReturnValue(true);
mockOpenBrowserSecurely.mockResolvedValue(undefined);
});
afterEach(() => {
vi.restoreAllMocks();
});
describe('initial render (choosing state)', () => {
it('should render the main message and two options', () => {
const { lastFrame, unmount } = render(
<ValidationDialog onChoice={mockOnChoice} />,
);
expect(lastFrame()).toContain(
'Further action is required to use this service.',
);
expect(RadioButtonSelect).toHaveBeenCalledWith(
expect.objectContaining({
items: [
{
label: 'Verify your account',
value: 'verify',
key: 'verify',
},
{
label: 'Change authentication',
value: 'change_auth',
key: 'change_auth',
},
],
}),
undefined,
);
unmount();
});
it('should render learn more URL when provided', () => {
const { lastFrame, unmount } = render(
<ValidationDialog
learnMoreUrl="https://example.com/help"
onChoice={mockOnChoice}
/>,
);
expect(lastFrame()).toContain('Learn more:');
expect(lastFrame()).toContain('https://example.com/help');
unmount();
});
});
describe('onChoice handling', () => {
it('should call onChoice with change_auth when that option is selected', () => {
const { unmount } = render(<ValidationDialog onChoice={mockOnChoice} />);
const onSelect = (RadioButtonSelect as Mock).mock.calls[0][0].onSelect;
act(() => {
onSelect('change_auth');
});
expect(mockOnChoice).toHaveBeenCalledWith('change_auth');
unmount();
});
it('should call onChoice with verify when no validation link is provided', () => {
const { unmount } = render(<ValidationDialog onChoice={mockOnChoice} />);
const onSelect = (RadioButtonSelect as Mock).mock.calls[0][0].onSelect;
act(() => {
onSelect('verify');
});
expect(mockOnChoice).toHaveBeenCalledWith('verify');
unmount();
});
it('should open browser and transition to waiting state when verify is selected with a link', async () => {
const { lastFrame, unmount } = render(
<ValidationDialog
validationLink="https://accounts.google.com/verify"
onChoice={mockOnChoice}
/>,
);
const onSelect = (RadioButtonSelect as Mock).mock.calls[0][0].onSelect;
await act(async () => {
await onSelect('verify');
});
expect(mockOpenBrowserSecurely).toHaveBeenCalledWith(
'https://accounts.google.com/verify',
);
expect(lastFrame()).toContain('Waiting for verification...');
unmount();
});
});
describe('headless mode', () => {
it('should show URL in message when browser cannot be launched', async () => {
mockShouldLaunchBrowser.mockReturnValue(false);
const { lastFrame, unmount } = render(
<ValidationDialog
validationLink="https://accounts.google.com/verify"
onChoice={mockOnChoice}
/>,
);
const onSelect = (RadioButtonSelect as Mock).mock.calls[0][0].onSelect;
await act(async () => {
await onSelect('verify');
});
expect(mockOpenBrowserSecurely).not.toHaveBeenCalled();
expect(lastFrame()).toContain('Please open this URL in a browser:');
expect(lastFrame()).toContain('https://accounts.google.com/verify');
unmount();
});
});
describe('error state', () => {
it('should show error and options when browser fails to open', async () => {
mockOpenBrowserSecurely.mockRejectedValue(new Error('Browser not found'));
const { lastFrame, unmount } = render(
<ValidationDialog
validationLink="https://accounts.google.com/verify"
onChoice={mockOnChoice}
/>,
);
const onSelect = (RadioButtonSelect as Mock).mock.calls[0][0].onSelect;
await act(async () => {
await onSelect('verify');
});
expect(lastFrame()).toContain('Browser not found');
// RadioButtonSelect should be rendered again with options in error state
expect((RadioButtonSelect as Mock).mock.calls.length).toBeGreaterThan(1);
unmount();
});
});
});
@@ -0,0 +1,177 @@
/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import type React from 'react';
import { useState, useEffect, useCallback } from 'react';
import { Box, Text } from 'ink';
import { RadioButtonSelect } from './shared/RadioButtonSelect.js';
import { theme } from '../semantic-colors.js';
import { CliSpinner } from './CliSpinner.js';
import {
openBrowserSecurely,
shouldLaunchBrowser,
type ValidationIntent,
} from '@google/gemini-cli-core';
import { useKeypress } from '../hooks/useKeypress.js';
import { keyMatchers, Command } from '../keyMatchers.js';
interface ValidationDialogProps {
validationLink?: string;
validationDescription?: string;
learnMoreUrl?: string;
onChoice: (choice: ValidationIntent) => void;
}
type DialogState = 'choosing' | 'waiting' | 'complete' | 'error';
export function ValidationDialog({
validationLink,
learnMoreUrl,
onChoice,
}: ValidationDialogProps): React.JSX.Element {
const [state, setState] = useState<DialogState>('choosing');
const [errorMessage, setErrorMessage] = useState<string>('');
const items = [
{
label: 'Verify your account',
value: 'verify' as const,
key: 'verify',
},
{
label: 'Change authentication',
value: 'change_auth' as const,
key: 'change_auth',
},
];
// Handle keypresses during 'waiting' state (ESC to cancel, Enter to confirm completion)
useKeypress(
(key) => {
if (keyMatchers[Command.ESCAPE](key) || keyMatchers[Command.QUIT](key)) {
onChoice('cancel');
} else if (keyMatchers[Command.RETURN](key)) {
// User confirmed verification is complete - transition to 'complete' state
setState('complete');
}
},
{ isActive: state === 'waiting' },
);
// When state becomes 'complete', show success message briefly then proceed
useEffect(() => {
if (state === 'complete') {
const timer = setTimeout(() => {
onChoice('verify');
}, 500);
return () => clearTimeout(timer);
}
return undefined;
}, [state, onChoice]);
const handleSelect = useCallback(
async (choice: ValidationIntent) => {
if (choice === 'verify') {
if (validationLink) {
// Check if we're in an environment where we can launch a browser
if (!shouldLaunchBrowser()) {
// In headless mode, show the link and wait for user to manually verify
setErrorMessage(
`Please open this URL in a browser: ${validationLink}`,
);
setState('waiting');
return;
}
try {
await openBrowserSecurely(validationLink);
setState('waiting');
} catch (error) {
setErrorMessage(
error instanceof Error ? error.message : 'Failed to open browser',
);
setState('error');
}
} else {
// No validation link, just retry
onChoice('verify');
}
} else {
// 'change_auth' or 'cancel'
onChoice(choice);
}
},
[validationLink, onChoice],
);
if (state === 'error') {
return (
<Box borderStyle="round" flexDirection="column" padding={1}>
<Text color={theme.status.error}>
{errorMessage ||
'Failed to open verification link. Please try again or change authentication.'}
</Text>
<Box marginTop={1}>
<RadioButtonSelect
items={items}
onSelect={(choice) => void handleSelect(choice as ValidationIntent)}
/>
</Box>
</Box>
);
}
if (state === 'waiting') {
return (
<Box borderStyle="round" flexDirection="column" padding={1}>
<Box>
<CliSpinner />
<Text>
{' '}
Waiting for verification... (Press ESC or CTRL+C to cancel)
</Text>
</Box>
{errorMessage && (
<Box marginTop={1}>
<Text>{errorMessage}</Text>
</Box>
)}
<Box marginTop={1}>
<Text dimColor>Press Enter when verification is complete.</Text>
</Box>
</Box>
);
}
if (state === 'complete') {
return (
<Box borderStyle="round" flexDirection="column" padding={1}>
<Text color={theme.status.success}>Verification complete</Text>
</Box>
);
}
return (
<Box borderStyle="round" flexDirection="column" padding={1}>
<Box marginBottom={1}>
<Text>Further action is required to use this service.</Text>
</Box>
<Box marginTop={1} marginBottom={1}>
<RadioButtonSelect
items={items}
onSelect={(choice) => void handleSelect(choice as ValidationIntent)}
/>
</Box>
{learnMoreUrl && (
<Box marginTop={1}>
<Text dimColor>
Learn more: <Text color={theme.text.accent}>{learnMoreUrl}</Text>
</Text>
</Box>
)}
</Box>
);
}
@@ -5,11 +5,14 @@ exports[`RewindViewer > Content Filtering > 'removes reference markers' 1`] = `
│ │
│ > Rewind │
│ │
some command @file │
some command @file │
│ No files have been changed │
│ │
│ ● Stay at current position │
│ Cancel rewind and stay here │
│ │
(Use Enter to select a message, Esc to close)
│ (Use Enter to select a message, Esc to close, Right/Left to expand/collapse) │
│ │
╰──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯"
`;
@@ -19,11 +22,14 @@ exports[`RewindViewer > Content Filtering > 'strips expanded MCP resource conten
│ │
│ > Rewind │
│ │
read @server3:mcp://demo-resource hello │
read @server3:mcp://demo-resource hello │
│ No files have been changed │
│ │
│ ● Stay at current position │
│ Cancel rewind and stay here │
│ │
(Use Enter to select a message, Esc to close)
│ (Use Enter to select a message, Esc to close, Right/Left to expand/collapse) │
│ │
╰──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯"
`;
@@ -63,17 +69,20 @@ exports[`RewindViewer > Navigation > handles 'down' navigation > after-down 1`]
│ │
│ > Rewind │
│ │
│ Q3 │
│ No files have been changed │
│ │
│ ● Q2 │
│ No files have been changed │
│ │
│ Q1 │
│ No files have been changed │
│ │
│ Q2 │
│ No files have been changed │
│ │
(Use Enter to select a message, Esc to close)
Q3
│ No files have been changed │
│ │
│ ● Stay at current position │
│ Cancel rewind and stay here │
│ │
│ │
│ (Use Enter to select a message, Esc to close, Right/Left to expand/collapse) │
│ │
╰──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯"
`;
@@ -83,17 +92,20 @@ exports[`RewindViewer > Navigation > handles 'up' navigation > after-up 1`] = `
│ │
│ > Rewind │
│ │
│ Q3
│ Q1
│ No files have been changed │
│ │
│ Q2 │
│ No files have been changed │
│ │
│ ● Q1
│ ● Q3
│ No files have been changed │
│ │
│ Stay at current position │
│ Cancel rewind and stay here │
│ │
(Use Enter to select a message, Esc to close)
│ (Use Enter to select a message, Esc to close, Right/Left to expand/collapse) │
│ │
╰──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯"
`;
@@ -103,17 +115,20 @@ exports[`RewindViewer > Navigation > handles cyclic navigation > cyclic-down 1`]
│ │
│ > Rewind │
│ │
Q3
Q1
│ No files have been changed │
│ │
│ Q2 │
│ No files have been changed │
│ │
│ Q1
│ Q3
│ No files have been changed │
│ │
│ ● Stay at current position │
│ Cancel rewind and stay here │
│ │
(Use Enter to select a message, Esc to close)
│ (Use Enter to select a message, Esc to close, Right/Left to expand/collapse) │
│ │
╰──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯"
`;
@@ -123,17 +138,20 @@ exports[`RewindViewer > Navigation > handles cyclic navigation > cyclic-up 1`] =
│ │
│ > Rewind │
│ │
│ Q3
│ Q1
│ No files have been changed │
│ │
│ Q2 │
│ No files have been changed │
│ │
│ ● Q1
│ ● Q3
│ No files have been changed │
│ │
│ Stay at current position │
│ Cancel rewind and stay here │
│ │
(Use Enter to select a message, Esc to close)
│ (Use Enter to select a message, Esc to close, Right/Left to expand/collapse) │
│ │
╰──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯"
`;
@@ -143,11 +161,14 @@ exports[`RewindViewer > Rendering > renders 'a single interaction' 1`] = `
│ │
│ > Rewind │
│ │
Hello │
Hello │
│ No files have been changed │
│ │
│ ● Stay at current position │
│ Cancel rewind and stay here │
│ │
(Use Enter to select a message, Esc to close)
│ (Use Enter to select a message, Esc to close, Right/Left to expand/collapse) │
│ │
╰──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯"
`;
@@ -157,17 +178,15 @@ exports[`RewindViewer > Rendering > renders 'full text for selected item' 1`] =
│ │
│ > Rewind │
│ │
1 │
│ 2
│ 3 │
│ 4 │
│ 5 │
│ 6 │
│ 7 │
1 │
│ 2...
│ No files have been changed │
│ │
│ ● Stay at current position │
│ Cancel rewind and stay here │
│ │
(Use Enter to select a message, Esc to close)
│ (Use Enter to select a message, Esc to close, Right/Left to expand/collapse) │
│ │
╰──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯"
`;
@@ -177,8 +196,11 @@ exports[`RewindViewer > Rendering > renders 'nothing interesting for empty conve
│ │
│ > Rewind │
│ │
│ ● Stay at current position │
│ Cancel rewind and stay here │
│ │
(Use Enter to select a message, Esc to close)
│ (Use Enter to select a message, Esc to close, Right/Left to expand/collapse) │
│ │
╰──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯"
`;
@@ -188,14 +210,17 @@ exports[`RewindViewer > updates content when conversation changes (background up
│ │
│ > Rewind │
│ │
│ ● Message 2 │
│ No files have been changed │
│ │
│ Message 1 │
│ No files have been changed │
│ │
│ Message 2 │
│ No files have been changed │
│ │
(Use Enter to select a message, Esc to close)
● Stay at current position
│ Cancel rewind and stay here │
│ │
│ │
│ (Use Enter to select a message, Esc to close, Right/Left to expand/collapse) │
│ │
╰──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯"
`;
@@ -205,11 +230,14 @@ exports[`RewindViewer > updates content when conversation changes (background up
│ │
│ > Rewind │
│ │
Message 1 │
Message 1 │
│ No files have been changed │
│ │
│ ● Stay at current position │
│ Cancel rewind and stay here │
│ │
(Use Enter to select a message, Esc to close)
│ (Use Enter to select a message, Esc to close, Right/Left to expand/collapse) │
│ │
╰──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯"
`;
@@ -219,22 +247,19 @@ exports[`RewindViewer > updates selection and expansion on navigation > after-do
│ │
│ > Rewind │
│ │
│ Line A │
│ Line B... │
│ No files have been changed │
│ │
│ Line 1 │
│ Line 2
│ ... last 5 lines hidden ... │
│ Line 2...
│ No files have been changed │
│ │
│ ● Line A
Line B
│ Line C │
│ Line D │
│ Line E │
│ Line F │
│ Line G │
│ No files have been changed │
│ ● Stay at current position
Cancel rewind and stay here
│ │
│ │
│ (Use Enter to select a message, Esc to close)
│ (Use Enter to select a message, Esc to close, Right/Left to expand/collapse)
│ │
╰──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯"
`;
@@ -244,22 +269,19 @@ exports[`RewindViewer > updates selection and expansion on navigation > initial-
│ │
│ > Rewind │
│ │
│ ● Line 1 │
│ Line 2 │
│ Line 3 │
│ Line 4 │
│ Line 5 │
│ Line 6 │
│ Line 7 │
│ No files have been changed │
│ │
│ Line A │
│ Line B
│ ... last 5 lines hidden ... │
│ Line B...
│ No files have been changed │
│ │
│ Line 1 │
│ Line 2... │
│ No files have been changed │
│ │
(Use Enter to select a message, Esc to close)
● Stay at current position
│ Cancel rewind and stay here │
│ │
│ │
│ (Use Enter to select a message, Esc to close, Right/Left to expand/collapse) │
│ │
╰──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯"
`;
@@ -10,7 +10,9 @@ exports[`StatusDisplay > renders ContextSummaryDisplay by default 1`] = `"Mock C
exports[`StatusDisplay > renders Ctrl+D prompt 1`] = `"Press Ctrl+D again to exit."`;
exports[`StatusDisplay > renders Escape prompt 1`] = `"Press Esc again to rewind."`;
exports[`StatusDisplay > renders Escape prompt when buffer is NOT empty 1`] = `"Press Esc again to clear prompt."`;
exports[`StatusDisplay > renders Escape prompt when buffer is empty 1`] = `"Press Esc again to rewind."`;
exports[`StatusDisplay > renders HookStatusDisplay when hooks are active 1`] = `"Mock Hook Status Display"`;
@@ -125,6 +125,7 @@ describe('BaseSelectionList', () => {
onHighlight: mockOnHighlight,
isFocused,
showNumbers,
wrapAround: true,
});
});
@@ -30,6 +30,7 @@ export interface BaseSelectionListProps<
showNumbers?: boolean;
showScrollArrows?: boolean;
maxItemsToShow?: number;
wrapAround?: boolean;
renderItem: (item: TItem, context: RenderItemContext) => React.ReactNode;
}
@@ -59,6 +60,7 @@ export function BaseSelectionList<
showNumbers = true,
showScrollArrows = false,
maxItemsToShow = 10,
wrapAround = true,
renderItem,
}: BaseSelectionListProps<T, TItem>): React.JSX.Element {
const { activeIndex } = useSelectionList({
@@ -68,6 +70,7 @@ export function BaseSelectionList<
onHighlight,
isFocused,
showNumbers,
wrapAround,
});
const [scrollOffset, setScrollOffset] = useState(0);
@@ -1,20 +1,20 @@
/**
* @license
* Copyright 2025 Google LLC
* Copyright 2026 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import { describe, it, expect } from 'vitest';
import { render } from '../../test-utils/render.js';
import { PrepareLabel, MAX_WIDTH } from './PrepareLabel.js';
import { render } from '../../../test-utils/render.js';
import { ExpandableText, MAX_WIDTH } from './ExpandableText.js';
describe('PrepareLabel', () => {
describe('ExpandableText', () => {
const color = 'white';
const flat = (s: string | undefined) => (s ?? '').replace(/\n/g, '');
it('renders plain label when no match (short label)', () => {
const { lastFrame, unmount } = render(
<PrepareLabel
<ExpandableText
label="simple command"
userInput=""
matchedIndex={undefined}
@@ -29,7 +29,7 @@ describe('PrepareLabel', () => {
it('truncates long label when collapsed and no match', () => {
const long = 'x'.repeat(MAX_WIDTH + 25);
const { lastFrame, unmount } = render(
<PrepareLabel
<ExpandableText
label={long}
userInput=""
textColor={color}
@@ -47,7 +47,7 @@ describe('PrepareLabel', () => {
it('shows full long label when expanded and no match', () => {
const long = 'y'.repeat(MAX_WIDTH + 25);
const { lastFrame, unmount } = render(
<PrepareLabel
<ExpandableText
label={long}
userInput=""
textColor={color}
@@ -66,7 +66,7 @@ describe('PrepareLabel', () => {
const userInput = 'commit';
const matchedIndex = label.indexOf(userInput);
const { lastFrame, unmount } = render(
<PrepareLabel
<ExpandableText
label={label}
userInput={userInput}
matchedIndex={matchedIndex}
@@ -86,7 +86,7 @@ describe('PrepareLabel', () => {
const label = prefix + core + suffix;
const matchedIndex = prefix.length;
const { lastFrame, unmount } = render(
<PrepareLabel
<ExpandableText
label={label}
userInput={core}
matchedIndex={matchedIndex}
@@ -111,7 +111,7 @@ describe('PrepareLabel', () => {
const label = prefix + core + suffix;
const matchedIndex = prefix.length;
const { lastFrame, unmount } = render(
<PrepareLabel
<ExpandableText
label={label}
userInput={core}
matchedIndex={matchedIndex}
@@ -128,4 +128,24 @@ describe('PrepareLabel', () => {
expect(out).toMatchSnapshot();
unmount();
});
it('respects custom maxWidth', () => {
const customWidth = 50;
const long = 'z'.repeat(100);
const { lastFrame, unmount } = render(
<ExpandableText
label={long}
userInput=""
textColor={color}
isExpanded={false}
maxWidth={customWidth}
/>,
);
const out = lastFrame();
const f = flat(out);
expect(f.endsWith('...')).toBe(true);
expect(f.length).toBe(customWidth + 3);
expect(out).toMatchSnapshot();
unmount();
});
});
@@ -1,29 +1,33 @@
/**
* @license
* Copyright 2025 Google LLC
* Copyright 2026 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import React from 'react';
import { Text } from 'ink';
import { theme } from '../semantic-colors.js';
import { theme } from '../../semantic-colors.js';
export const MAX_WIDTH = 150; // Maximum width for the text that is shown
export const MAX_WIDTH = 150;
export interface PrepareLabelProps {
export interface ExpandableTextProps {
label: string;
matchedIndex?: number;
userInput: string;
textColor: string;
userInput?: string;
textColor?: string;
isExpanded?: boolean;
maxWidth?: number;
maxLines?: number;
}
const _PrepareLabel: React.FC<PrepareLabelProps> = ({
const _ExpandableText: React.FC<ExpandableTextProps> = ({
label,
matchedIndex,
userInput,
textColor,
userInput = '',
textColor = theme.text.primary,
isExpanded = false,
maxWidth = MAX_WIDTH,
maxLines,
}) => {
const hasMatch =
matchedIndex !== undefined &&
@@ -33,11 +37,27 @@ const _PrepareLabel: React.FC<PrepareLabelProps> = ({
// Render the plain label if there's no match
if (!hasMatch) {
const display = isExpanded
? label
: label.length > MAX_WIDTH
? label.slice(0, MAX_WIDTH) + '...'
: label;
let display = label;
if (!isExpanded) {
if (maxLines !== undefined) {
const lines = label.split('\n');
// 1. Truncate by logical lines
let truncated = lines.slice(0, maxLines).join('\n');
const hasMoreLines = lines.length > maxLines;
// 2. Truncate by characters (visual approximation) to prevent massive wrapping
if (truncated.length > maxWidth) {
truncated = truncated.slice(0, maxWidth) + '...';
} else if (hasMoreLines) {
truncated += '...';
}
display = truncated;
} else if (label.length > maxWidth) {
display = label.slice(0, maxWidth) + '...';
}
}
return (
<Text wrap="wrap" color={textColor}>
{display}
@@ -51,18 +71,18 @@ const _PrepareLabel: React.FC<PrepareLabelProps> = ({
let after = '';
// Case 1: Show the full string if it's expanded or already fits
if (isExpanded || label.length <= MAX_WIDTH) {
if (isExpanded || label.length <= maxWidth) {
before = label.slice(0, matchedIndex);
match = label.slice(matchedIndex, matchedIndex + matchLength);
after = label.slice(matchedIndex + matchLength);
}
// Case 2: The match itself is too long, so we only show a truncated portion of the match
else if (matchLength >= MAX_WIDTH) {
match = label.slice(matchedIndex, matchedIndex + MAX_WIDTH - 1) + '...';
else if (matchLength >= maxWidth) {
match = label.slice(matchedIndex, matchedIndex + maxWidth - 1) + '...';
}
// Case 3: Truncate the string to create a window around the match
else {
const contextSpace = MAX_WIDTH - matchLength;
const contextSpace = maxWidth - matchLength;
const beforeSpace = Math.floor(contextSpace / 2);
const afterSpace = Math.ceil(contextSpace / 2);
@@ -113,4 +133,4 @@ const _PrepareLabel: React.FC<PrepareLabelProps> = ({
);
};
export const PrepareLabel = React.memo(_PrepareLabel);
export const ExpandableText = React.memo(_ExpandableText);
@@ -356,18 +356,18 @@ describe('ScrollableList Demo Behavior', () => {
expect(listRef?.getScrollState()?.scrollTop).toBeLessThan(2);
});
// End -> \x1b[F
// End -> \x1b[1;5F (Ctrl+End)
await act(async () => {
stdin.write('\x1b[F');
stdin.write('\x1b[1;5F');
});
await waitFor(() => {
// Total 50 items, height 10. Max scroll ~40.
expect(listRef?.getScrollState()?.scrollTop).toBeGreaterThan(30);
});
// Home -> \x1b[H
// Home -> \x1b[1;5H (Ctrl+Home)
await act(async () => {
stdin.write('\x1b[H');
stdin.write('\x1b[1;5H');
});
await waitFor(() => {
expect(listRef?.getScrollState()?.scrollTop).toBe(0);
@@ -0,0 +1,27 @@
// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
exports[`ExpandablePrompt > creates centered window around match when collapsed 1`] = `
"...ry/long/path/that/keeps/going/cd_/very/long/path/that/keeps/going/search-here/and/then/some/more/
components//and/then/some/more/components//and/..."
`;
exports[`ExpandablePrompt > highlights matched substring when expanded (text only visible) 1`] = `"run: git commit -m "feat: add search""`;
exports[`ExpandablePrompt > renders plain label when no match (short label) 1`] = `"simple command"`;
exports[`ExpandablePrompt > respects custom maxWidth 1`] = `"zzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzz..."`;
exports[`ExpandablePrompt > shows full long label when expanded and no match 1`] = `
"yyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyy
yyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyy"
`;
exports[`ExpandablePrompt > truncates long label when collapsed and no match 1`] = `
"xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx
xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx..."
`;
exports[`ExpandablePrompt > truncates match itself when match is very long 1`] = `
"xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx
xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx..."
`;
@@ -0,0 +1,27 @@
// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
exports[`ExpandableText > creates centered window around match when collapsed 1`] = `
"...ry/long/path/that/keeps/going/cd_/very/long/path/that/keeps/going/search-here/and/then/some/more/
components//and/then/some/more/components//and/..."
`;
exports[`ExpandableText > highlights matched substring when expanded (text only visible) 1`] = `"run: git commit -m "feat: add search""`;
exports[`ExpandableText > renders plain label when no match (short label) 1`] = `"simple command"`;
exports[`ExpandableText > respects custom maxWidth 1`] = `"zzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzz..."`;
exports[`ExpandableText > shows full long label when expanded and no match 1`] = `
"yyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyy
yyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyy"
`;
exports[`ExpandableText > truncates long label when collapsed and no match 1`] = `
"xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx
xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx..."
`;
exports[`ExpandableText > truncates match itself when match is very long 1`] = `
"xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx
xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx..."
`;
@@ -1096,6 +1096,23 @@ describe('useTextBuffer', () => {
expect(getBufferState(result).lines).toEqual(['', '']);
});
it('should handle Ctrl+J as newline', () => {
const { result } = renderHook(() =>
useTextBuffer({ viewport, isValidPath: () => false }),
);
act(() =>
result.current.handleInput({
name: 'j',
ctrl: true,
meta: false,
shift: false,
insertable: false,
sequence: '\n',
}),
);
expect(getBufferState(result).lines).toEqual(['', '']);
});
it('should do nothing for a tab key press', () => {
const { result } = renderHook(() =>
useTextBuffer({ viewport, isValidPath: () => false }),
@@ -2242,11 +2242,9 @@ export function useTextBuffer({
if (!command) {
command =
(process.env['VISUAL'] ??
process.env['VISUAL'] ??
process.env['EDITOR'] ??
process.platform === 'win32')
? 'notepad'
: 'vi';
(process.platform === 'win32' ? 'notepad' : 'vi');
}
dispatch({ type: 'create_undo_snapshot' });
@@ -2292,6 +2290,7 @@ export function useTextBuffer({
if (key.name === 'paste') insert(input, { paste: true });
else if (keyMatchers[Command.RETURN](key)) newline();
else if (keyMatchers[Command.NEWLINE](key)) newline();
else if (keyMatchers[Command.MOVE_LEFT](key)) move('left');
else if (keyMatchers[Command.MOVE_RIGHT](key)) move('right');
else if (keyMatchers[Command.MOVE_UP](key)) move('up');
@@ -154,6 +154,36 @@ describe('KeypressContext', () => {
);
},
);
it('should recognize \n (LF) as ctrl+j', async () => {
const { keyHandler } = setupKeypressTest();
act(() => stdin.write('\n'));
expect(keyHandler).toHaveBeenCalledWith(
expect.objectContaining({
name: 'j',
ctrl: true,
meta: false,
shift: false,
}),
);
});
it('should recognize \\x1b\\n as Alt+Enter (return with meta)', async () => {
const { keyHandler } = setupKeypressTest();
act(() => stdin.write('\x1b\n'));
expect(keyHandler).toHaveBeenCalledWith(
expect.objectContaining({
name: 'return',
ctrl: false,
meta: true,
shift: false,
}),
);
});
});
describe('Fast return buffering', () => {
@@ -182,6 +212,9 @@ describe('KeypressContext', () => {
name: 'return',
sequence: '\r',
insertable: true,
shift: true,
meta: false,
ctrl: false,
}),
);
});
@@ -158,6 +158,9 @@ function bufferFastReturn(keypressHandler: KeypressHandler): KeypressHandler {
keypressHandler({
...key,
name: 'return',
shift: true, // to make it a newline, not a submission
ctrl: false,
meta: false,
sequence: '\r',
insertable: true,
});
@@ -524,9 +527,9 @@ function* emitKeys(
// carriage return
name = 'return';
meta = escaped;
} else if (ch === '\n') {
// Enter, should have been called linefeed
name = 'enter';
} else if (escaped && ch === '\n') {
// Alt+Enter (linefeed), should be consistent with carriage return
name = 'return';
meta = escaped;
} else if (ch === '\t') {
// tab
@@ -46,6 +46,7 @@ export interface UIActions {
handleProQuotaChoice: (
choice: 'retry_later' | 'retry_once' | 'retry_always' | 'upgrade',
) => void;
handleValidationChoice: (choice: 'verify' | 'change_auth' | 'cancel') => void;
openSessionBrowser: () => void;
closeSessionBrowser: () => void;
handleResumeSession: (session: SessionInfo) => Promise<void>;
@@ -23,6 +23,7 @@ import type {
UserTierId,
IdeInfo,
FallbackIntent,
ValidationIntent,
} from '@google/gemini-cli-core';
import type { DOMElement } from 'ink';
import type { SessionStatsState } from '../contexts/SessionContext.js';
@@ -38,6 +39,13 @@ export interface ProQuotaDialogRequest {
resolve: (intent: FallbackIntent) => void;
}
export interface ValidationDialogRequest {
validationLink?: string;
validationDescription?: string;
learnMoreUrl?: string;
resolve: (intent: ValidationIntent) => void;
}
import { type UseHistoryManagerReturn } from '../hooks/useHistoryManager.js';
import { type RestartReason } from '../hooks/useIdeTrustListener.js';
import type { TerminalBackgroundColor } from '../utils/terminalCapabilityManager.js';
@@ -102,6 +110,7 @@ export interface UIState {
// Quota-related state
userTier: UserTierId | undefined;
proQuotaRequest: ProQuotaDialogRequest | null;
validationRequest: ValidationDialogRequest | null;
currentModel: string;
contextFileNames: string[];
errorCount: number;
@@ -18,12 +18,17 @@ import {
isNodeError,
unescapePath,
ReadManyFilesTool,
REFERENCE_CONTENT_START,
REFERENCE_CONTENT_END,
} from '@google/gemini-cli-core';
import { Buffer } from 'node:buffer';
import type { HistoryItem, IndividualToolCallDisplay } from '../types.js';
import { ToolCallStatus } from '../types.js';
import type { UseHistoryManagerReturn } from './useHistoryManager.js';
const REF_CONTENT_HEADER = `\n${REFERENCE_CONTENT_START}`;
const REF_CONTENT_FOOTER = `\n${REFERENCE_CONTENT_END}`;
interface HandleAtCommandParams {
query: string;
config: Config;
@@ -499,10 +504,17 @@ export async function handleAtCommand({
const resourceResults = await Promise.all(resourcePromises);
const resourceReadDisplays: IndividualToolCallDisplay[] = [];
let resourceErrorOccurred = false;
let hasAddedReferenceHeader = false;
for (const result of resourceResults) {
resourceReadDisplays.push(result.display);
if (result.success) {
if (!hasAddedReferenceHeader) {
processedQueryParts.push({
text: REF_CONTENT_HEADER,
});
hasAddedReferenceHeader = true;
}
processedQueryParts.push({ text: `\nContent from @${result.uri}:\n` });
processedQueryParts.push(...result.parts);
} else {
@@ -540,6 +552,9 @@ export async function handleAtCommand({
userMessageTimestamp,
);
}
if (hasAddedReferenceHeader) {
processedQueryParts.push({ text: REF_CONTENT_FOOTER });
}
return { processedQuery: processedQueryParts };
}
@@ -570,9 +585,12 @@ export async function handleAtCommand({
if (Array.isArray(result.llmContent)) {
const fileContentRegex = /^--- (.*?) ---\n\n([\s\S]*?)\n\n$/;
processedQueryParts.push({
text: '\n--- Content from referenced files ---',
});
if (!hasAddedReferenceHeader) {
processedQueryParts.push({
text: REF_CONTENT_HEADER,
});
hasAddedReferenceHeader = true;
}
for (const part of result.llmContent) {
if (typeof part === 'string') {
const match = fileContentRegex.exec(part);
@@ -156,11 +156,22 @@ describe('useSlashCommandProcessor', () => {
});
const setupProcessorHook = async (
builtinCommands: SlashCommand[] = [],
fileCommands: SlashCommand[] = [],
mcpCommands: SlashCommand[] = [],
setIsProcessing = vi.fn(),
options: {
builtinCommands?: SlashCommand[];
fileCommands?: SlashCommand[];
mcpCommands?: SlashCommand[];
setIsProcessing?: (isProcessing: boolean) => void;
refreshStatic?: () => void;
} = {},
) => {
const {
builtinCommands = [],
fileCommands = [],
mcpCommands = [],
setIsProcessing = vi.fn(),
refreshStatic = vi.fn(),
} = options;
mockBuiltinLoadCommands.mockResolvedValue(Object.freeze(builtinCommands));
mockFileLoadCommands.mockResolvedValue(Object.freeze(fileCommands));
mockMcpLoadCommands.mockResolvedValue(Object.freeze(mcpCommands));
@@ -177,7 +188,7 @@ describe('useSlashCommandProcessor', () => {
mockAddItem,
mockClearItems,
mockLoadHistory,
vi.fn(), // refreshStatic
refreshStatic,
vi.fn(), // toggleVimEnabled
setIsProcessing,
{
@@ -195,6 +206,7 @@ describe('useSlashCommandProcessor', () => {
toggleDebugProfiler: vi.fn(),
dispatchExtensionStateUpdate: vi.fn(),
addConfirmUpdateExtensionRequest: vi.fn(),
setText: vi.fn(),
},
new Map(), // extensionsUpdateState
true, // isConfigInitialized
@@ -233,7 +245,9 @@ describe('useSlashCommandProcessor', () => {
context.ui.clear();
},
});
const result = await setupProcessorHook([clearCommand]);
const result = await setupProcessorHook({
builtinCommands: [clearCommand],
});
await act(async () => {
await result.current.handleSlashCommand('/clear');
@@ -250,7 +264,9 @@ describe('useSlashCommandProcessor', () => {
context.ui.clear();
},
});
const result = await setupProcessorHook([clearCommand]);
const result = await setupProcessorHook({
builtinCommands: [clearCommand],
});
await act(async () => {
await result.current.handleSlashCommand('/clear');
@@ -270,7 +286,9 @@ describe('useSlashCommandProcessor', () => {
it('should call loadCommands and populate state after mounting', async () => {
const testCommand = createTestCommand({ name: 'test' });
const result = await setupProcessorHook([testCommand]);
const result = await setupProcessorHook({
builtinCommands: [testCommand],
});
await waitFor(() => {
expect(result.current.slashCommands).toHaveLength(1);
@@ -284,7 +302,9 @@ describe('useSlashCommandProcessor', () => {
it('should provide an immutable array of commands to consumers', async () => {
const testCommand = createTestCommand({ name: 'test' });
const result = await setupProcessorHook([testCommand]);
const result = await setupProcessorHook({
builtinCommands: [testCommand],
});
await waitFor(() => {
expect(result.current.slashCommands).toHaveLength(1);
@@ -312,7 +332,10 @@ describe('useSlashCommandProcessor', () => {
CommandKind.FILE,
);
const result = await setupProcessorHook([builtinCommand], [fileCommand]);
const result = await setupProcessorHook({
builtinCommands: [builtinCommand],
fileCommands: [fileCommand],
});
await waitFor(() => {
// The service should only return one command with the name 'override'
@@ -362,7 +385,9 @@ describe('useSlashCommandProcessor', () => {
},
],
};
const result = await setupProcessorHook([parentCommand]);
const result = await setupProcessorHook({
builtinCommands: [parentCommand],
});
await waitFor(() => expect(result.current.slashCommands).toHaveLength(1));
await act(async () => {
@@ -396,7 +421,9 @@ describe('useSlashCommandProcessor', () => {
},
],
};
const result = await setupProcessorHook([parentCommand]);
const result = await setupProcessorHook({
builtinCommands: [parentCommand],
});
await waitFor(() => expect(result.current.slashCommands).toHaveLength(1));
await act(async () => {
@@ -420,7 +447,9 @@ describe('useSlashCommandProcessor', () => {
it('sets isProcessing to false if the the input is not a command', async () => {
const setMockIsProcessing = vi.fn();
const result = await setupProcessorHook([], [], [], setMockIsProcessing);
const result = await setupProcessorHook({
setIsProcessing: setMockIsProcessing,
});
await act(async () => {
await result.current.handleSlashCommand('imnotacommand');
@@ -436,12 +465,10 @@ describe('useSlashCommandProcessor', () => {
action: vi.fn().mockRejectedValue(new Error('oh no!')),
});
const result = await setupProcessorHook(
[failCommand],
[],
[],
setMockIsProcessing,
);
const result = await setupProcessorHook({
builtinCommands: [failCommand],
setIsProcessing: setMockIsProcessing,
});
await waitFor(() => expect(result.current.slashCommands).toBeDefined());
@@ -460,12 +487,10 @@ describe('useSlashCommandProcessor', () => {
action: () => new Promise((resolve) => setTimeout(resolve, 50)),
});
const result = await setupProcessorHook(
[command],
[],
[],
mockSetIsProcessing,
);
const result = await setupProcessorHook({
builtinCommands: [command],
setIsProcessing: mockSetIsProcessing,
});
await waitFor(() => expect(result.current.slashCommands).toHaveLength(1));
const executionPromise = act(async () => {
@@ -507,7 +532,9 @@ describe('useSlashCommandProcessor', () => {
.fn()
.mockResolvedValue({ type: 'dialog', dialog: dialogType }),
});
const result = await setupProcessorHook([command]);
const result = await setupProcessorHook({
builtinCommands: [command],
});
await waitFor(() =>
expect(result.current.slashCommands).toHaveLength(1),
);
@@ -536,20 +563,42 @@ describe('useSlashCommandProcessor', () => {
clientHistory: [{ role: 'user', parts: [{ text: 'old prompt' }] }],
}),
});
const result = await setupProcessorHook([command]);
const mockRefreshStatic = vi.fn();
const result = await setupProcessorHook({
builtinCommands: [command],
refreshStatic: mockRefreshStatic,
});
await waitFor(() => expect(result.current.slashCommands).toHaveLength(1));
await act(async () => {
await result.current.handleSlashCommand('/load');
});
// ui.clear() is called which calls refreshStatic()
expect(mockClearItems).toHaveBeenCalledTimes(1);
expect(mockRefreshStatic).toHaveBeenCalledTimes(1);
expect(mockAddItem).toHaveBeenCalledWith(
{ type: 'user', text: 'old prompt' },
expect.any(Number),
);
});
it('should call refreshStatic exactly once when ui.loadHistory is called', async () => {
const mockRefreshStatic = vi.fn();
const result = await setupProcessorHook({
refreshStatic: mockRefreshStatic,
});
await act(async () => {
result.current.commandContext.ui.loadHistory([]);
});
expect(mockLoadHistory).toHaveBeenCalled();
expect(mockRefreshStatic).toHaveBeenCalledTimes(1);
});
it('should handle a "quit" action', async () => {
const quitAction = vi
.fn()
@@ -558,7 +607,9 @@ describe('useSlashCommandProcessor', () => {
name: 'exit',
action: quitAction,
});
const result = await setupProcessorHook([command]);
const result = await setupProcessorHook({
builtinCommands: [command],
});
await waitFor(() => expect(result.current.slashCommands).toHaveLength(1));
@@ -581,7 +632,9 @@ describe('useSlashCommandProcessor', () => {
CommandKind.FILE,
);
const result = await setupProcessorHook([], [fileCommand]);
const result = await setupProcessorHook({
fileCommands: [fileCommand],
});
await waitFor(() => expect(result.current.slashCommands).toHaveLength(1));
let actionResult;
@@ -613,7 +666,9 @@ describe('useSlashCommandProcessor', () => {
CommandKind.MCP_PROMPT,
);
const result = await setupProcessorHook([], [], [mcpCommand]);
const result = await setupProcessorHook({
mcpCommands: [mcpCommand],
});
await waitFor(() => expect(result.current.slashCommands).toHaveLength(1));
let actionResult;
@@ -636,7 +691,9 @@ describe('useSlashCommandProcessor', () => {
describe('Command Parsing and Matching', () => {
it('should be case-sensitive', async () => {
const command = createTestCommand({ name: 'test' });
const result = await setupProcessorHook([command]);
const result = await setupProcessorHook({
builtinCommands: [command],
});
await waitFor(() => expect(result.current.slashCommands).toHaveLength(1));
await act(async () => {
@@ -662,7 +719,9 @@ describe('useSlashCommandProcessor', () => {
description: 'a command with an alias',
action,
});
const result = await setupProcessorHook([command]);
const result = await setupProcessorHook({
builtinCommands: [command],
});
await waitFor(() => expect(result.current.slashCommands).toHaveLength(1));
await act(async () => {
@@ -678,7 +737,9 @@ describe('useSlashCommandProcessor', () => {
it('should handle extra whitespace around the command', async () => {
const action = vi.fn();
const command = createTestCommand({ name: 'test', action });
const result = await setupProcessorHook([command]);
const result = await setupProcessorHook({
builtinCommands: [command],
});
await waitFor(() => expect(result.current.slashCommands).toHaveLength(1));
await act(async () => {
@@ -691,7 +752,9 @@ describe('useSlashCommandProcessor', () => {
it('should handle `?` as a command prefix', async () => {
const action = vi.fn();
const command = createTestCommand({ name: 'help', action });
const result = await setupProcessorHook([command]);
const result = await setupProcessorHook({
builtinCommands: [command],
});
await waitFor(() => expect(result.current.slashCommands).toHaveLength(1));
await act(async () => {
@@ -720,7 +783,10 @@ describe('useSlashCommandProcessor', () => {
CommandKind.FILE,
);
const result = await setupProcessorHook([], [fileCommand], [mcpCommand]);
const result = await setupProcessorHook({
fileCommands: [fileCommand],
mcpCommands: [mcpCommand],
});
await waitFor(() => {
// The service should only return one command with the name 'override'
@@ -756,7 +822,10 @@ describe('useSlashCommandProcessor', () => {
// The order of commands in the final loaded array is not guaranteed,
// so the test must work regardless of which comes first.
const result = await setupProcessorHook([quitCommand], [exitCommand]);
const result = await setupProcessorHook({
builtinCommands: [quitCommand],
fileCommands: [exitCommand],
});
await waitFor(() => {
expect(result.current.slashCommands).toHaveLength(2);
@@ -783,7 +852,10 @@ describe('useSlashCommandProcessor', () => {
CommandKind.FILE,
);
const result = await setupProcessorHook([quitCommand], [exitCommand]);
const result = await setupProcessorHook({
builtinCommands: [quitCommand],
fileCommands: [exitCommand],
});
await waitFor(() => expect(result.current.slashCommands).toHaveLength(2));
await act(async () => {
@@ -879,7 +951,9 @@ describe('useSlashCommandProcessor', () => {
desc: 'command path when alias is used',
},
])('should log $desc', async ({ command, expectedLog }) => {
const result = await setupProcessorHook(loggingTestCommands);
const result = await setupProcessorHook({
builtinCommands: loggingTestCommands,
});
await waitFor(() => expect(result.current.slashCommands).toBeDefined());
await act(async () => {
@@ -898,7 +972,9 @@ describe('useSlashCommandProcessor', () => {
{ command: '/bogusbogusbogus', desc: 'bogus command' },
{ command: '/unknown', desc: 'unknown command' },
])('should not log for $desc', async ({ command }) => {
const result = await setupProcessorHook(loggingTestCommands);
const result = await setupProcessorHook({
builtinCommands: loggingTestCommands,
});
await waitFor(() => expect(result.current.slashCommands).toBeDefined());
await act(async () => {
@@ -76,6 +76,7 @@ interface SlashCommandProcessorActions {
toggleDebugProfiler: () => void;
dispatchExtensionStateUpdate: (action: ExtensionUpdateAction) => void;
addConfirmUpdateExtensionRequest: (request: ConfirmationRequest) => void;
setText: (text: string) => void;
}
/**
@@ -210,7 +211,13 @@ export const useSlashCommandProcessor = (
refreshStatic();
setBannerVisible(false);
},
loadHistory,
loadHistory: (history, postLoadInput) => {
loadHistory(history);
refreshStatic();
if (postLoadInput !== undefined) {
actions.setText(postLoadInput);
}
},
setDebugMessage: actions.setDebugMessage,
pendingItem,
setPendingItem,
@@ -1962,73 +1962,6 @@ describe('useGeminiStream', () => {
});
});
describe('MCP Discovery State', () => {
it('should block non-slash command queries when discovery is in progress and servers exist', async () => {
const mockMcpClientManager = {
getDiscoveryState: vi
.fn()
.mockReturnValue(MCPDiscoveryState.IN_PROGRESS),
getMcpServerCount: vi.fn().mockReturnValue(1),
};
mockConfig.getMcpClientManager = () => mockMcpClientManager as any;
const { result } = renderTestHook();
await act(async () => {
await result.current.submitQuery('test query');
});
expect(coreEvents.emitFeedback).toHaveBeenCalledWith(
'info',
'Waiting for MCP servers to initialize... Slash commands are still available.',
);
expect(mockSendMessageStream).not.toHaveBeenCalled();
});
it('should NOT block queries when discovery is NOT_STARTED but there are no servers', async () => {
const mockMcpClientManager = {
getDiscoveryState: vi
.fn()
.mockReturnValue(MCPDiscoveryState.NOT_STARTED),
getMcpServerCount: vi.fn().mockReturnValue(0),
};
mockConfig.getMcpClientManager = () => mockMcpClientManager as any;
const { result } = renderTestHook();
await act(async () => {
await result.current.submitQuery('test query');
});
expect(coreEvents.emitFeedback).not.toHaveBeenCalledWith(
'info',
'Waiting for MCP servers to initialize... Slash commands are still available.',
);
expect(mockSendMessageStream).toHaveBeenCalled();
});
it('should NOT block slash commands even when discovery is in progress', async () => {
const mockMcpClientManager = {
getDiscoveryState: vi
.fn()
.mockReturnValue(MCPDiscoveryState.IN_PROGRESS),
getMcpServerCount: vi.fn().mockReturnValue(1),
};
mockConfig.getMcpClientManager = () => mockMcpClientManager as any;
const { result } = renderTestHook();
await act(async () => {
await result.current.submitQuery('/help');
});
expect(coreEvents.emitFeedback).not.toHaveBeenCalledWith(
'info',
'Waiting for MCP servers to initialize... Slash commands are still available.',
);
});
});
describe('handleFinishedEvent', () => {
it('should add info message for MAX_TOKENS finish reason', async () => {
// Setup mock to return a stream with MAX_TOKENS finish reason
@@ -3182,68 +3115,4 @@ describe('useGeminiStream', () => {
});
});
});
describe('MCP Server Initialization', () => {
it('should allow slash commands to run while MCP servers are initializing', async () => {
const mockMcpClientManager = {
getDiscoveryState: vi
.fn()
.mockReturnValue(MCPDiscoveryState.IN_PROGRESS),
getMcpServerCount: vi.fn().mockReturnValue(1),
};
mockConfig.getMcpClientManager = () => mockMcpClientManager as any;
const { result } = renderTestHook();
await act(async () => {
await result.current.submitQuery('/help');
});
// Slash command should be handled, and no Gemini call should be made.
expect(mockHandleSlashCommand).toHaveBeenCalledWith('/help');
expect(coreEvents.emitFeedback).not.toHaveBeenCalled();
});
it('should block normal prompts and provide feedback while MCP servers are initializing', async () => {
const mockMcpClientManager = {
getDiscoveryState: vi
.fn()
.mockReturnValue(MCPDiscoveryState.IN_PROGRESS),
getMcpServerCount: vi.fn().mockReturnValue(1),
};
mockConfig.getMcpClientManager = () => mockMcpClientManager as any;
const { result } = renderTestHook();
await act(async () => {
await result.current.submitQuery('a normal prompt');
});
// No slash command, no Gemini call, but feedback should be emitted.
expect(mockHandleSlashCommand).not.toHaveBeenCalled();
expect(mockSendMessageStream).not.toHaveBeenCalled();
expect(coreEvents.emitFeedback).toHaveBeenCalledWith(
'info',
'Waiting for MCP servers to initialize... Slash commands are still available.',
);
});
it('should allow normal prompts to run when MCP servers are finished initializing', async () => {
const mockMcpClientManager = {
getDiscoveryState: vi.fn().mockReturnValue(MCPDiscoveryState.COMPLETED),
getMcpServerCount: vi.fn().mockReturnValue(1),
};
mockConfig.getMcpClientManager = () => mockMcpClientManager as any;
const { result } = renderTestHook();
await act(async () => {
await result.current.submitQuery('a normal prompt');
});
// Prompt should be sent to Gemini.
expect(mockHandleSlashCommand).not.toHaveBeenCalled();
expect(mockSendMessageStream).toHaveBeenCalled();
expect(coreEvents.emitFeedback).not.toHaveBeenCalled();
});
});
});
+39 -22
View File
@@ -28,9 +28,9 @@ import {
processRestorableToolCalls,
recordToolCallInteractions,
ToolErrorType,
ValidationRequiredError,
coreEvents,
CoreEvent,
MCPDiscoveryState,
} from '@google/gemini-cli-core';
import type {
Config,
@@ -802,7 +802,12 @@ export const useGeminiStream = (
);
const handleAgentExecutionStoppedEvent = useCallback(
(reason: string, userMessageTimestamp: number, systemMessage?: string) => {
(
reason: string,
userMessageTimestamp: number,
systemMessage?: string,
contextCleared?: boolean,
) => {
if (pendingHistoryItemRef.current) {
addItem(pendingHistoryItemRef.current, userMessageTimestamp);
setPendingHistoryItem(null);
@@ -814,13 +819,27 @@ export const useGeminiStream = (
},
userMessageTimestamp,
);
if (contextCleared) {
addItem(
{
type: MessageType.INFO,
text: 'Conversation context has been cleared.',
},
userMessageTimestamp,
);
}
setIsResponding(false);
},
[addItem, pendingHistoryItemRef, setPendingHistoryItem, setIsResponding],
);
const handleAgentExecutionBlockedEvent = useCallback(
(reason: string, userMessageTimestamp: number, systemMessage?: string) => {
(
reason: string,
userMessageTimestamp: number,
systemMessage?: string,
contextCleared?: boolean,
) => {
if (pendingHistoryItemRef.current) {
addItem(pendingHistoryItemRef.current, userMessageTimestamp);
setPendingHistoryItem(null);
@@ -832,6 +851,15 @@ export const useGeminiStream = (
},
userMessageTimestamp,
);
if (contextCleared) {
addItem(
{
type: MessageType.INFO,
text: 'Conversation context has been cleared.',
},
userMessageTimestamp,
);
}
},
[addItem, pendingHistoryItemRef, setPendingHistoryItem],
);
@@ -872,6 +900,7 @@ export const useGeminiStream = (
event.value.reason,
userMessageTimestamp,
event.value.systemMessage,
event.value.contextCleared,
);
break;
case ServerGeminiEventType.AgentExecutionBlocked:
@@ -879,6 +908,7 @@ export const useGeminiStream = (
event.value.reason,
userMessageTimestamp,
event.value.systemMessage,
event.value.contextCleared,
);
break;
case ServerGeminiEventType.ChatCompressed:
@@ -960,25 +990,6 @@ export const useGeminiStream = (
async ({ metadata: spanMetadata }) => {
spanMetadata.input = query;
const discoveryState = config
.getMcpClientManager()
?.getDiscoveryState();
const mcpServerCount =
config.getMcpClientManager()?.getMcpServerCount() ?? 0;
if (
!options?.isContinuation &&
typeof query === 'string' &&
!isSlashCommand(query.trim()) &&
mcpServerCount > 0 &&
discoveryState !== MCPDiscoveryState.COMPLETED
) {
coreEvents.emitFeedback(
'info',
'Waiting for MCP servers to initialize... Slash commands are still available.',
);
return;
}
const queryId = `${Date.now()}-${Math.random()}`;
activeQueryIdRef.current = queryId;
if (
@@ -1100,6 +1111,12 @@ export const useGeminiStream = (
spanMetadata.error = error;
if (error instanceof UnauthorizedError) {
onAuthError('Session expired or is unauthorized.');
} else if (
// Suppress ValidationRequiredError if it was marked as handled (e.g. user clicked change_auth or cancelled)
error instanceof ValidationRequiredError &&
error.userHandled
) {
// Error was handled by validation dialog, don't display again
} else if (!isNodeError(error) || error.name !== 'AbortError') {
addItem(
{
@@ -0,0 +1,97 @@
/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import { describe, it, expect, vi, beforeEach, type Mock } from 'vitest';
import { act } from 'react';
import { render } from '../../test-utils/render.js';
import { useMcpStatus } from './useMcpStatus.js';
import {
MCPDiscoveryState,
type Config,
CoreEvent,
coreEvents,
} from '@google/gemini-cli-core';
describe('useMcpStatus', () => {
let mockConfig: Config;
let mockMcpClientManager: {
getDiscoveryState: Mock<() => MCPDiscoveryState>;
getMcpServerCount: Mock<() => number>;
};
beforeEach(() => {
mockMcpClientManager = {
getDiscoveryState: vi.fn().mockReturnValue(MCPDiscoveryState.NOT_STARTED),
getMcpServerCount: vi.fn().mockReturnValue(0),
};
mockConfig = {
getMcpClientManager: vi.fn().mockReturnValue(mockMcpClientManager),
} as unknown as Config;
});
const renderMcpStatusHook = (config: Config) => {
let hookResult: ReturnType<typeof useMcpStatus>;
function TestComponent({ config }: { config: Config }) {
hookResult = useMcpStatus(config);
return null;
}
render(<TestComponent config={config} />);
return {
result: {
get current() {
return hookResult;
},
},
};
};
it('should initialize with correct values (no servers)', () => {
const { result } = renderMcpStatusHook(mockConfig);
expect(result.current.discoveryState).toBe(MCPDiscoveryState.NOT_STARTED);
expect(result.current.mcpServerCount).toBe(0);
expect(result.current.isMcpReady).toBe(true);
});
it('should initialize with correct values (with servers, not started)', () => {
mockMcpClientManager.getMcpServerCount.mockReturnValue(1);
const { result } = renderMcpStatusHook(mockConfig);
expect(result.current.isMcpReady).toBe(false);
});
it('should not be ready while in progress', () => {
mockMcpClientManager.getDiscoveryState.mockReturnValue(
MCPDiscoveryState.IN_PROGRESS,
);
mockMcpClientManager.getMcpServerCount.mockReturnValue(1);
const { result } = renderMcpStatusHook(mockConfig);
expect(result.current.isMcpReady).toBe(false);
});
it('should update state when McpClientUpdate is emitted', () => {
mockMcpClientManager.getMcpServerCount.mockReturnValue(1);
mockMcpClientManager.getDiscoveryState.mockReturnValue(
MCPDiscoveryState.IN_PROGRESS,
);
const { result } = renderMcpStatusHook(mockConfig);
expect(result.current.isMcpReady).toBe(false);
mockMcpClientManager.getDiscoveryState.mockReturnValue(
MCPDiscoveryState.COMPLETED,
);
act(() => {
coreEvents.emit(CoreEvent.McpClientUpdate, new Map());
});
expect(result.current.discoveryState).toBe(MCPDiscoveryState.COMPLETED);
expect(result.current.isMcpReady).toBe(true);
});
});

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