mirror of
https://github.com/google-gemini/gemini-cli.git
synced 2026-07-27 10:11:00 -07:00
Compare commits
47 Commits
400-errors
...
v0.26.0
| Author | SHA1 | Date | |
|---|---|---|---|
| c1b110a618 | |||
| a380b4219c | |||
| 31c6fef1e8 | |||
| d8e9db3761 | |||
| 43846f4e3b | |||
| 2a3c879782 | |||
| 958cc45937 | |||
| 9c667cf7ba | |||
| c593a29647 | |||
| cebe386d79 | |||
| 1c207e2f82 | |||
| ee87c98f43 | |||
| 75b5eeeb12 | |||
| 0fa9a54088 | |||
| 603e66b2ea | |||
| dc8fc75ac0 | |||
| 2b58605227 | |||
| 97aac696fb | |||
| 93ae7772fd | |||
| 9866eb0551 | |||
| 55c2783e6a | |||
| 2455f939a3 | |||
| 995ae42f53 | |||
| e1fd5be429 | |||
| 3b626e7c61 | |||
| aceb06a587 | |||
| 211d2c5fdd | |||
| c9061a1cfe | |||
| b288f124b2 | |||
| 645e2ec041 | |||
| 67d69084e9 | |||
| ed0b0fae49 | |||
| f0f705d3ca | |||
| f42b4c80ac | |||
| b99e841021 | |||
| a16d5983cf | |||
| e5745f16cb | |||
| 12b0fe1cc2 | |||
| 5b8a23979f | |||
| b71fe94e0a | |||
| e92f60b4fc | |||
| 15f26175b8 | |||
| 85b17166a5 | |||
| 88df6210eb | |||
| 943481ccc0 | |||
| 79076d1d52 | |||
| 166e04a8dd |
@@ -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
|
||||
|
||||
@@ -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).
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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 -->
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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.
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -80,10 +80,6 @@
|
||||
"label": "Model selection",
|
||||
"slug": "docs/cli/model"
|
||||
},
|
||||
{
|
||||
"label": "Rewind",
|
||||
"slug": "docs/cli/rewind"
|
||||
},
|
||||
{
|
||||
"label": "Sandbox",
|
||||
"slug": "docs/cli/sandbox"
|
||||
|
||||
@@ -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'],
|
||||
|
||||
@@ -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');
|
||||
},
|
||||
});
|
||||
});
|
||||
@@ -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: {
|
||||
|
||||
@@ -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: [
|
||||
|
||||
@@ -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',
|
||||
|
||||
Generated
+22
-33
@@ -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
@@ -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,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",
|
||||
|
||||
@@ -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,
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
|
||||
@@ -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();
|
||||
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
@@ -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.',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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)}`);
|
||||
}
|
||||
|
||||
@@ -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: () => {
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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)`;
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
@@ -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: () => {
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -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.',
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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',
|
||||
});
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -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({
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
),
|
||||
};
|
||||
},
|
||||
};
|
||||
@@ -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();
|
||||
});
|
||||
|
||||
@@ -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
|
||||
});
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
+30
-10
@@ -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();
|
||||
});
|
||||
});
|
||||
+39
-19
@@ -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();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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
Reference in New Issue
Block a user