mirror of
https://github.com/QwenLM/qwen-code.git
synced 2025-12-30 13:39:15 +00:00
Compare commits
29 Commits
v0.0.12-ni
...
fix/is_bac
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
bee0bc2b32 | ||
|
|
724c24933c | ||
|
|
17cdce6298 | ||
|
|
de468f0525 | ||
|
|
50199288ec | ||
|
|
8803b2eb76 | ||
|
|
b99de25e38 | ||
|
|
e552bc9609 | ||
|
|
5f90472a7d | ||
|
|
d3476a2d47 | ||
|
|
f599cda7d2 | ||
|
|
9df193ca42 | ||
|
|
19950e5b7c | ||
|
|
6dcec540d6 | ||
|
|
0581344d48 | ||
|
|
da78a9ff94 | ||
|
|
8e2fc76c15 | ||
|
|
35efa9f04a | ||
|
|
484f8642c4 | ||
|
|
c093bed38c | ||
|
|
b9fd4737c9 | ||
|
|
8d17864959 | ||
|
|
7109914a86 | ||
|
|
4721a6f324 | ||
|
|
3ad533c50b | ||
|
|
6af52e74ab | ||
|
|
6dad6b7f31 | ||
|
|
2253c7b263 | ||
|
|
386538521b |
2
.gitignore
vendored
2
.gitignore
vendored
@@ -47,3 +47,5 @@ packages/vscode-ide-companion/*.vsix
|
||||
logs/
|
||||
# GHA credentials
|
||||
gha-creds-*.json
|
||||
|
||||
QWEN.md
|
||||
193
QWEN.md
193
QWEN.md
@@ -1,193 +0,0 @@
|
||||
## 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.
|
||||
|
||||
## 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
|
||||
|
||||
## Comments policy
|
||||
|
||||
Only write high-value comments if at all. Avoid talking to the user through comments.
|
||||
|
||||
## General style requirements
|
||||
|
||||
Use hyphens instead of underscores in flag names (e.g. `my-flag` instead of `my_flag`).
|
||||
162
README.gemini.md
162
README.gemini.md
@@ -1,162 +0,0 @@
|
||||
# Gemini CLI
|
||||
|
||||
[](https://github.com/google-gemini/gemini-cli/actions/workflows/ci.yml)
|
||||
|
||||

|
||||
|
||||
This repository contains the Gemini CLI, a command-line AI workflow tool that connects to your
|
||||
tools, understands your code and accelerates your workflows.
|
||||
|
||||
With the Gemini CLI you can:
|
||||
|
||||
- Query and edit large codebases in and beyond Gemini's 1M token context window.
|
||||
- Generate new apps from PDFs or sketches, using Gemini's multimodal capabilities.
|
||||
- Automate operational tasks, like querying pull requests or handling complex rebases.
|
||||
- Use tools and MCP servers to connect new capabilities, including [media generation with Imagen,
|
||||
Veo or Lyria](https://github.com/GoogleCloudPlatform/vertex-ai-creative-studio/tree/main/experiments/mcp-genmedia)
|
||||
- Ground your queries with the [Google Search](https://ai.google.dev/gemini-api/docs/grounding)
|
||||
tool, built in to Gemini.
|
||||
|
||||
## Quickstart
|
||||
|
||||
1. **Prerequisites:** Ensure you have [Node.js version 20](https://nodejs.org/en/download) or higher installed.
|
||||
2. **Run the CLI:** Execute the following command in your terminal:
|
||||
|
||||
```bash
|
||||
npx https://github.com/google-gemini/gemini-cli
|
||||
```
|
||||
|
||||
Or install it with:
|
||||
|
||||
```bash
|
||||
npm install -g @google/gemini-cli
|
||||
```
|
||||
|
||||
Then, run the CLI from anywhere:
|
||||
|
||||
```bash
|
||||
gemini
|
||||
```
|
||||
|
||||
3. **Pick a color theme**
|
||||
4. **Authenticate:** When prompted, sign in with your personal Google account. This will grant you up to 60 model requests per minute and 1,000 model requests per day using Gemini.
|
||||
|
||||
You are now ready to use the Gemini CLI!
|
||||
|
||||
### Use a Gemini API key:
|
||||
|
||||
The Gemini API provides a free tier with [100 requests per day](https://ai.google.dev/gemini-api/docs/rate-limits#free-tier) using Gemini 2.5 Pro, control over which model you use, and access to higher rate limits (with a paid plan):
|
||||
|
||||
1. Generate a key from [Google AI Studio](https://aistudio.google.com/apikey).
|
||||
2. Set it as an environment variable in your terminal. Replace `YOUR_API_KEY` with your generated key.
|
||||
|
||||
```bash
|
||||
export GEMINI_API_KEY="YOUR_API_KEY"
|
||||
```
|
||||
|
||||
3. (Optionally) Upgrade your Gemini API project to a paid plan on the API key page (will automatically unlock [Tier 1 rate limits](https://ai.google.dev/gemini-api/docs/rate-limits#tier-1))
|
||||
|
||||
### Use a Vertex AI API key:
|
||||
|
||||
The Vertex AI API provides a [free tier](https://cloud.google.com/vertex-ai/generative-ai/docs/start/express-mode/overview) using express mode for Gemini 2.5 Pro, control over which model you use, and access to higher rate limits with a billing account:
|
||||
|
||||
1. Generate a key from [Google Cloud](https://cloud.google.com/vertex-ai/generative-ai/docs/start/api-keys).
|
||||
2. Set it as an environment variable in your terminal. Replace `YOUR_API_KEY` with your generated key and set GOOGLE_GENAI_USE_VERTEXAI to true
|
||||
|
||||
```bash
|
||||
export GOOGLE_API_KEY="YOUR_API_KEY"
|
||||
export GOOGLE_GENAI_USE_VERTEXAI=true
|
||||
```
|
||||
|
||||
3. (Optionally) Add a billing account on your project to get access to [higher usage limits](https://cloud.google.com/vertex-ai/generative-ai/docs/quotas)
|
||||
|
||||
For other authentication methods, including Google Workspace accounts, see the [authentication](./docs/cli/authentication.md) guide.
|
||||
|
||||
## Examples
|
||||
|
||||
Once the CLI is running, you can start interacting with Gemini from your shell.
|
||||
|
||||
You can start a project from a new directory:
|
||||
|
||||
```sh
|
||||
cd new-project/
|
||||
gemini
|
||||
> Write me a Gemini Discord bot that answers questions using a FAQ.md file I will provide
|
||||
```
|
||||
|
||||
Or work with an existing project:
|
||||
|
||||
```sh
|
||||
git clone https://github.com/google-gemini/gemini-cli
|
||||
cd gemini-cli
|
||||
gemini
|
||||
> Give me a summary of all of the changes that went in yesterday
|
||||
```
|
||||
|
||||
### Next steps
|
||||
|
||||
- Learn how to [contribute to or build from the source](./CONTRIBUTING.md).
|
||||
- Explore the available **[CLI Commands](./docs/cli/commands.md)**.
|
||||
- If you encounter any issues, review the **[troubleshooting guide](./docs/troubleshooting.md)**.
|
||||
- For more comprehensive documentation, see the [full documentation](./docs/index.md).
|
||||
- Take a look at some [popular tasks](#popular-tasks) for more inspiration.
|
||||
- Check out our **[Official Roadmap](./ROADMAP.md)**
|
||||
|
||||
### Troubleshooting
|
||||
|
||||
Head over to the [troubleshooting guide](docs/troubleshooting.md) if you're
|
||||
having issues.
|
||||
|
||||
## Popular tasks
|
||||
|
||||
### Explore a new codebase
|
||||
|
||||
Start by `cd`ing into an existing or newly-cloned repository and running `gemini`.
|
||||
|
||||
```text
|
||||
> Describe the main pieces of this system's architecture.
|
||||
```
|
||||
|
||||
```text
|
||||
> What security mechanisms are in place?
|
||||
```
|
||||
|
||||
### Work with your existing code
|
||||
|
||||
```text
|
||||
> Implement a first draft for GitHub issue #123.
|
||||
```
|
||||
|
||||
```text
|
||||
> Help me migrate this codebase to the latest version of Java. Start with a plan.
|
||||
```
|
||||
|
||||
### Automate your workflows
|
||||
|
||||
Use MCP servers to integrate your local system tools with your enterprise collaboration suite.
|
||||
|
||||
```text
|
||||
> Make me a slide deck showing the git history from the last 7 days, grouped by feature and team member.
|
||||
```
|
||||
|
||||
```text
|
||||
> Make a full-screen web app for a wall display to show our most interacted-with GitHub issues.
|
||||
```
|
||||
|
||||
### Interact with your system
|
||||
|
||||
```text
|
||||
> Convert all the images in this directory to png, and rename them to use dates from the exif data.
|
||||
```
|
||||
|
||||
```text
|
||||
> Organize my PDF invoices by month of expenditure.
|
||||
```
|
||||
|
||||
### Uninstall
|
||||
|
||||
Head over to the [Uninstall](docs/Uninstall.md) guide for uninstallation instructions.
|
||||
|
||||
## Terms of Service and Privacy Notice
|
||||
|
||||
For details on the terms of service and privacy notice applicable to your use of Gemini CLI, see the [Terms of Service and Privacy Notice](./docs/tos-privacy.md).
|
||||
@@ -1,63 +0,0 @@
|
||||
# Qwen CLI Roadmap
|
||||
|
||||
The [Official Gemini CLI Roadmap](https://github.com/orgs/google-gemini/projects/11/)
|
||||
|
||||
Gemini CLI is an open-source AI agent that brings the power of Gemini directly into your terminal. It provides lightweight access to Gemini, giving you the most direct path from your prompt to our model.
|
||||
|
||||
This document outlines our approach to the Gemini CLI roadmap. Here, you'll find our guiding principles and a breakdown of the key areas we are
|
||||
focused on for development. Our roadmap is not a static list but a dynamic set of priorities that are tracked live in our GitHub Issues.
|
||||
|
||||
As an [Apache 2.0 open source project](https://github.com/google-gemini/gemini-cli?tab=Apache-2.0-1-ov-file#readme), we appreciate and welcome [public contributions](https://github.com/google-gemini/gemini-cli/blob/main/CONTRIBUTING.md), and will give first priority to those contributions aligned with our roadmap. If you want to propose a new feature or change to our roadmap, please start by [opening an issue for discussion](https://github.com/google-gemini/gemini-cli/issues/new/choose).
|
||||
|
||||
## Disclaimer
|
||||
|
||||
This roadmap represents our current thinking and is for informational purposes only. It is not a commitment or a guarantee of future delivery. The development, release, and timing of any features are subject to change, and we may update the roadmap based on community discussions as well as when our priorities evolve.
|
||||
|
||||
## Guiding Principles
|
||||
|
||||
Our development is guided by the following principles:
|
||||
|
||||
- **Power & Simplicity:** Deliver access to state-of-the-art Gemini models with an intuitive and easy-to-use lightweight command-line interface.
|
||||
- **Extensibility:** An adaptable agent to help you with a variety of use cases and environments along with the ability to run these agents anywhere.
|
||||
- **Intelligent:** Gemini CLI should be reliably ranked among the best agentic tools as measured by benchmarks like SWE Bench, Terminal Bench, and CSAT.
|
||||
- **Free and Open Source:** Foster a thriving open source community where cost isn’t a barrier to personal use, and PRs get merged quickly. This means resolving and closing issues, pull requests, and discussion posts quickly.
|
||||
|
||||
## How the Roadmap Works
|
||||
|
||||
Our roadmap is managed directly through GitHub Issues. See our entry point Roadmap Issue [here](https://github.com/google-gemini/gemini-cli/issues/4191). This approach allows for transparency and gives you a direct way to learn more or get involved with any specific initiative. All our roadmap items will be tagged as Type:`Feature` and Label:`maintainer` for features we are actively working on, or Type:`Task` and Label:`maintainer` for a more detailed list of tasks.
|
||||
|
||||
Issues are organized to provide key information at a glance:
|
||||
|
||||
- **Target Quarter:** `Milestone` denotes the anticipated delivery timeline.
|
||||
- **Feature Area:** Labels such as `area/model` or `area/tooling` categorize the work.
|
||||
- **Issue Type:** _Workstream_ => _Epics_ => _Features_ => _Tasks|Bugs_
|
||||
|
||||
To see what we're working on, you can filter our issues by these dimensions. See all our items [here](https://github.com/orgs/google-gemini/projects/11/views/19)
|
||||
|
||||
## Focus Areas
|
||||
|
||||
To better organize our efforts, we categorize our work into several key feature areas. These labels are used on our GitHub Issues to help you filter and
|
||||
find initiatives that interest you.
|
||||
|
||||
- **Authentication:** Secure user access via API keys, Gemini Code Assist login, etc.
|
||||
- **Model:** Support new Gemini models, multi-modality, local execution, and performance tuning.
|
||||
- **User Experience:** Improve the CLI's usability, performance, interactive features, and documentation.
|
||||
- **Tooling:** Built-in tools and the MCP ecosystem.
|
||||
- **Core:** Core functionality of the CLI
|
||||
- **Extensibility:** Bringing Gemini CLI to other surfaces e.g. GitHub.
|
||||
- **Contribution:** Improve the contribution process via test automation and CI/CD pipeline enhancements.
|
||||
- **Platform:** Manage installation, OS support, and the underlying CLI framework.
|
||||
- **Quality:** Focus on testing, reliability, performance, and overall product quality.
|
||||
- **Background Agents:** Enable long-running, autonomous tasks and proactive assistance.
|
||||
- **Security and Privacy:** For all things related to security and privacy
|
||||
|
||||
## How to Contribute
|
||||
|
||||
Gemini CLI is an open-source project, and we welcome contributions from the community! Whether you're a developer, a designer, or just an enthusiastic user you can find our [Community Guidelines here](https://github.com/google-gemini/gemini-cli/blob/main/CONTRIBUTING.md) to learn how to get started. There are many ways to get involved:
|
||||
|
||||
- **Roadmap:** Please review and find areas in our [roadmap](https://github.com/google-gemini/gemini-cli/issues/4191) that you would like to contribute to. Contributions based on this will be easiest to integrate with.
|
||||
- **Report Bugs:** If you find an issue, please create a [bug](https://github.com/google-gemini/gemini-cli/issues/new?template=bug_report.yml) with as much detail as possible. If you believe it is a critical breaking issue preventing direct CLI usage, please tag it as `priority/p0`.
|
||||
- **Suggest Features:** Have a great idea? We'd love to hear it! Open a [feature request](https://github.com/google-gemini/gemini-cli/issues/new?template=feature_request.yml).
|
||||
- **Contribute Code:** Check out our [CONTRIBUTING.md](https://github.com/google-gemini/gemini-cli/blob/main/CONTRIBUTING.md) file for guidelines on how to submit pull requests. We have a list of "good first issues" for new contributors.
|
||||
- **Write Documentation:** Help us improve our documentation, tutorials, and examples.
|
||||
We are excited about the future of Gemini CLI and look forward to building it with you!
|
||||
@@ -346,6 +346,22 @@ If you are experiencing performance issues with file searching (e.g., with `@` c
|
||||
}
|
||||
```
|
||||
|
||||
- **`skipNextSpeakerCheck`** (boolean):
|
||||
- **Description:** Skips the next speaker check after text responses. When enabled, the system bypasses analyzing whether the AI should continue speaking.
|
||||
- **Default:** `false`
|
||||
- **Example:**
|
||||
```json
|
||||
"skipNextSpeakerCheck": true
|
||||
```
|
||||
|
||||
- **`skipLoopDetection`** (boolean):
|
||||
- **Description:** Disables all loop detection checks (streaming and LLM-based). Loop detection prevents infinite loops in AI responses but can generate false positives that interrupt legitimate workflows. Enable this option if you experience frequent false positive loop detection interruptions.
|
||||
- **Default:** `false`
|
||||
- **Example:**
|
||||
```json
|
||||
"skipLoopDetection": true
|
||||
```
|
||||
|
||||
### Example `settings.json`:
|
||||
|
||||
```json
|
||||
@@ -373,6 +389,8 @@ If you are experiencing performance issues with file searching (e.g., with `@` c
|
||||
"usageStatisticsEnabled": true,
|
||||
"hideTips": false,
|
||||
"hideBanner": false,
|
||||
"skipNextSpeakerCheck": false,
|
||||
"skipLoopDetection": false,
|
||||
"maxSessionTurns": 10,
|
||||
"summarizeToolOutput": {
|
||||
"run_shell_command": {
|
||||
|
||||
@@ -1,336 +0,0 @@
|
||||
# Gemini CLI for the Enterprise
|
||||
|
||||
This document outlines configuration patterns and best practices for deploying and managing Gemini CLI in an enterprise environment. By leveraging system-level settings, administrators can enforce security policies, manage tool access, and ensure a consistent experience for all users.
|
||||
|
||||
> **A Note on Security:** The patterns described in this document are intended to help administrators create a more controlled and secure environment for using Gemini CLI. However, they should not be considered a foolproof security boundary. A determined user with sufficient privileges on their local machine may still be able to circumvent these configurations. These measures are designed to prevent accidental misuse and enforce corporate policy in a managed environment, not to defend against a malicious actor with local administrative rights.
|
||||
|
||||
## Centralized Configuration: The System Settings File
|
||||
|
||||
The most powerful tools for enterprise administration are the system-wide settings files. These files allow you to define a baseline configuration (`system-defaults.json`) and a set of overrides (`settings.json`) that apply to all users on a machine. For a complete overview of configuration options, see the [Configuration documentation](./configuration.md).
|
||||
|
||||
Settings are merged from four files. The precedence order for single-value settings (like `theme`) is:
|
||||
|
||||
1. System Defaults (`system-defaults.json`)
|
||||
2. User Settings (`~/.gemini/settings.json`)
|
||||
3. Workspace Settings (`<project>/.gemini/settings.json`)
|
||||
4. System Overrides (`settings.json`)
|
||||
|
||||
This means the System Overrides file has the final say. For settings that are arrays (`includeDirectories`) or objects (`mcpServers`), the values are merged.
|
||||
|
||||
**Example of Merging and Precedence:**
|
||||
|
||||
Here is how settings from different levels are combined.
|
||||
|
||||
- **System Defaults `system-defaults.json`:**
|
||||
|
||||
```json
|
||||
{
|
||||
"theme": "default-corporate-theme",
|
||||
"includeDirectories": ["/etc/gemini-cli/common-context"]
|
||||
}
|
||||
```
|
||||
|
||||
- **User `settings.json` (`~/.gemini/settings.json`):**
|
||||
|
||||
```json
|
||||
{
|
||||
"theme": "user-preferred-dark-theme",
|
||||
"mcpServers": {
|
||||
"corp-server": {
|
||||
"command": "/usr/local/bin/corp-server-dev"
|
||||
},
|
||||
"user-tool": {
|
||||
"command": "npm start --prefix ~/tools/my-tool"
|
||||
}
|
||||
},
|
||||
"includeDirectories": ["~/gemini-context"]
|
||||
}
|
||||
```
|
||||
|
||||
- **Workspace `settings.json` (`<project>/.gemini/settings.json`):**
|
||||
|
||||
```json
|
||||
{
|
||||
"theme": "project-specific-light-theme",
|
||||
"mcpServers": {
|
||||
"project-tool": {
|
||||
"command": "npm start"
|
||||
}
|
||||
},
|
||||
"includeDirectories": ["./project-context"]
|
||||
}
|
||||
```
|
||||
|
||||
- **System Overrides `settings.json`:**
|
||||
```json
|
||||
{
|
||||
"theme": "system-enforced-theme",
|
||||
"mcpServers": {
|
||||
"corp-server": {
|
||||
"command": "/usr/local/bin/corp-server-prod"
|
||||
}
|
||||
},
|
||||
"includeDirectories": ["/etc/gemini-cli/global-context"]
|
||||
}
|
||||
```
|
||||
|
||||
This results in the following merged configuration:
|
||||
|
||||
- **Final Merged Configuration:**
|
||||
```json
|
||||
{
|
||||
"theme": "system-enforced-theme",
|
||||
"mcpServers": {
|
||||
"corp-server": {
|
||||
"command": "/usr/local/bin/corp-server-prod"
|
||||
},
|
||||
"user-tool": {
|
||||
"command": "npm start --prefix ~/tools/my-tool"
|
||||
},
|
||||
"project-tool": {
|
||||
"command": "npm start"
|
||||
}
|
||||
},
|
||||
"includeDirectories": [
|
||||
"/etc/gemini-cli/common-context",
|
||||
"~/gemini-context",
|
||||
"./project-context",
|
||||
"/etc/gemini-cli/global-context"
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
**Why:**
|
||||
|
||||
- **`theme`**: The value from the system overrides (`system-enforced-theme`) is used, as it has the highest precedence.
|
||||
- **`mcpServers`**: The objects are merged. The `corp-server` definition from the system overrides takes precedence over the user's definition. The unique `user-tool` and `project-tool` are included.
|
||||
- **`includeDirectories`**: The arrays are concatenated in the order of System Defaults, User, Workspace, and then System Overrides.
|
||||
|
||||
- **Location**:
|
||||
- **Linux**: `/etc/gemini-cli/settings.json`
|
||||
- **Windows**: `C:\ProgramData\gemini-cli\settings.json`
|
||||
- **macOS**: `/Library/Application Support/GeminiCli/settings.json`
|
||||
- The path can be overridden using the `GEMINI_CLI_SYSTEM_SETTINGS_PATH` environment variable.
|
||||
- **Control**: This file should be managed by system administrators and protected with appropriate file permissions to prevent unauthorized modification by users.
|
||||
|
||||
By using the system settings file, you can enforce the security and configuration patterns described below.
|
||||
|
||||
## Restricting Tool Access
|
||||
|
||||
You can significantly enhance security by controlling which tools the Gemini model can use. This is achieved through the `coreTools` and `excludeTools` settings. For a list of available tools, see the [Tools documentation](../tools/index.md).
|
||||
|
||||
### Allowlisting with `coreTools`
|
||||
|
||||
The most secure approach is to explicitly add the tools and commands that users are permitted to execute to an allowlist. This prevents the use of any tool not on the approved list.
|
||||
|
||||
**Example:** Allow only safe, read-only file operations and listing files.
|
||||
|
||||
```json
|
||||
{
|
||||
"coreTools": ["ReadFileTool", "GlobTool", "ShellTool(ls)"]
|
||||
}
|
||||
```
|
||||
|
||||
### Blocklisting with `excludeTools`
|
||||
|
||||
Alternatively, you can add specific tools that are considered dangerous in your environment to a blocklist.
|
||||
|
||||
**Example:** Prevent the use of the shell tool for removing files.
|
||||
|
||||
```json
|
||||
{
|
||||
"excludeTools": ["ShellTool(rm -rf)"]
|
||||
}
|
||||
```
|
||||
|
||||
**Security Note:** Blocklisting with `excludeTools` is less secure than allowlisting with `coreTools`, as it relies on blocking known-bad commands, and clever users may find ways to bypass simple string-based blocks. **Allowlisting is the recommended approach.**
|
||||
|
||||
## Managing Custom Tools (MCP Servers)
|
||||
|
||||
If your organization uses custom tools via [Model-Context Protocol (MCP) servers](../core/tools-api.md), it is crucial to understand how server configurations are managed to apply security policies effectively.
|
||||
|
||||
### How MCP Server Configurations are Merged
|
||||
|
||||
Gemini CLI loads `settings.json` files from three levels: System, Workspace, and User. When it comes to the `mcpServers` object, these configurations are **merged**:
|
||||
|
||||
1. **Merging:** The lists of servers from all three levels are combined into a single list.
|
||||
2. **Precedence:** If a server with the **same name** is defined at multiple levels (e.g., a server named `corp-api` exists in both system and user settings), the definition from the highest-precedence level is used. The order of precedence is: **System > Workspace > User**.
|
||||
|
||||
This means a user **cannot** override the definition of a server that is already defined in the system-level settings. However, they **can** add new servers with unique names.
|
||||
|
||||
### Enforcing a Catalog of Tools
|
||||
|
||||
The security of your MCP tool ecosystem depends on a combination of defining the canonical servers and adding their names to an allowlist.
|
||||
|
||||
### Restricting Tools Within an MCP Server
|
||||
|
||||
For even greater security, especially when dealing with third-party MCP servers, you can restrict which specific tools from a server are exposed to the model. This is done using the `includeTools` and `excludeTools` properties within a server's definition. This allows you to use a subset of tools from a server without allowing potentially dangerous ones.
|
||||
|
||||
Following the principle of least privilege, it is highly recommended to use `includeTools` to create an allowlist of only the necessary tools.
|
||||
|
||||
**Example:** Only allow the `code-search` and `get-ticket-details` tools from a third-party MCP server, even if the server offers other tools like `delete-ticket`.
|
||||
|
||||
```json
|
||||
{
|
||||
"allowMCPServers": ["third-party-analyzer"],
|
||||
"mcpServers": {
|
||||
"third-party-analyzer": {
|
||||
"command": "/usr/local/bin/start-3p-analyzer.sh",
|
||||
"includeTools": ["code-search", "get-ticket-details"]
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
#### More Secure Pattern: Define and Add to Allowlist in System Settings
|
||||
|
||||
To create a secure, centrally-managed catalog of tools, the system administrator **must** do both of the following in the system-level `settings.json` file:
|
||||
|
||||
1. **Define the full configuration** for every approved server in the `mcpServers` object. This ensures that even if a user defines a server with the same name, the secure system-level definition will take precedence.
|
||||
2. **Add the names** of those servers to an allowlist using the `allowMCPServers` setting. This is a critical security step that prevents users from running any servers that are not on this list. If this setting is omitted, the CLI will merge and allow any server defined by the user.
|
||||
|
||||
**Example System `settings.json`:**
|
||||
|
||||
1. Add the _names_ of all approved servers to an allowlist.
|
||||
This will prevent users from adding their own servers.
|
||||
|
||||
2. Provide the canonical _definition_ for each server on the allowlist.
|
||||
|
||||
```json
|
||||
{
|
||||
"allowMCPServers": ["corp-data-api", "source-code-analyzer"],
|
||||
"mcpServers": {
|
||||
"corp-data-api": {
|
||||
"command": "/usr/local/bin/start-corp-api.sh",
|
||||
"timeout": 5000
|
||||
},
|
||||
"source-code-analyzer": {
|
||||
"command": "/usr/local/bin/start-analyzer.sh"
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
This pattern is more secure because it uses both definition and an allowlist. Any server a user defines will either be overridden by the system definition (if it has the same name) or blocked because its name is not in the `allowMCPServers` list.
|
||||
|
||||
### Less Secure Pattern: Omitting the Allowlist
|
||||
|
||||
If the administrator defines the `mcpServers` object but fails to also specify the `allowMCPServers` allowlist, users may add their own servers.
|
||||
|
||||
**Example System `settings.json`:**
|
||||
|
||||
This configuration defines servers but does not enforce the allowlist.
|
||||
The administrator has NOT included the "allowMCPServers" setting.
|
||||
|
||||
```json
|
||||
{
|
||||
"mcpServers": {
|
||||
"corp-data-api": {
|
||||
"command": "/usr/local/bin/start-corp-api.sh"
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
In this scenario, a user can add their own server in their local `settings.json`. Because there is no `allowMCPServers` list to filter the merged results, the user's server will be added to the list of available tools and allowed to run.
|
||||
|
||||
## Enforcing Sandboxing for Security
|
||||
|
||||
To mitigate the risk of potentially harmful operations, you can enforce the use of sandboxing for all tool execution. The sandbox isolates tool execution in a containerized environment.
|
||||
|
||||
**Example:** Force all tool execution to happen within a Docker sandbox.
|
||||
|
||||
```json
|
||||
{
|
||||
"sandbox": "docker"
|
||||
}
|
||||
```
|
||||
|
||||
You can also specify a custom, hardened Docker image for the sandbox using the `--sandbox-image` command-line argument or by building a custom `sandbox.Dockerfile` as described in the [Sandboxing documentation](./configuration.md#sandboxing).
|
||||
|
||||
## Controlling Network Access via Proxy
|
||||
|
||||
In corporate environments with strict network policies, you can configure Gemini CLI to route all outbound traffic through a corporate proxy. This can be set via an environment variable, but it can also be enforced for custom tools via the `mcpServers` configuration.
|
||||
|
||||
**Example (for an MCP Server):**
|
||||
|
||||
```json
|
||||
{
|
||||
"mcpServers": {
|
||||
"proxied-server": {
|
||||
"command": "node",
|
||||
"args": ["mcp_server.js"],
|
||||
"env": {
|
||||
"HTTP_PROXY": "http://proxy.example.com:8080",
|
||||
"HTTPS_PROXY": "http://proxy.example.com:8080"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Telemetry and Auditing
|
||||
|
||||
For auditing and monitoring purposes, you can configure Gemini CLI to send telemetry data to a central location. This allows you to track tool usage and other events. For more information, see the [telemetry documentation](../telemetry.md).
|
||||
|
||||
**Example:** Enable telemetry and send it to a local OTLP collector. If `otlpEndpoint` is not specified, it defaults to `http://localhost:4317`.
|
||||
|
||||
```json
|
||||
{
|
||||
"telemetry": {
|
||||
"enabled": true,
|
||||
"target": "gcp",
|
||||
"logPrompts": false
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Note:** Ensure that `logPrompts` is set to `false` in an enterprise setting to avoid collecting potentially sensitive information from user prompts.
|
||||
|
||||
## Putting It All Together: Example System `settings.json`
|
||||
|
||||
Here is an example of a system `settings.json` file that combines several of the patterns discussed above to create a secure, controlled environment for Gemini CLI.
|
||||
|
||||
```json
|
||||
{
|
||||
"sandbox": "docker",
|
||||
|
||||
"coreTools": [
|
||||
"ReadFileTool",
|
||||
"GlobTool",
|
||||
"ShellTool(ls)",
|
||||
"ShellTool(cat)",
|
||||
"ShellTool(grep)"
|
||||
],
|
||||
|
||||
"mcpServers": {
|
||||
"corp-tools": {
|
||||
"command": "/opt/gemini-tools/start.sh",
|
||||
"timeout": 5000
|
||||
}
|
||||
},
|
||||
"allowMCPServers": ["corp-tools"],
|
||||
|
||||
"telemetry": {
|
||||
"enabled": true,
|
||||
"target": "gcp",
|
||||
"otlpEndpoint": "https://telemetry-prod.example.com:4317",
|
||||
"logPrompts": false
|
||||
},
|
||||
|
||||
"bugCommand": {
|
||||
"urlTemplate": "https://servicedesk.example.com/new-ticket?title={title}&details={info}"
|
||||
},
|
||||
|
||||
"usageStatisticsEnabled": false
|
||||
}
|
||||
```
|
||||
|
||||
This configuration:
|
||||
|
||||
- Forces all tool execution into a Docker sandbox.
|
||||
- Strictly uses an allowlist for a small set of safe shell commands and file tools.
|
||||
- Defines and allows a single corporate MCP server for custom tools.
|
||||
- Enables telemetry for auditing, without logging prompt content.
|
||||
- Redirects the `/bug` command to an internal ticketing system.
|
||||
- Disables general usage statistics collection.
|
||||
@@ -81,20 +81,20 @@ You can install extensions using the `install` command. This command allows you
|
||||
|
||||
### Usage
|
||||
|
||||
`gemini extensions install <source> | [options]`
|
||||
`qwen extensions install <source> | [options]`
|
||||
|
||||
### Options
|
||||
|
||||
- `source <url> positional argument`: The URL of a Git repository to install the extension from. The repository must contain a `gemini-extension.json` file in its root.
|
||||
- `--path <path>`: The path to a local directory to install as an extension. The directory must contain a `gemini-extension.json` file.
|
||||
- `source <url> positional argument`: The URL of a Git repository to install the extension from. The repository must contain a `qwen-extension.json` file in its root.
|
||||
- `--path <path>`: The path to a local directory to install as an extension. The directory must contain a `qwen-extension.json` file.
|
||||
|
||||
# Variables
|
||||
|
||||
Gemini CLI extensions allow variable substitution in `gemini-extension.json`. This can be useful if e.g., you need the current directory to run an MCP server using `"cwd": "${extensionPath}${/}run.ts"`.
|
||||
Qwen Code extensions allow variable substitution in `qwen-extension.json`. This can be useful if e.g., you need the current directory to run an MCP server using `"cwd": "${extensionPath}${/}run.ts"`.
|
||||
|
||||
**Supported variables:**
|
||||
|
||||
| variable | description |
|
||||
| -------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| `${extensionPath}` | The fully-qualified path of the extension in the user's filesystem e.g., '/Users/username/.gemini/extensions/example-extension'. This will not unwrap symlinks. |
|
||||
| `${/} or ${pathSeparator}` | The path separator (differs per OS). |
|
||||
| variable | description |
|
||||
| -------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| `${extensionPath}` | The fully-qualified path of the extension in the user's filesystem e.g., '/Users/username/.qwen/extensions/example-extension'. This will not unwrap symlinks. |
|
||||
| `${/} or ${pathSeparator}` | The path separator (differs per OS). |
|
||||
|
||||
@@ -45,7 +45,7 @@ You can also install the extension directly from a marketplace.
|
||||
- **For VS Code Forks:** To support forks of VS Code, the extension is also published on the [Open VSX Registry](https://open-vsx.org/extension/qwenlm/qwen-code-vscode-ide-companion). Follow your editor's instructions for installing extensions from this registry.
|
||||
|
||||
> NOTE:
|
||||
> The "Gemini CLI Companion" extension may appear towards the bottom of search results. If you don't see it immediately, try scrolling down or sorting by "Newly Published".
|
||||
> The "Qwen Code Companion" extension may appear towards the bottom of search results. If you don't see it immediately, try scrolling down or sorting by "Newly Published".
|
||||
>
|
||||
> After manually installing the extension, you must run `/ide enable` in the CLI to activate the integration.
|
||||
|
||||
@@ -80,7 +80,7 @@ If connected, this command will show the IDE it's connected to and a list of rec
|
||||
|
||||
### Working with Diffs
|
||||
|
||||
When you ask Gemini to modify a file, it can open a diff view directly in your editor.
|
||||
When you ask Qwen model to modify a file, it can open a diff view directly in your editor.
|
||||
|
||||
**To accept a diff**, you can perform any of the following actions:
|
||||
|
||||
@@ -139,6 +139,6 @@ If you encounter issues with IDE integration, here are some common error message
|
||||
- **Cause:** You are running Qwen Code in a terminal or environment that is not a supported IDE.
|
||||
- **Solution:** Run Qwen Code from the integrated terminal of a supported IDE, like VS Code.
|
||||
|
||||
- **Message:** `No installer is available for IDE. Please install the Gemini CLI Companion extension manually from the marketplace.`
|
||||
- **Message:** `No installer is available for IDE. Please install the Qwen Code Companion extension manually from the marketplace.`
|
||||
- **Cause:** You ran `/ide install`, but the CLI does not have an automated installer for your specific IDE.
|
||||
- **Solution:** Open your IDE's extension marketplace, search for "Qwen Code Companion", and install it manually.
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
# Ignoring Files
|
||||
|
||||
This document provides an overview of the Gemini Ignore (`.qwenignore`) feature of Qwen Code.
|
||||
This document provides an overview of the Qwen Ignore (`.qwenignore`) feature of Qwen Code.
|
||||
|
||||
Qwen Code includes the ability to automatically ignore files, similar to `.gitignore` (used by Git) and `.aiexclude` (used by Gemini Code Assist). Adding paths to your `.qwenignore` file will exclude them from tools that support this feature, although they will still be visible to other services (such as Git).
|
||||
Qwen Code includes the ability to automatically ignore files, similar to `.gitignore` (used by Git). Adding paths to your `.qwenignore` file will exclude them from tools that support this feature, although they will still be visible to other services (such as Git).
|
||||
|
||||
## How it works
|
||||
|
||||
|
||||
343
docs/releases.md
343
docs/releases.md
@@ -1,343 +0,0 @@
|
||||
# Gemini CLI Releases
|
||||
|
||||
## Release Cadence and Tags
|
||||
|
||||
We will follow https://semver.org/ as closely as possible but will call out when or if we have to deviate from it. Our weekly releases will be minor version increments and any bug or hotfixes btween releases will go out as patch versions on the most recent release.
|
||||
|
||||
### Preview
|
||||
|
||||
New preview releases will be published each week at UTC 2359 on Tuesdays. These releases will not have been fully vetted and may contain regressions or other outstanding issues. Please help us test and install with `preview` tag.
|
||||
|
||||
```bash
|
||||
npm install -g @google/gemini-cli@preview
|
||||
```
|
||||
|
||||
### Stable
|
||||
|
||||
- New stable releases will be published each week at UTC 2000 on Tuesdays, this will be the full promotion of last week's release + any bug fixes and validations. Use `latest` tag.
|
||||
|
||||
```bash
|
||||
npm install -g @google/gemini-cli@latest
|
||||
```
|
||||
|
||||
### Nightly
|
||||
|
||||
- New releases will be published each week at UTC 0000 each day, This will be all changes from the main branch as represted at time of release. It should be assumed there are pending validations and issues. Use `nightly` tag.
|
||||
|
||||
```bash
|
||||
npm install -g @google/gemini-cli@nightly
|
||||
```
|
||||
|
||||
# Release Process.
|
||||
|
||||
Where `x.y.z` is the next version to be released. In most all cases for the weekly release this will be an increment on `y`, aka minor version update. Major version updates `x` will need broader coordination and communication. For patches `z` see below. When possible we will do our best to adher to https://semver.org/
|
||||
|
||||
Our release cadence is new releases are sent to a preview channel for a week and then promoted to stable after a week. Version numbers will follow SemVer with weekly releases incrementing the minor version. Patches and bug fixes to both preview and stable releases will increment the patch version.
|
||||
|
||||
## Nightly Release
|
||||
|
||||
Each night at UTC 0000 we will auto deploy a nightly release from `main`. This will be a version of the next production release, x.y.z, with the nightly tag.
|
||||
|
||||
## Create Preview Release
|
||||
|
||||
Each Tuesday at UTC 2359 we will auto deploy a preview release of the next production release x.y.z.
|
||||
|
||||
- This will happen as a scheduled instance of the ‘release’ action. It will be cut off of Main.
|
||||
- This will create a branch `release/vx.y.z-preview.n`
|
||||
- We will run evals and smoke testing against this branch and the npm package. For now this should be manual smoke testing, we don't have a dedicated matrix or specific detailed process. There is work coming soon to make this more formalized and automatic see https://github.com/google-gemini/gemini-cli/issues/3788
|
||||
- Users installing `@preview` will get this release as well
|
||||
|
||||
## Promote Stable Release
|
||||
|
||||
After one week (On the following Tuesday) with all signals a go, we will manually release at 2000 UTC via the current on-call person.
|
||||
|
||||
- The release action will be used with the source branch as `release/vx.y.z-preview.n`
|
||||
- The version will be x.y.z
|
||||
- The releaser will create and merge a pr into main with the version changes.
|
||||
- Smoke tests and manual validation will be run. For now this should be manual smoke testing, we don't have a dedicated matrix or specific detailed process. There is work coming soon to make this more formalized and automatic see https://github.com/google-gemini/gemini-cli/issues/3788
|
||||
|
||||
## Patching Releases
|
||||
|
||||
If a critical bug needs to be fixed before the next scheduled release, follow this process to create a patch.
|
||||
|
||||
### 1. Create a Hotfix Branch
|
||||
|
||||
First, create a new branch for your fix. The source for this branch depends on whether you are patching a stable or a preview release.
|
||||
|
||||
- **For a stable release patch:**
|
||||
Create a branch from the Git tag of the version you need to patch. Tag names are formatted as `vx.y.z`.
|
||||
|
||||
```bash
|
||||
# Example: Create a hotfix branch for v0.2.0
|
||||
git checkout v0.2.0 -b hotfix/issue-123-fix-for-v0.2.0
|
||||
```
|
||||
|
||||
- **For a preview release patch:**
|
||||
Create a branch from the existing preview release branch, which is formatted as `release/vx.y.z-preview.n`.
|
||||
|
||||
```bash
|
||||
# Example: Create a hotfix branch for a preview release
|
||||
git checkout release/v0.2.0-preview.0 && git checkout -b hotfix/issue-456-fix-for-preview
|
||||
```
|
||||
|
||||
### 2. Implement the Fix
|
||||
|
||||
In your new hotfix branch, either create a new commit with the fix or cherry-pick an existing commit from the `main` branch. Merge your changes into the source of the hotfix branch (ex. https://github.com/google-gemini/gemini-cli/pull/6850).
|
||||
|
||||
### 3. Perform the Release
|
||||
|
||||
Follow the manual release process using the "Release" GitHub Actions workflow.
|
||||
|
||||
- **Version**: For stable patches, increment the patch version (e.g., `v0.2.0` -> `v0.2.1`). For preview patches, increment the preview number (e.g., `v0.2.0-preview.0` -> `v0.2.0-preview.1`).
|
||||
- **Ref**: Use your source branch as the reference (ex. `release/v0.2.0-preview.0`)
|
||||
|
||||

|
||||
|
||||
### 4. Update Versions
|
||||
|
||||
After the hotfix is released, merge the changes back to the appropriate branch.
|
||||
|
||||
- **For a stable release hotfix:**
|
||||
Open a pull request to merge the release branch (e.g., `release/0.2.1`) back into `main`. This keeps the version number in `main` up to date.
|
||||
|
||||
- **For a preview release hotfix:**
|
||||
Open a pull request to merge the new preview release branch (e.g., `release/v0.2.0-preview.1`) back into the existing preview release branch (`release/v0.2.0-preview.0`) (ex. https://github.com/google-gemini/gemini-cli/pull/6868)
|
||||
|
||||
## Release Schedule
|
||||
|
||||
<table>
|
||||
<tr>
|
||||
<td>Date
|
||||
</td>
|
||||
<td>Stable UTC 2000
|
||||
</td>
|
||||
<td>Preview UTC 2359
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>Aug 19th, 2025
|
||||
</td>
|
||||
<td>N/A
|
||||
</td>
|
||||
<td>0.2.0-preview.0
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>Aug 26th, 2025
|
||||
</td>
|
||||
<td>0.2.0
|
||||
</td>
|
||||
<td>0.3.0-preview.0
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>Sep 2nd, 2025
|
||||
</td>
|
||||
<td>0.3.0
|
||||
</td>
|
||||
<td>0.4.0-preview.0
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>Sep 9th, 2025
|
||||
</td>
|
||||
<td>0.4.0
|
||||
</td>
|
||||
<td>0.5.0-preview.0
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>Sep 16th, 2025
|
||||
</td>
|
||||
<td>0.5.0
|
||||
</td>
|
||||
<td>0.6.0-preview.0
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>Sep 23rd, 2025
|
||||
</td>
|
||||
<td>0.6.0
|
||||
</td>
|
||||
<td>0.7.0-preview.0
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
|
||||
## How To Release
|
||||
|
||||
Releases are managed through the [release.yml](https://github.com/google-gemini/gemini-cli/actions/workflows/release.yml) GitHub Actions workflow. To perform a manual release for a patch or hotfix:
|
||||
|
||||
1. Navigate to the **Actions** tab of the repository.
|
||||
2. Select the **Release** workflow from the list.
|
||||
3. Click the **Run workflow** dropdown button.
|
||||
4. Fill in the required inputs:
|
||||
- **Version**: The exact version to release (e.g., `v0.2.1`).
|
||||
- **Ref**: The branch or commit SHA to release from (defaults to `main`).
|
||||
- **Dry Run**: Leave as `true` to test the workflow without publishing, or set to `false` to perform a live release.
|
||||
5. Click **Run workflow**.
|
||||
|
||||
### TLDR
|
||||
|
||||
Each release, wether automated or manual performs the following steps:
|
||||
|
||||
1. Checks out the latest code from the `main` branch.
|
||||
1. Installs all dependencies.
|
||||
1. Runs the full suite of `preflight` checks and integration tests.
|
||||
1. If all tests succeed, it calculates the next version number based on the inputs.
|
||||
1. It creates a branch name `release/${VERSION}`.
|
||||
1. It creates a tag name `v${VERSION}`.
|
||||
1. It then builds and publishes the packages to npm with the provided version number.
|
||||
1. Finally, it creates a GitHub Release for the version.
|
||||
|
||||
### Failure Handling
|
||||
|
||||
If any step in the workflow fails, it will automatically create a new issue in the repository with the labels `bug` and `release-failure`. The issue will contain a link to the failed workflow run for easy debugging.
|
||||
|
||||
### Docker
|
||||
|
||||
We also run a Google cloud build called [release-docker.yml](../.gcp/release-docker.yml). Which publishes the sandbox docker to match your release. This will also be moved to GH and combined with the main release file once service account permissions are sorted out.
|
||||
|
||||
## Release Validation
|
||||
|
||||
After pushing a new release smoke testing should be performed to ensure that the packages are working as expected. This can be done by installing the packages locally and running a set of tests to ensure that they are functioning correctly.
|
||||
|
||||
- `npx -y @google/gemini-cli@latest --version` to validate the push worked as expected if you were not doing a rc or dev tag
|
||||
- `npx -y @google/gemini-cli@<release tag> --version` to validate the tag pushed appropriately
|
||||
- _This is destructive locally_ `npm uninstall @google/gemini-cli && npm uninstall -g @google/gemini-cli && npm cache clean --force && npm install @google/gemini-cli@<version>`
|
||||
- Smoke testing a basic run through of exercising a few llm commands and tools is recommended to ensure that the packages are working as expected. We'll codify this more in the future.
|
||||
|
||||
## Local Testing and Validation: Changes to the Packaging and Publishing Process
|
||||
|
||||
If you need to test the release process without actually publishing to NPM or creating a public GitHub release, you can trigger the workflow manually from the GitHub UI.
|
||||
|
||||
1. Go to the [Actions tab](https://github.com/google-gemini/gemini-cli/actions/workflows/release.yml) of the repository.
|
||||
2. Click on the "Run workflow" dropdown.
|
||||
3. Leave the `dry_run` option checked (`true`).
|
||||
4. Click the "Run workflow" button.
|
||||
|
||||
This will run the entire release process but will skip the `npm publish` and `gh release create` steps. You can inspect the workflow logs to ensure everything is working as expected.
|
||||
|
||||
It is crucial to test any changes to the packaging and publishing process locally before committing them. This ensures that the packages will be published correctly and that they will work as expected when installed by a user.
|
||||
|
||||
To validate your changes, you can perform a dry run of the publishing process. This will simulate the publishing process without actually publishing the packages to the npm registry.
|
||||
|
||||
```bash
|
||||
npm_package_version=9.9.9 SANDBOX_IMAGE_REGISTRY="registry" SANDBOX_IMAGE_NAME="thename" npm run publish:npm --dry-run
|
||||
```
|
||||
|
||||
This command will do the following:
|
||||
|
||||
1. Build all the packages.
|
||||
2. Run all the prepublish scripts.
|
||||
3. Create the package tarballs that would be published to npm.
|
||||
4. Print a summary of the packages that would be published.
|
||||
|
||||
You can then inspect the generated tarballs to ensure that they contain the correct files and that the `package.json` files have been updated correctly. The tarballs will be created in the root of each package's directory (e.g., `packages/cli/google-gemini-cli-0.1.6.tgz`).
|
||||
|
||||
By performing a dry run, you can be confident that your changes to the packaging process are correct and that the packages will be published successfully.
|
||||
|
||||
## Release Deep Dive
|
||||
|
||||
The main goal of the release process is to take the source code from the packages/ directory, build it, and assemble a
|
||||
clean, self-contained package in a temporary `bundle` directory at the root of the project. This `bundle` directory is what
|
||||
actually gets published to NPM.
|
||||
|
||||
Here are the key stages:
|
||||
|
||||
Stage 1: Pre-Release Sanity Checks and Versioning
|
||||
|
||||
- What happens: Before any files are moved, the process ensures the project is in a good state. This involves running tests,
|
||||
linting, and type-checking (npm run preflight). The version number in the root package.json and packages/cli/package.json
|
||||
is updated to the new release version.
|
||||
- Why: This guarantees that only high-quality, working code is released. Versioning is the first step to signify a new
|
||||
release.
|
||||
|
||||
Stage 2: Building the Source Code
|
||||
|
||||
- What happens: The TypeScript source code in packages/core/src and packages/cli/src is compiled into JavaScript.
|
||||
- File movement:
|
||||
- packages/core/src/\*_/_.ts -> compiled to -> packages/core/dist/
|
||||
- packages/cli/src/\*_/_.ts -> compiled to -> packages/cli/dist/
|
||||
- Why: The TypeScript code written during development needs to be converted into plain JavaScript that can be run by
|
||||
Node.js. The core package is built first as the cli package depends on it.
|
||||
|
||||
Stage 3: Assembling the Final Publishable Package
|
||||
|
||||
This is the most critical stage where files are moved and transformed into their final state for publishing. A temporary
|
||||
`bundle` folder is created at the project root to house the final package contents.
|
||||
|
||||
1. The `package.json` is Transformed:
|
||||
- What happens: The package.json from packages/cli/ is read, modified, and written into the root `bundle`/ directory.
|
||||
- File movement: packages/cli/package.json -> (in-memory transformation) -> `bundle`/package.json
|
||||
- Why: The final package.json must be different from the one used in development. Key changes include:
|
||||
- Removing devDependencies.
|
||||
- Removing workspace-specific "dependencies": { "@gemini-cli/core": "workspace:\*" } and ensuring the core code is
|
||||
bundled directly into the final JavaScript file.
|
||||
- Ensuring the bin, main, and files fields point to the correct locations within the final package structure.
|
||||
|
||||
2. The JavaScript Bundle is Created:
|
||||
- What happens: The built JavaScript from both packages/core/dist and packages/cli/dist are bundled into a single,
|
||||
executable JavaScript file.
|
||||
- File movement: packages/cli/dist/index.js + packages/core/dist/index.js -> (bundled by esbuild) -> `bundle`/gemini.js (or a
|
||||
similar name).
|
||||
- Why: This creates a single, optimized file that contains all the necessary application code. It simplifies the package
|
||||
by removing the need for the core package to be a separate dependency on NPM, as its code is now included directly.
|
||||
|
||||
3. Static and Supporting Files are Copied:
|
||||
- What happens: Essential files that are not part of the source code but are required for the package to work correctly
|
||||
or be well-described are copied into the `bundle` directory.
|
||||
- File movement:
|
||||
- README.md -> `bundle`/README.md
|
||||
- LICENSE -> `bundle`/LICENSE
|
||||
- packages/cli/src/utils/\*.sb (sandbox profiles) -> `bundle`/
|
||||
- Why:
|
||||
- The README.md and LICENSE are standard files that should be included in any NPM package.
|
||||
- The sandbox profiles (.sb files) are critical runtime assets required for the CLI's sandboxing feature to
|
||||
function. They must be located next to the final executable.
|
||||
|
||||
Stage 4: Publishing to NPM
|
||||
|
||||
- What happens: The npm publish command is run from inside the root `bundle` directory.
|
||||
- Why: By running npm publish from within the `bundle` directory, only the files we carefully assembled in Stage 3 are uploaded
|
||||
to the NPM registry. This prevents any source code, test files, or development configurations from being accidentally
|
||||
published, resulting in a clean and minimal package for users.
|
||||
|
||||
Summary of File Flow
|
||||
|
||||
```mermaid
|
||||
graph TD
|
||||
subgraph "Source Files"
|
||||
A["packages/core/src/*.ts<br/>packages/cli/src/*.ts"]
|
||||
B["packages/cli/package.json"]
|
||||
C["README.md<br/>LICENSE<br/>packages/cli/src/utils/*.sb"]
|
||||
end
|
||||
|
||||
subgraph "Process"
|
||||
D(Build)
|
||||
E(Transform)
|
||||
F(Assemble)
|
||||
G(Publish)
|
||||
end
|
||||
|
||||
subgraph "Artifacts"
|
||||
H["Bundled JS"]
|
||||
I["Final package.json"]
|
||||
J["bundle/"]
|
||||
end
|
||||
|
||||
subgraph "Destination"
|
||||
K["NPM Registry"]
|
||||
end
|
||||
|
||||
A --> D --> H
|
||||
B --> E --> I
|
||||
C --> F
|
||||
H --> F
|
||||
I --> F
|
||||
F --> J
|
||||
J --> G --> K
|
||||
```
|
||||
|
||||
This process ensures that the final published artifact is a purpose-built, clean, and efficient representation of the
|
||||
project, rather than a direct copy of the development workspace.
|
||||
@@ -133,6 +133,28 @@ Focus on creating clear, comprehensive documentation that helps both
|
||||
new contributors and end users understand the project.
|
||||
```
|
||||
|
||||
## Using Subagents Effectively
|
||||
|
||||
### Automatic Delegation
|
||||
|
||||
Qwen Code proactively delegates tasks based on:
|
||||
|
||||
- The task description in your request
|
||||
- The description field in subagent configurations
|
||||
- Current context and available tools
|
||||
|
||||
To encourage more proactive subagent use, include phrases like "use PROACTIVELY" or "MUST BE USED" in your description field.
|
||||
|
||||
### Explicit Invocation
|
||||
|
||||
Request a specific subagent by mentioning it in your command:
|
||||
|
||||
```
|
||||
> Let the testing-expert subagent create unit tests for the payment module
|
||||
> Have the documentation-writer subagent update the API reference
|
||||
> Get the react-specialist subagent to optimize this component's performance
|
||||
```
|
||||
|
||||
## Examples
|
||||
|
||||
### Development Workflow Agents
|
||||
|
||||
@@ -75,7 +75,7 @@ This guide provides solutions to common issues and debugging tips, including top
|
||||
|
||||
## Exit Codes
|
||||
|
||||
The Gemini CLI uses specific exit codes to indicate the reason for termination. This is especially useful for scripting and automation.
|
||||
The Qwen Code uses specific exit codes to indicate the reason for termination. This is especially useful for scripting and automation.
|
||||
|
||||
| Exit Code | Error Type | Description |
|
||||
| --------- | -------------------------- | --------------------------------------------------------------------------------------------------- |
|
||||
|
||||
1
package-lock.json
generated
1
package-lock.json
generated
@@ -13789,6 +13789,7 @@
|
||||
"packages/test-utils": {
|
||||
"name": "@qwen-code/qwen-code-test-utils",
|
||||
"version": "0.0.11",
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"devDependencies": {
|
||||
"typescript": "^5.3.3"
|
||||
|
||||
@@ -14,7 +14,7 @@ import { enableCommand } from './extensions/enable.js';
|
||||
|
||||
export const extensionsCommand: CommandModule = {
|
||||
command: 'extensions <command>',
|
||||
describe: 'Manage Gemini CLI extensions.',
|
||||
describe: 'Manage Qwen Code extensions.',
|
||||
builder: (yargs) =>
|
||||
yargs
|
||||
.command(installCommand)
|
||||
|
||||
@@ -629,6 +629,7 @@ export async function loadCliConfig(
|
||||
shouldUseNodePtyShell: settings.tools?.usePty,
|
||||
skipNextSpeakerCheck: settings.model?.skipNextSpeakerCheck,
|
||||
enablePromptCompletion: settings.general?.enablePromptCompletion ?? false,
|
||||
skipLoopDetection: settings.skipLoopDetection ?? false,
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -330,14 +330,14 @@ describe('installExtension', () => {
|
||||
);
|
||||
});
|
||||
|
||||
it('should throw an error and cleanup if gemini-extension.json is missing', async () => {
|
||||
it('should throw an error and cleanup if qwen-extension.json is missing', async () => {
|
||||
const sourceExtDir = path.join(tempHomeDir, 'bad-extension');
|
||||
fs.mkdirSync(sourceExtDir, { recursive: true });
|
||||
|
||||
await expect(
|
||||
installExtension({ source: sourceExtDir, type: 'local' }),
|
||||
).rejects.toThrow(
|
||||
`Invalid extension at ${sourceExtDir}. Please make sure it has a valid gemini-extension.json file.`,
|
||||
`Invalid extension at ${sourceExtDir}. Please make sure it has a valid qwen-extension.json file.`,
|
||||
);
|
||||
|
||||
const targetExtDir = path.join(userExtensionsDir, 'bad-extension');
|
||||
@@ -345,8 +345,8 @@ describe('installExtension', () => {
|
||||
});
|
||||
|
||||
it('should install an extension from a git URL', async () => {
|
||||
const gitUrl = 'https://github.com/google/gemini-extensions.git';
|
||||
const extensionName = 'gemini-extensions';
|
||||
const gitUrl = 'https://github.com/google/qwen-extensions.git';
|
||||
const extensionName = 'qwen-extensions';
|
||||
const targetExtDir = path.join(userExtensionsDir, extensionName);
|
||||
const metadataPath = path.join(targetExtDir, INSTALL_METADATA_FILENAME);
|
||||
|
||||
@@ -555,8 +555,8 @@ describe('updateExtension', () => {
|
||||
|
||||
it('should update a git-installed extension', async () => {
|
||||
// 1. "Install" an extension
|
||||
const gitUrl = 'https://github.com/google/gemini-extensions.git';
|
||||
const extensionName = 'gemini-extensions';
|
||||
const gitUrl = 'https://github.com/google/qwen-extensions.git';
|
||||
const extensionName = 'qwen-extensions';
|
||||
const targetExtDir = path.join(userExtensionsDir, extensionName);
|
||||
const metadataPath = path.join(targetExtDir, INSTALL_METADATA_FILENAME);
|
||||
|
||||
|
||||
@@ -71,9 +71,7 @@ export class ExtensionStorage {
|
||||
}
|
||||
|
||||
static async createTmpDir(): Promise<string> {
|
||||
return await fs.promises.mkdtemp(
|
||||
path.join(os.tmpdir(), 'gemini-extension'),
|
||||
);
|
||||
return await fs.promises.mkdtemp(path.join(os.tmpdir(), 'qwen-extension'));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -355,11 +353,11 @@ export async function installExtension(
|
||||
const newExtension = loadExtension(localSourcePath);
|
||||
if (!newExtension) {
|
||||
throw new Error(
|
||||
`Invalid extension at ${installMetadata.source}. Please make sure it has a valid gemini-extension.json file.`,
|
||||
`Invalid extension at ${installMetadata.source}. Please make sure it has a valid qwen-extension.json file.`,
|
||||
);
|
||||
}
|
||||
|
||||
// ~/.gemini/extensions/{ExtensionConfig.name}.
|
||||
// ~/.qwen/extensions/{ExtensionConfig.name}.
|
||||
newExtensionName = newExtension.config.name;
|
||||
const extensionStorage = new ExtensionStorage(newExtensionName);
|
||||
const destinationPath = extensionStorage.getExtensionDir();
|
||||
@@ -455,7 +453,7 @@ export async function updateExtension(
|
||||
}
|
||||
if (!extension.installMetadata) {
|
||||
throw new Error(
|
||||
`Extension cannot be updated because it is missing the .gemini-extension.install.json file. To update manually, uninstall and then reinstall the updated version.`,
|
||||
`Extension cannot be updated because it is missing the .qwen-extension.install.json file. To update manually, uninstall and then reinstall the updated version.`,
|
||||
);
|
||||
}
|
||||
const originalVersion = extension.config.version;
|
||||
|
||||
@@ -863,6 +863,15 @@ export const SETTINGS_SCHEMA = {
|
||||
description: 'Skip the next speaker check.',
|
||||
showInDialog: true,
|
||||
},
|
||||
skipLoopDetection: {
|
||||
type: 'boolean',
|
||||
label: 'Skip Loop Detection',
|
||||
category: 'General',
|
||||
requiresRestart: false,
|
||||
default: false,
|
||||
description: 'Disable all loop detection checks (streaming and LLM).',
|
||||
showInDialog: true,
|
||||
},
|
||||
enableWelcomeBack: {
|
||||
type: 'boolean',
|
||||
label: 'Enable Welcome Back',
|
||||
|
||||
@@ -525,7 +525,7 @@ describe('FileCommandLoader', () => {
|
||||
).getProjectCommandsDir();
|
||||
const extensionDir = path.join(
|
||||
process.cwd(),
|
||||
'.gemini/extensions/test-ext',
|
||||
'.qwen/extensions/test-ext',
|
||||
);
|
||||
|
||||
mock({
|
||||
@@ -536,7 +536,7 @@ describe('FileCommandLoader', () => {
|
||||
'project.toml': 'prompt = "Project command"',
|
||||
},
|
||||
[extensionDir]: {
|
||||
'gemini-extension.json': JSON.stringify({
|
||||
'qwen-extension.json': JSON.stringify({
|
||||
name: 'test-ext',
|
||||
version: '1.0.0',
|
||||
}),
|
||||
@@ -576,12 +576,12 @@ describe('FileCommandLoader', () => {
|
||||
).getProjectCommandsDir();
|
||||
const extensionDir = path.join(
|
||||
process.cwd(),
|
||||
'.gemini/extensions/test-ext',
|
||||
'.qwen/extensions/test-ext',
|
||||
);
|
||||
|
||||
mock({
|
||||
[extensionDir]: {
|
||||
'gemini-extension.json': JSON.stringify({
|
||||
'qwen-extension.json': JSON.stringify({
|
||||
name: 'test-ext',
|
||||
version: '1.0.0',
|
||||
}),
|
||||
@@ -670,16 +670,16 @@ describe('FileCommandLoader', () => {
|
||||
it('only loads commands from active extensions', async () => {
|
||||
const extensionDir1 = path.join(
|
||||
process.cwd(),
|
||||
'.gemini/extensions/active-ext',
|
||||
'.qwen/extensions/active-ext',
|
||||
);
|
||||
const extensionDir2 = path.join(
|
||||
process.cwd(),
|
||||
'.gemini/extensions/inactive-ext',
|
||||
'.qwen/extensions/inactive-ext',
|
||||
);
|
||||
|
||||
mock({
|
||||
[extensionDir1]: {
|
||||
'gemini-extension.json': JSON.stringify({
|
||||
'qwen-extension.json': JSON.stringify({
|
||||
name: 'active-ext',
|
||||
version: '1.0.0',
|
||||
}),
|
||||
@@ -688,7 +688,7 @@ describe('FileCommandLoader', () => {
|
||||
},
|
||||
},
|
||||
[extensionDir2]: {
|
||||
'gemini-extension.json': JSON.stringify({
|
||||
'qwen-extension.json': JSON.stringify({
|
||||
name: 'inactive-ext',
|
||||
version: '1.0.0',
|
||||
}),
|
||||
@@ -727,12 +727,12 @@ describe('FileCommandLoader', () => {
|
||||
it('handles missing extension commands directory gracefully', async () => {
|
||||
const extensionDir = path.join(
|
||||
process.cwd(),
|
||||
'.gemini/extensions/no-commands',
|
||||
'.qwen/extensions/no-commands',
|
||||
);
|
||||
|
||||
mock({
|
||||
[extensionDir]: {
|
||||
'gemini-extension.json': JSON.stringify({
|
||||
'qwen-extension.json': JSON.stringify({
|
||||
name: 'no-commands',
|
||||
version: '1.0.0',
|
||||
}),
|
||||
@@ -757,11 +757,11 @@ describe('FileCommandLoader', () => {
|
||||
});
|
||||
|
||||
it('handles nested command structure in extensions', async () => {
|
||||
const extensionDir = path.join(process.cwd(), '.gemini/extensions/a');
|
||||
const extensionDir = path.join(process.cwd(), '.qwen/extensions/a');
|
||||
|
||||
mock({
|
||||
[extensionDir]: {
|
||||
'gemini-extension.json': JSON.stringify({
|
||||
'qwen-extension.json': JSON.stringify({
|
||||
name: 'a',
|
||||
version: '1.0.0',
|
||||
}),
|
||||
|
||||
@@ -142,9 +142,11 @@ function isToolExecuting(pendingHistoryItems: HistoryItemWithoutId[]) {
|
||||
|
||||
export const AppWrapper = (props: AppProps) => {
|
||||
const kittyProtocolStatus = useKittyKeyboardProtocol();
|
||||
const nodeMajorVersion = parseInt(process.versions.node.split('.')[0], 10);
|
||||
return (
|
||||
<KeypressProvider
|
||||
kittyProtocolEnabled={kittyProtocolStatus.enabled}
|
||||
pasteWorkaround={process.platform === 'win32' || nodeMajorVersion < 20}
|
||||
config={props.config}
|
||||
debugKeystrokeLogging={
|
||||
props.settings.merged.general?.debugKeystrokeLogging
|
||||
@@ -665,7 +667,13 @@ const App = ({ config, settings, startupWarnings = [], version }: AppProps) => {
|
||||
);
|
||||
|
||||
const pendingHistoryItems = useMemo(
|
||||
() => [...pendingSlashCommandHistoryItems, ...pendingGeminiHistoryItems],
|
||||
() =>
|
||||
[...pendingSlashCommandHistoryItems, ...pendingGeminiHistoryItems].map(
|
||||
(item, index) => ({
|
||||
...item,
|
||||
id: index,
|
||||
}),
|
||||
),
|
||||
[pendingSlashCommandHistoryItems, pendingGeminiHistoryItems],
|
||||
);
|
||||
|
||||
@@ -1119,16 +1127,14 @@ const App = ({ config, settings, startupWarnings = [], version }: AppProps) => {
|
||||
</Static>
|
||||
<OverflowProvider>
|
||||
<Box ref={pendingHistoryItemRef} flexDirection="column">
|
||||
{pendingHistoryItems.map((item, i) => (
|
||||
{pendingHistoryItems.map((item) => (
|
||||
<HistoryItemDisplay
|
||||
key={i}
|
||||
key={item.id}
|
||||
availableTerminalHeight={
|
||||
constrainHeight ? availableTerminalHeight : undefined
|
||||
}
|
||||
terminalWidth={mainAreaWidth}
|
||||
// TODO(taehykim): It seems like references to ids aren't necessary in
|
||||
// HistoryItemDisplay. Refactor later. Use a fake id for now.
|
||||
item={{ ...item, id: 0 }}
|
||||
item={item}
|
||||
isPending={true}
|
||||
config={config}
|
||||
isFocused={!isEditorDialogOpen}
|
||||
|
||||
@@ -53,7 +53,7 @@ describe('initCommand', () => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it(`should inform the user if ${DEFAULT_CONTEXT_FILENAME} already exists and is non-empty`, async () => {
|
||||
it(`should ask for confirmation if ${DEFAULT_CONTEXT_FILENAME} already exists and is non-empty`, async () => {
|
||||
// Arrange: Simulate that the file exists
|
||||
vi.mocked(fs.existsSync).mockReturnValue(true);
|
||||
vi.spyOn(fs, 'readFileSync').mockReturnValue('# Existing content');
|
||||
@@ -61,13 +61,15 @@ describe('initCommand', () => {
|
||||
// Act: Run the command's action
|
||||
const result = await initCommand.action!(mockContext, '');
|
||||
|
||||
// Assert: Check for the correct informational message
|
||||
expect(result).toEqual({
|
||||
type: 'message',
|
||||
messageType: 'info',
|
||||
content: `A ${DEFAULT_CONTEXT_FILENAME} file already exists in this directory. No changes were made.`,
|
||||
});
|
||||
// Assert: Ensure no file was written
|
||||
// Assert: Check for the correct confirmation request
|
||||
expect(result).toEqual(
|
||||
expect.objectContaining({
|
||||
type: 'confirm_action',
|
||||
prompt: expect.anything(), // React element, not a string
|
||||
originalInvocation: expect.anything(),
|
||||
}),
|
||||
);
|
||||
// Assert: Ensure no file was written yet
|
||||
expect(fs.writeFileSync).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
@@ -91,9 +93,13 @@ describe('initCommand', () => {
|
||||
);
|
||||
|
||||
// Assert: Check that the correct prompt is submitted
|
||||
expect(result.type).toBe('submit_prompt');
|
||||
expect(result.content).toContain(
|
||||
'You are Qwen Code, an interactive CLI agent',
|
||||
expect(result).toEqual(
|
||||
expect.objectContaining({
|
||||
type: 'submit_prompt',
|
||||
content: expect.stringContaining(
|
||||
'You are Qwen Code, an interactive CLI agent',
|
||||
),
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
@@ -104,7 +110,43 @@ describe('initCommand', () => {
|
||||
const result = await initCommand.action!(mockContext, '');
|
||||
|
||||
expect(fs.writeFileSync).toHaveBeenCalledWith(geminiMdPath, '', 'utf8');
|
||||
expect(result.type).toBe('submit_prompt');
|
||||
expect(result).toEqual(
|
||||
expect.objectContaining({
|
||||
type: 'submit_prompt',
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it(`should regenerate ${DEFAULT_CONTEXT_FILENAME} when overwrite is confirmed`, async () => {
|
||||
// Arrange: Simulate that the file exists and overwrite is confirmed
|
||||
vi.mocked(fs.existsSync).mockReturnValue(true);
|
||||
vi.spyOn(fs, 'readFileSync').mockReturnValue('# Existing content');
|
||||
mockContext.overwriteConfirmed = true;
|
||||
|
||||
// Act: Run the command's action
|
||||
const result = await initCommand.action!(mockContext, '');
|
||||
|
||||
// Assert: Check that writeFileSync was called correctly
|
||||
expect(fs.writeFileSync).toHaveBeenCalledWith(geminiMdPath, '', 'utf8');
|
||||
|
||||
// Assert: Check that an informational message was added to the UI
|
||||
expect(mockContext.ui.addItem).toHaveBeenCalledWith(
|
||||
{
|
||||
type: 'info',
|
||||
text: `Empty ${DEFAULT_CONTEXT_FILENAME} created. Now analyzing the project to populate it.`,
|
||||
},
|
||||
expect.any(Number),
|
||||
);
|
||||
|
||||
// Assert: Check that the correct prompt is submitted
|
||||
expect(result).toEqual(
|
||||
expect.objectContaining({
|
||||
type: 'submit_prompt',
|
||||
content: expect.stringContaining(
|
||||
'You are Qwen Code, an interactive CLI agent',
|
||||
),
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('should return an error if config is not available', async () => {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 Google LLC
|
||||
* Copyright 2025 Qwen
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
@@ -13,6 +13,8 @@ import type {
|
||||
} from './types.js';
|
||||
import { getCurrentGeminiMdFilename } from '@qwen-code/qwen-code-core';
|
||||
import { CommandKind } from './types.js';
|
||||
import { Text } from 'ink';
|
||||
import React from 'react';
|
||||
|
||||
export const initCommand: SlashCommand = {
|
||||
name: 'init',
|
||||
@@ -35,15 +37,27 @@ export const initCommand: SlashCommand = {
|
||||
|
||||
try {
|
||||
if (fs.existsSync(contextFilePath)) {
|
||||
// If file exists but is empty (or whitespace), continue to initialize; otherwise, bail out
|
||||
// If file exists but is empty (or whitespace), continue to initialize
|
||||
try {
|
||||
const existing = fs.readFileSync(contextFilePath, 'utf8');
|
||||
if (existing && existing.trim().length > 0) {
|
||||
return {
|
||||
type: 'message',
|
||||
messageType: 'info',
|
||||
content: `A ${contextFileName} file already exists in this directory. No changes were made.`,
|
||||
};
|
||||
// File exists and has content - ask for confirmation to overwrite
|
||||
if (!context.overwriteConfirmed) {
|
||||
return {
|
||||
type: 'confirm_action',
|
||||
// TODO: Move to .tsx file to use JSX syntax instead of React.createElement
|
||||
// For now, using React.createElement to maintain .ts compatibility for PR review
|
||||
prompt: React.createElement(
|
||||
Text,
|
||||
null,
|
||||
`A ${contextFileName} file already exists in this directory. Do you want to regenerate it?`,
|
||||
),
|
||||
originalInvocation: {
|
||||
raw: context.invocation?.raw || '/init',
|
||||
},
|
||||
};
|
||||
}
|
||||
// User confirmed overwrite, continue with regeneration
|
||||
}
|
||||
} catch {
|
||||
// If we fail to read, conservatively proceed to (re)create the file
|
||||
|
||||
@@ -30,7 +30,7 @@ describe('FolderTrustDialog', () => {
|
||||
|
||||
expect(lastFrame()).toContain('Do you trust this folder?');
|
||||
expect(lastFrame()).toContain(
|
||||
'Trusting a folder allows Gemini to execute commands it suggests.',
|
||||
'Trusting a folder allows Qwen Code to execute commands it suggests.',
|
||||
);
|
||||
});
|
||||
|
||||
@@ -66,7 +66,7 @@ describe('FolderTrustDialog', () => {
|
||||
);
|
||||
|
||||
expect(lastFrame()).toContain(
|
||||
'To see changes, Gemini CLI must be restarted',
|
||||
'To see changes, Qwen Code must be restarted',
|
||||
);
|
||||
});
|
||||
|
||||
|
||||
@@ -73,7 +73,7 @@ export const FolderTrustDialog: React.FC<FolderTrustDialogProps> = ({
|
||||
<Box flexDirection="column" marginBottom={1}>
|
||||
<Text bold>Do you trust this folder?</Text>
|
||||
<Text>
|
||||
Trusting a folder allows Gemini to execute commands it suggests.
|
||||
Trusting a folder allows Qwen Code to execute commands it suggests.
|
||||
This is a security feature to prevent accidental execution in
|
||||
untrusted directories.
|
||||
</Text>
|
||||
@@ -88,7 +88,7 @@ export const FolderTrustDialog: React.FC<FolderTrustDialogProps> = ({
|
||||
{isRestarting && (
|
||||
<Box marginLeft={1} marginTop={1}>
|
||||
<Text color={Colors.AccentYellow}>
|
||||
To see changes, Gemini CLI must be restarted. Press r to exit and
|
||||
To see changes, Qwen Code must be restarted. Press r to exit and
|
||||
apply changes now.
|
||||
</Text>
|
||||
</Box>
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
*/
|
||||
|
||||
import type React from 'react';
|
||||
import { memo } from 'react';
|
||||
import type { HistoryItem } from '../types.js';
|
||||
import { UserMessage } from './messages/UserMessage.js';
|
||||
import { UserShellMessage } from './messages/UserShellMessage.js';
|
||||
@@ -35,7 +36,7 @@ interface HistoryItemDisplayProps {
|
||||
commands?: readonly SlashCommand[];
|
||||
}
|
||||
|
||||
export const HistoryItemDisplay: React.FC<HistoryItemDisplayProps> = ({
|
||||
const HistoryItemDisplayComponent: React.FC<HistoryItemDisplayProps> = ({
|
||||
item,
|
||||
availableTerminalHeight,
|
||||
terminalWidth,
|
||||
@@ -101,3 +102,7 @@ export const HistoryItemDisplay: React.FC<HistoryItemDisplayProps> = ({
|
||||
{item.type === 'summary' && <SummaryMessage summary={item.summary} />}
|
||||
</Box>
|
||||
);
|
||||
|
||||
HistoryItemDisplayComponent.displayName = 'HistoryItemDisplay';
|
||||
|
||||
export const HistoryItemDisplay = memo(HistoryItemDisplayComponent);
|
||||
|
||||
@@ -678,7 +678,7 @@ describe('SettingsDialog', () => {
|
||||
|
||||
// Should not show restart prompt initially
|
||||
expect(lastFrame()).not.toContain(
|
||||
'To see changes, Gemini CLI must be restarted',
|
||||
'To see changes, Qwen Code must be restarted',
|
||||
);
|
||||
|
||||
unmount();
|
||||
|
||||
@@ -780,7 +780,7 @@ export function SettingsDialog({
|
||||
</Text>
|
||||
{showRestartPrompt && (
|
||||
<Text color={Colors.AccentYellow}>
|
||||
To see changes, Gemini CLI must be restarted. Press r to exit and
|
||||
To see changes, Qwen Code must be restarted. Press r to exit and
|
||||
apply changes now.
|
||||
</Text>
|
||||
)}
|
||||
|
||||
@@ -47,7 +47,7 @@ export function WorkspaceMigrationDialog(props: {
|
||||
<>
|
||||
<Text>
|
||||
The following extensions failed to migrate. Please try installing
|
||||
them manually. To see other changes, Gemini CLI must be restarted.
|
||||
them manually. To see other changes, Qwen Code must be restarted.
|
||||
Press {"'q'"} to quit.
|
||||
</Text>
|
||||
<Box flexDirection="column" marginTop={1} marginLeft={2}>
|
||||
@@ -58,7 +58,7 @@ export function WorkspaceMigrationDialog(props: {
|
||||
</>
|
||||
) : (
|
||||
<Text>
|
||||
Migration complete. To see changes, Gemini CLI must be restarted.
|
||||
Migration complete. To see changes, Qwen Code must be restarted.
|
||||
Press {"'q'"} to quit.
|
||||
</Text>
|
||||
)}
|
||||
|
||||
@@ -27,6 +27,7 @@ export interface ToolConfirmationMessageProps {
|
||||
isFocused?: boolean;
|
||||
availableTerminalHeight?: number;
|
||||
terminalWidth: number;
|
||||
compactMode?: boolean;
|
||||
}
|
||||
|
||||
export const ToolConfirmationMessage: React.FC<
|
||||
@@ -37,6 +38,7 @@ export const ToolConfirmationMessage: React.FC<
|
||||
isFocused = true,
|
||||
availableTerminalHeight,
|
||||
terminalWidth,
|
||||
compactMode = false,
|
||||
}) => {
|
||||
const { onConfirm } = confirmationDetails;
|
||||
const childWidth = terminalWidth - 2; // 2 for padding
|
||||
@@ -70,6 +72,40 @@ export const ToolConfirmationMessage: React.FC<
|
||||
|
||||
const handleSelect = (item: ToolConfirmationOutcome) => handleConfirm(item);
|
||||
|
||||
// Compact mode: return simple 3-option display
|
||||
if (compactMode) {
|
||||
const compactOptions: Array<RadioSelectItem<ToolConfirmationOutcome>> = [
|
||||
{
|
||||
label: 'Yes, allow once',
|
||||
value: ToolConfirmationOutcome.ProceedOnce,
|
||||
},
|
||||
{
|
||||
label: 'Allow always',
|
||||
value: ToolConfirmationOutcome.ProceedAlways,
|
||||
},
|
||||
{
|
||||
label: 'No',
|
||||
value: ToolConfirmationOutcome.Cancel,
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<Box flexDirection="column">
|
||||
<Box>
|
||||
<Text wrap="truncate">Do you want to proceed?</Text>
|
||||
</Box>
|
||||
<Box>
|
||||
<RadioButtonSelect
|
||||
items={compactOptions}
|
||||
onSelect={handleSelect}
|
||||
isFocused={isFocused}
|
||||
/>
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
// Original logic continues unchanged below
|
||||
let bodyContent: React.ReactNode | null = null; // Removed contextDisplay here
|
||||
let question: string;
|
||||
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
*/
|
||||
|
||||
import { useReducer, useCallback, useMemo } from 'react';
|
||||
import { Box, Text, useInput } from 'ink';
|
||||
import { Box, Text } from 'ink';
|
||||
import { wizardReducer, initialWizardState } from '../reducers.js';
|
||||
import { LocationSelector } from './LocationSelector.js';
|
||||
import { GenerationMethodSelector } from './GenerationMethodSelector.js';
|
||||
@@ -20,6 +20,7 @@ import type { Config } from '@qwen-code/qwen-code-core';
|
||||
import { Colors } from '../../../colors.js';
|
||||
import { theme } from '../../../semantic-colors.js';
|
||||
import { TextEntryStep } from './TextEntryStep.js';
|
||||
import { useKeypress } from '../../../hooks/useKeypress.js';
|
||||
|
||||
interface AgentCreationWizardProps {
|
||||
onClose: () => void;
|
||||
@@ -49,8 +50,12 @@ export function AgentCreationWizard({
|
||||
}, [onClose]);
|
||||
|
||||
// Centralized ESC key handling for the entire wizard
|
||||
useInput((input, key) => {
|
||||
if (key.escape) {
|
||||
useKeypress(
|
||||
(key) => {
|
||||
if (key.name !== 'escape') {
|
||||
return;
|
||||
}
|
||||
|
||||
// LLM DescriptionInput handles its own ESC logic when generating
|
||||
const kind = getStepKind(state.generationMethod, state.currentStep);
|
||||
if (kind === 'LLM_DESC' && state.isGenerating) {
|
||||
@@ -64,8 +69,9 @@ export function AgentCreationWizard({
|
||||
// On other steps, ESC goes back to previous step
|
||||
handlePrevious();
|
||||
}
|
||||
}
|
||||
});
|
||||
},
|
||||
{ isActive: true },
|
||||
);
|
||||
|
||||
const stepProps: WizardStepProps = useMemo(
|
||||
() => ({
|
||||
|
||||
@@ -227,7 +227,7 @@ export const AgentSelectionStep = ({
|
||||
const textColor = isSelected ? theme.text.accent : theme.text.primary;
|
||||
|
||||
return (
|
||||
<Box key={agent.name} alignItems="center">
|
||||
<Box key={`${agent.name}-${agent.level}`} alignItems="center">
|
||||
<Box minWidth={2} flexShrink={0}>
|
||||
<Text color={isSelected ? theme.text.accent : theme.text.primary}>
|
||||
{isSelected ? '●' : ' '}
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
*/
|
||||
|
||||
import { useState, useCallback, useMemo, useEffect } from 'react';
|
||||
import { Box, Text, useInput } from 'ink';
|
||||
import { Box, Text } from 'ink';
|
||||
import { AgentSelectionStep } from './AgentSelectionStep.js';
|
||||
import { ActionSelectionStep } from './ActionSelectionStep.js';
|
||||
import { AgentViewerStep } from './AgentViewerStep.js';
|
||||
@@ -17,7 +17,8 @@ import { MANAGEMENT_STEPS } from '../types.js';
|
||||
import { Colors } from '../../../colors.js';
|
||||
import { theme } from '../../../semantic-colors.js';
|
||||
import { getColorForDisplay, shouldShowColor } from '../utils.js';
|
||||
import type { Config, SubagentConfig } from '@qwen-code/qwen-code-core';
|
||||
import type { SubagentConfig, Config } from '@qwen-code/qwen-code-core';
|
||||
import { useKeypress } from '../../../hooks/useKeypress.js';
|
||||
|
||||
interface AgentsManagerDialogProps {
|
||||
onClose: () => void;
|
||||
@@ -52,18 +53,7 @@ export function AgentsManagerDialog({
|
||||
const manager = config.getSubagentManager();
|
||||
|
||||
// Load agents from all levels separately to show all agents including conflicts
|
||||
const [projectAgents, userAgents, builtinAgents] = await Promise.all([
|
||||
manager.listSubagents({ level: 'project' }),
|
||||
manager.listSubagents({ level: 'user' }),
|
||||
manager.listSubagents({ level: 'builtin' }),
|
||||
]);
|
||||
|
||||
// Combine all agents (project, user, and builtin level)
|
||||
const allAgents = [
|
||||
...(projectAgents || []),
|
||||
...(userAgents || []),
|
||||
...(builtinAgents || []),
|
||||
];
|
||||
const allAgents = await manager.listSubagents();
|
||||
|
||||
setAvailableAgents(allAgents);
|
||||
}, [config]);
|
||||
@@ -122,8 +112,12 @@ export function AgentsManagerDialog({
|
||||
);
|
||||
|
||||
// Centralized ESC key handling for the entire dialog
|
||||
useInput((input, key) => {
|
||||
if (key.escape) {
|
||||
useKeypress(
|
||||
(key) => {
|
||||
if (key.name !== 'escape') {
|
||||
return;
|
||||
}
|
||||
|
||||
const currentStep = getCurrentStep();
|
||||
if (currentStep === MANAGEMENT_STEPS.AGENT_SELECTION) {
|
||||
// On first step, ESC cancels the entire dialog
|
||||
@@ -132,8 +126,9 @@ export function AgentsManagerDialog({
|
||||
// On other steps, ESC goes back to previous step in navigation stack
|
||||
handleNavigateBack();
|
||||
}
|
||||
}
|
||||
});
|
||||
},
|
||||
{ isActive: true },
|
||||
);
|
||||
|
||||
// Props for child components - now using direct state and callbacks
|
||||
const commonProps = useMemo(
|
||||
|
||||
@@ -18,12 +18,12 @@ import { COLOR_OPTIONS } from '../constants.js';
|
||||
import { fmtDuration } from '../utils.js';
|
||||
import { ToolConfirmationMessage } from '../../messages/ToolConfirmationMessage.js';
|
||||
|
||||
export type DisplayMode = 'default' | 'verbose';
|
||||
export type DisplayMode = 'compact' | 'default' | 'verbose';
|
||||
|
||||
export interface AgentExecutionDisplayProps {
|
||||
data: TaskResultDisplay;
|
||||
availableHeight?: number;
|
||||
childWidth?: number;
|
||||
childWidth: number;
|
||||
config: Config;
|
||||
}
|
||||
|
||||
@@ -80,7 +80,7 @@ export const AgentExecutionDisplay: React.FC<AgentExecutionDisplayProps> = ({
|
||||
childWidth,
|
||||
config,
|
||||
}) => {
|
||||
const [displayMode, setDisplayMode] = React.useState<DisplayMode>('default');
|
||||
const [displayMode, setDisplayMode] = React.useState<DisplayMode>('compact');
|
||||
|
||||
const agentColor = useMemo(() => {
|
||||
const colorOption = COLOR_OPTIONS.find(
|
||||
@@ -93,8 +93,6 @@ export const AgentExecutionDisplay: React.FC<AgentExecutionDisplayProps> = ({
|
||||
// This component only listens to keyboard shortcut events when the subagent is running
|
||||
if (data.status !== 'running') return '';
|
||||
|
||||
if (displayMode === 'verbose') return 'Press ctrl+r to show less.';
|
||||
|
||||
if (displayMode === 'default') {
|
||||
const hasMoreLines =
|
||||
data.taskPrompt.split('\n').length > MAX_TASK_PROMPT_LINES;
|
||||
@@ -102,17 +100,28 @@ export const AgentExecutionDisplay: React.FC<AgentExecutionDisplayProps> = ({
|
||||
data.toolCalls && data.toolCalls.length > MAX_TOOL_CALLS;
|
||||
|
||||
if (hasMoreToolCalls || hasMoreLines) {
|
||||
return 'Press ctrl+r to show more.';
|
||||
return 'Press ctrl+r to show less, ctrl+e to show more.';
|
||||
}
|
||||
return '';
|
||||
return 'Press ctrl+r to show less.';
|
||||
}
|
||||
return '';
|
||||
}, [displayMode, data.toolCalls, data.taskPrompt, data.status]);
|
||||
|
||||
// Handle ctrl+r keypresses to control display mode
|
||||
if (displayMode === 'verbose') {
|
||||
return 'Press ctrl+e to show less.';
|
||||
}
|
||||
|
||||
return '';
|
||||
}, [displayMode, data]);
|
||||
|
||||
// Handle keyboard shortcuts to control display mode
|
||||
useKeypress(
|
||||
(key) => {
|
||||
if (key.ctrl && key.name === 'r') {
|
||||
// ctrl+r toggles between compact and default
|
||||
setDisplayMode((current) =>
|
||||
current === 'compact' ? 'default' : 'compact',
|
||||
);
|
||||
} else if (key.ctrl && key.name === 'e') {
|
||||
// ctrl+e toggles between default and verbose
|
||||
setDisplayMode((current) =>
|
||||
current === 'default' ? 'verbose' : 'default',
|
||||
);
|
||||
@@ -121,6 +130,82 @@ export const AgentExecutionDisplay: React.FC<AgentExecutionDisplayProps> = ({
|
||||
{ isActive: true },
|
||||
);
|
||||
|
||||
if (displayMode === 'compact') {
|
||||
return (
|
||||
<Box flexDirection="column">
|
||||
{/* Header: Agent name and status */}
|
||||
{!data.pendingConfirmation && (
|
||||
<Box flexDirection="row">
|
||||
<Text bold color={agentColor}>
|
||||
{data.subagentName}
|
||||
</Text>
|
||||
<StatusDot status={data.status} />
|
||||
<StatusIndicator status={data.status} />
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{/* Running state: Show current tool call and progress */}
|
||||
{data.status === 'running' && (
|
||||
<>
|
||||
{/* Current tool call */}
|
||||
{data.toolCalls && data.toolCalls.length > 0 && (
|
||||
<Box flexDirection="column">
|
||||
<ToolCallItem
|
||||
toolCall={data.toolCalls[data.toolCalls.length - 1]}
|
||||
compact={true}
|
||||
/>
|
||||
{/* Show count of additional tool calls if there are more than 1 */}
|
||||
{data.toolCalls.length > 1 && !data.pendingConfirmation && (
|
||||
<Box flexDirection="row" paddingLeft={4}>
|
||||
<Text color={Colors.Gray}>
|
||||
+{data.toolCalls.length - 1} more tool calls (ctrl+r to
|
||||
expand)
|
||||
</Text>
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{/* Inline approval prompt when awaiting confirmation */}
|
||||
{data.pendingConfirmation && (
|
||||
<Box flexDirection="column" marginTop={1} paddingLeft={1}>
|
||||
<ToolConfirmationMessage
|
||||
confirmationDetails={data.pendingConfirmation}
|
||||
isFocused={true}
|
||||
availableTerminalHeight={availableHeight}
|
||||
terminalWidth={childWidth}
|
||||
compactMode={true}
|
||||
config={config}
|
||||
/>
|
||||
</Box>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Completed state: Show summary line */}
|
||||
{data.status === 'completed' && data.executionSummary && (
|
||||
<Box flexDirection="row" marginTop={1}>
|
||||
<Text color={theme.text.secondary}>
|
||||
Execution Summary: {data.executionSummary.totalToolCalls} tool
|
||||
uses · {data.executionSummary.totalTokens.toLocaleString()} tokens
|
||||
· {fmtDuration(data.executionSummary.totalDurationMs)}
|
||||
</Text>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{/* Failed/Cancelled state: Show error reason */}
|
||||
{data.status === 'failed' && (
|
||||
<Box flexDirection="row" marginTop={1}>
|
||||
<Text color={theme.status.error}>
|
||||
Failed: {data.terminateReason}
|
||||
</Text>
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
// Default and verbose modes use normal layout
|
||||
return (
|
||||
<Box flexDirection="column" paddingX={1} gap={1}>
|
||||
{/* Header with subagent name and status */}
|
||||
@@ -158,7 +243,8 @@ export const AgentExecutionDisplay: React.FC<AgentExecutionDisplayProps> = ({
|
||||
config={config}
|
||||
isFocused={true}
|
||||
availableTerminalHeight={availableHeight}
|
||||
terminalWidth={childWidth ?? 80}
|
||||
terminalWidth={childWidth}
|
||||
compactMode={true}
|
||||
/>
|
||||
</Box>
|
||||
)}
|
||||
@@ -280,7 +366,8 @@ const ToolCallItem: React.FC<{
|
||||
resultDisplay?: string;
|
||||
description?: string;
|
||||
};
|
||||
}> = ({ toolCall }) => {
|
||||
compact?: boolean;
|
||||
}> = ({ toolCall, compact = false }) => {
|
||||
const STATUS_INDICATOR_WIDTH = 3;
|
||||
|
||||
// Map subagent status to ToolCallStatus-like display
|
||||
@@ -335,8 +422,8 @@ const ToolCallItem: React.FC<{
|
||||
</Text>
|
||||
</Box>
|
||||
|
||||
{/* Second line: truncated returnDisplay output */}
|
||||
{truncatedOutput && (
|
||||
{/* Second line: truncated returnDisplay output - hidden in compact mode */}
|
||||
{!compact && truncatedOutput && (
|
||||
<Box flexDirection="row" paddingLeft={STATUS_INDICATOR_WIDTH}>
|
||||
<Text color={Colors.Gray}>{truncatedOutput}</Text>
|
||||
</Box>
|
||||
|
||||
@@ -58,11 +58,16 @@ describe('KeypressContext - Kitty Protocol', () => {
|
||||
const wrapper = ({
|
||||
children,
|
||||
kittyProtocolEnabled = true,
|
||||
pasteWorkaround = false,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
kittyProtocolEnabled?: boolean;
|
||||
pasteWorkaround?: boolean;
|
||||
}) => (
|
||||
<KeypressProvider kittyProtocolEnabled={kittyProtocolEnabled}>
|
||||
<KeypressProvider
|
||||
kittyProtocolEnabled={kittyProtocolEnabled}
|
||||
pasteWorkaround={pasteWorkaround}
|
||||
>
|
||||
{children}
|
||||
</KeypressProvider>
|
||||
);
|
||||
@@ -379,6 +384,722 @@ describe('KeypressContext - Kitty Protocol', () => {
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
describe('paste mode markers', () => {
|
||||
// These tests use pasteWorkaround=true to force passthrough mode for raw keypress testing
|
||||
|
||||
it('should handle complete paste sequence with markers', async () => {
|
||||
const keyHandler = vi.fn();
|
||||
const pastedText = 'pasted content';
|
||||
|
||||
const { result } = renderHook(() => useKeypressContext(), {
|
||||
wrapper: ({ children }) =>
|
||||
wrapper({ children, pasteWorkaround: true }),
|
||||
});
|
||||
|
||||
act(() => {
|
||||
result.current.subscribe(keyHandler);
|
||||
});
|
||||
|
||||
// Send complete paste sequence: prefix + content + suffix
|
||||
act(() => {
|
||||
stdin.emit('data', Buffer.from(`\x1b[200~${pastedText}\x1b[201~`));
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(keyHandler).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
// Should emit a single paste event with the content
|
||||
expect(keyHandler).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
paste: true,
|
||||
sequence: pastedText,
|
||||
name: '',
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('should handle empty paste sequence', async () => {
|
||||
const keyHandler = vi.fn();
|
||||
|
||||
const { result } = renderHook(() => useKeypressContext(), {
|
||||
wrapper: ({ children }) =>
|
||||
wrapper({ children, pasteWorkaround: true }),
|
||||
});
|
||||
|
||||
act(() => {
|
||||
result.current.subscribe(keyHandler);
|
||||
});
|
||||
|
||||
// Send empty paste sequence: prefix immediately followed by suffix
|
||||
act(() => {
|
||||
stdin.emit('data', Buffer.from('\x1b[200~\x1b[201~'));
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(keyHandler).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
// Should emit a paste event with empty content
|
||||
expect(keyHandler).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
paste: true,
|
||||
sequence: '',
|
||||
name: '',
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('should handle data before paste markers', async () => {
|
||||
const keyHandler = vi.fn();
|
||||
|
||||
const { result } = renderHook(() => useKeypressContext(), {
|
||||
wrapper: ({ children }) =>
|
||||
wrapper({ children, pasteWorkaround: true }),
|
||||
});
|
||||
|
||||
act(() => {
|
||||
result.current.subscribe(keyHandler);
|
||||
});
|
||||
|
||||
// Send data before paste sequence
|
||||
act(() => {
|
||||
stdin.emit('data', Buffer.from('before\x1b[200~pasted\x1b[201~'));
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(keyHandler).toHaveBeenCalledTimes(7); // 6 chars + 1 paste event
|
||||
});
|
||||
|
||||
// Should process 'before' as individual characters
|
||||
expect(keyHandler).toHaveBeenNthCalledWith(
|
||||
1,
|
||||
expect.objectContaining({ name: 'b' }),
|
||||
);
|
||||
expect(keyHandler).toHaveBeenNthCalledWith(
|
||||
2,
|
||||
expect.objectContaining({ name: 'e' }),
|
||||
);
|
||||
expect(keyHandler).toHaveBeenNthCalledWith(
|
||||
3,
|
||||
expect.objectContaining({ name: 'f' }),
|
||||
);
|
||||
expect(keyHandler).toHaveBeenNthCalledWith(
|
||||
4,
|
||||
expect.objectContaining({ name: 'o' }),
|
||||
);
|
||||
expect(keyHandler).toHaveBeenNthCalledWith(
|
||||
5,
|
||||
expect.objectContaining({ name: 'r' }),
|
||||
);
|
||||
expect(keyHandler).toHaveBeenNthCalledWith(
|
||||
6,
|
||||
expect.objectContaining({ name: 'e' }),
|
||||
);
|
||||
|
||||
// Then emit paste event
|
||||
expect(keyHandler).toHaveBeenNthCalledWith(
|
||||
7,
|
||||
expect.objectContaining({
|
||||
paste: true,
|
||||
sequence: 'pasted',
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('should handle data after paste markers', async () => {
|
||||
const keyHandler = vi.fn();
|
||||
|
||||
const { result } = renderHook(() => useKeypressContext(), {
|
||||
wrapper: ({ children }) =>
|
||||
wrapper({ children, pasteWorkaround: true }),
|
||||
});
|
||||
|
||||
act(() => {
|
||||
result.current.subscribe(keyHandler);
|
||||
});
|
||||
|
||||
// Send paste sequence followed by data
|
||||
act(() => {
|
||||
stdin.emit('data', Buffer.from('\x1b[200~pasted\x1b[201~after'));
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(keyHandler).toHaveBeenCalledTimes(2); // 1 paste event + 1 paste event for 'after'
|
||||
});
|
||||
|
||||
// Should emit paste event first
|
||||
expect(keyHandler).toHaveBeenNthCalledWith(
|
||||
1,
|
||||
expect.objectContaining({
|
||||
paste: true,
|
||||
sequence: 'pasted',
|
||||
}),
|
||||
);
|
||||
|
||||
// Then process 'after' as a paste event (since it's > 2 chars)
|
||||
expect(keyHandler).toHaveBeenNthCalledWith(
|
||||
2,
|
||||
expect.objectContaining({
|
||||
paste: true,
|
||||
sequence: 'after',
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('should handle complex sequence with multiple paste blocks', async () => {
|
||||
const keyHandler = vi.fn();
|
||||
|
||||
const { result } = renderHook(() => useKeypressContext(), {
|
||||
wrapper: ({ children }) =>
|
||||
wrapper({ children, pasteWorkaround: true }),
|
||||
});
|
||||
|
||||
act(() => {
|
||||
result.current.subscribe(keyHandler);
|
||||
});
|
||||
|
||||
// Send complex sequence: data + paste1 + data + paste2 + data
|
||||
act(() => {
|
||||
stdin.emit(
|
||||
'data',
|
||||
Buffer.from(
|
||||
'start\x1b[200~first\x1b[201~middle\x1b[200~second\x1b[201~end',
|
||||
),
|
||||
);
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(keyHandler).toHaveBeenCalledTimes(14); // Adjusted based on actual behavior
|
||||
});
|
||||
|
||||
// Check the sequence: 'start' (5 chars) + paste1 + 'middle' (6 chars) + paste2 + 'end' (3 chars as paste)
|
||||
let callIndex = 1;
|
||||
|
||||
// 'start'
|
||||
expect(keyHandler).toHaveBeenNthCalledWith(
|
||||
callIndex++,
|
||||
expect.objectContaining({ name: 's' }),
|
||||
);
|
||||
expect(keyHandler).toHaveBeenNthCalledWith(
|
||||
callIndex++,
|
||||
expect.objectContaining({ name: 't' }),
|
||||
);
|
||||
expect(keyHandler).toHaveBeenNthCalledWith(
|
||||
callIndex++,
|
||||
expect.objectContaining({ name: 'a' }),
|
||||
);
|
||||
expect(keyHandler).toHaveBeenNthCalledWith(
|
||||
callIndex++,
|
||||
expect.objectContaining({ name: 'r' }),
|
||||
);
|
||||
expect(keyHandler).toHaveBeenNthCalledWith(
|
||||
callIndex++,
|
||||
expect.objectContaining({ name: 't' }),
|
||||
);
|
||||
|
||||
// first paste
|
||||
expect(keyHandler).toHaveBeenNthCalledWith(
|
||||
callIndex++,
|
||||
expect.objectContaining({
|
||||
paste: true,
|
||||
sequence: 'first',
|
||||
}),
|
||||
);
|
||||
|
||||
// 'middle'
|
||||
expect(keyHandler).toHaveBeenNthCalledWith(
|
||||
callIndex++,
|
||||
expect.objectContaining({ name: 'm' }),
|
||||
);
|
||||
expect(keyHandler).toHaveBeenNthCalledWith(
|
||||
callIndex++,
|
||||
expect.objectContaining({ name: 'i' }),
|
||||
);
|
||||
expect(keyHandler).toHaveBeenNthCalledWith(
|
||||
callIndex++,
|
||||
expect.objectContaining({ name: 'd' }),
|
||||
);
|
||||
expect(keyHandler).toHaveBeenNthCalledWith(
|
||||
callIndex++,
|
||||
expect.objectContaining({ name: 'd' }),
|
||||
);
|
||||
expect(keyHandler).toHaveBeenNthCalledWith(
|
||||
callIndex++,
|
||||
expect.objectContaining({ name: 'l' }),
|
||||
);
|
||||
expect(keyHandler).toHaveBeenNthCalledWith(
|
||||
callIndex++,
|
||||
expect.objectContaining({ name: 'e' }),
|
||||
);
|
||||
|
||||
// second paste
|
||||
expect(keyHandler).toHaveBeenNthCalledWith(
|
||||
callIndex++,
|
||||
expect.objectContaining({
|
||||
paste: true,
|
||||
sequence: 'second',
|
||||
}),
|
||||
);
|
||||
|
||||
// 'end' as paste event (since it's > 2 chars)
|
||||
expect(keyHandler).toHaveBeenNthCalledWith(
|
||||
callIndex++,
|
||||
expect.objectContaining({
|
||||
paste: true,
|
||||
sequence: 'end',
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('should handle fragmented paste markers across multiple data events', async () => {
|
||||
const keyHandler = vi.fn();
|
||||
|
||||
const { result } = renderHook(() => useKeypressContext(), {
|
||||
wrapper: ({ children }) =>
|
||||
wrapper({ children, pasteWorkaround: true }),
|
||||
});
|
||||
|
||||
act(() => {
|
||||
result.current.subscribe(keyHandler);
|
||||
});
|
||||
|
||||
// Send fragmented paste sequence
|
||||
act(() => {
|
||||
stdin.emit('data', Buffer.from('\x1b[200~partial'));
|
||||
stdin.emit('data', Buffer.from(' content\x1b[201~'));
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(keyHandler).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
// Should combine the fragmented content into a single paste event
|
||||
expect(keyHandler).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
paste: true,
|
||||
sequence: 'partial content',
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('should handle multiline content within paste markers', async () => {
|
||||
const keyHandler = vi.fn();
|
||||
const multilineContent = 'line1\nline2\nline3';
|
||||
|
||||
const { result } = renderHook(() => useKeypressContext(), {
|
||||
wrapper: ({ children }) =>
|
||||
wrapper({ children, pasteWorkaround: true }),
|
||||
});
|
||||
|
||||
act(() => {
|
||||
result.current.subscribe(keyHandler);
|
||||
});
|
||||
|
||||
// Send paste sequence with multiline content
|
||||
act(() => {
|
||||
stdin.emit(
|
||||
'data',
|
||||
Buffer.from(`\x1b[200~${multilineContent}\x1b[201~`),
|
||||
);
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(keyHandler).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
// Should emit a single paste event with the multiline content
|
||||
expect(keyHandler).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
paste: true,
|
||||
sequence: multilineContent,
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('should handle paste markers split across buffer boundaries', async () => {
|
||||
const keyHandler = vi.fn();
|
||||
|
||||
const { result } = renderHook(() => useKeypressContext(), {
|
||||
wrapper: ({ children }) =>
|
||||
wrapper({ children, pasteWorkaround: true }),
|
||||
});
|
||||
|
||||
act(() => {
|
||||
result.current.subscribe(keyHandler);
|
||||
});
|
||||
|
||||
// Send paste marker split across multiple data events
|
||||
act(() => {
|
||||
stdin.emit('data', Buffer.from('\x1b[20'));
|
||||
stdin.emit('data', Buffer.from('0~content\x1b[2'));
|
||||
stdin.emit('data', Buffer.from('01~'));
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
// With the current implementation, fragmented data gets processed differently
|
||||
// The first fragment '\x1b[20' gets processed as individual characters
|
||||
// The second fragment '0~content\x1b[2' gets processed as paste + individual chars
|
||||
// The third fragment '01~' gets processed as individual characters
|
||||
expect(keyHandler).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
// The current implementation processes fragmented paste markers as separate events
|
||||
// rather than reconstructing them into a single paste event
|
||||
expect(keyHandler.mock.calls.length).toBeGreaterThan(1);
|
||||
});
|
||||
});
|
||||
|
||||
it('buffers fragmented paste chunks before emitting newlines', () => {
|
||||
vi.useFakeTimers();
|
||||
const keyHandler = vi.fn();
|
||||
|
||||
const { result } = renderHook(() => useKeypressContext(), {
|
||||
wrapper: ({ children }) => wrapper({ children, pasteWorkaround: true }),
|
||||
});
|
||||
|
||||
act(() => {
|
||||
result.current.subscribe(keyHandler);
|
||||
});
|
||||
|
||||
try {
|
||||
act(() => {
|
||||
stdin.emit('data', Buffer.from('\r'));
|
||||
stdin.emit('data', Buffer.from('rest of paste'));
|
||||
});
|
||||
|
||||
act(() => {
|
||||
vi.advanceTimersByTime(8);
|
||||
});
|
||||
|
||||
// With the current implementation, fragmented data gets combined and
|
||||
// treated as a single paste event due to the buffering mechanism
|
||||
expect(keyHandler).toHaveBeenCalledTimes(1);
|
||||
|
||||
// Should be treated as a paste event with the combined content
|
||||
expect(keyHandler).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
paste: true,
|
||||
sequence: '\rrest of paste',
|
||||
}),
|
||||
);
|
||||
} finally {
|
||||
vi.useRealTimers();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('Raw keypress pipeline', () => {
|
||||
// These tests use pasteWorkaround=true to force passthrough mode for raw keypress testing
|
||||
|
||||
it('should buffer input data and wait for timeout', () => {
|
||||
vi.useFakeTimers();
|
||||
const keyHandler = vi.fn();
|
||||
|
||||
const { result } = renderHook(() => useKeypressContext(), {
|
||||
wrapper: ({ children }) => wrapper({ children, pasteWorkaround: true }),
|
||||
});
|
||||
|
||||
act(() => {
|
||||
result.current.subscribe(keyHandler);
|
||||
});
|
||||
|
||||
try {
|
||||
// Send single character
|
||||
act(() => {
|
||||
stdin.emit('data', Buffer.from('a'));
|
||||
});
|
||||
|
||||
// With the current implementation, single characters are processed immediately
|
||||
expect(keyHandler).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
name: 'a',
|
||||
sequence: 'a',
|
||||
}),
|
||||
);
|
||||
} finally {
|
||||
vi.useRealTimers();
|
||||
}
|
||||
});
|
||||
|
||||
it('should concatenate new data and reset timeout', () => {
|
||||
vi.useFakeTimers();
|
||||
const keyHandler = vi.fn();
|
||||
|
||||
const { result } = renderHook(() => useKeypressContext(), {
|
||||
wrapper: ({ children }) => wrapper({ children, pasteWorkaround: true }),
|
||||
});
|
||||
|
||||
act(() => {
|
||||
result.current.subscribe(keyHandler);
|
||||
});
|
||||
|
||||
try {
|
||||
// Send first chunk
|
||||
act(() => {
|
||||
stdin.emit('data', Buffer.from('hel'));
|
||||
});
|
||||
|
||||
// Advance timer partially
|
||||
act(() => {
|
||||
vi.advanceTimersByTime(4);
|
||||
});
|
||||
|
||||
// Send second chunk before timeout
|
||||
act(() => {
|
||||
stdin.emit('data', Buffer.from('lo'));
|
||||
});
|
||||
|
||||
// With the current implementation, data is processed as it arrives
|
||||
// First chunk 'hel' is treated as paste (multi-character)
|
||||
expect(keyHandler).toHaveBeenNthCalledWith(
|
||||
1,
|
||||
expect.objectContaining({
|
||||
paste: true,
|
||||
sequence: 'hel',
|
||||
}),
|
||||
);
|
||||
|
||||
// Second chunk 'lo' is processed as individual characters
|
||||
expect(keyHandler).toHaveBeenNthCalledWith(
|
||||
2,
|
||||
expect.objectContaining({
|
||||
name: 'l',
|
||||
sequence: 'l',
|
||||
paste: false,
|
||||
}),
|
||||
);
|
||||
|
||||
expect(keyHandler).toHaveBeenNthCalledWith(
|
||||
3,
|
||||
expect.objectContaining({
|
||||
name: 'o',
|
||||
sequence: 'o',
|
||||
paste: false,
|
||||
}),
|
||||
);
|
||||
|
||||
expect(keyHandler).toHaveBeenCalledTimes(3);
|
||||
} finally {
|
||||
vi.useRealTimers();
|
||||
}
|
||||
});
|
||||
|
||||
it('should flush immediately when buffer exceeds limit', () => {
|
||||
vi.useFakeTimers();
|
||||
const keyHandler = vi.fn();
|
||||
|
||||
const { result } = renderHook(() => useKeypressContext(), {
|
||||
wrapper: ({ children }) => wrapper({ children, pasteWorkaround: true }),
|
||||
});
|
||||
|
||||
act(() => {
|
||||
result.current.subscribe(keyHandler);
|
||||
});
|
||||
|
||||
try {
|
||||
// Create a large buffer that exceeds the 64 byte limit
|
||||
const largeData = 'x'.repeat(65);
|
||||
|
||||
act(() => {
|
||||
stdin.emit('data', Buffer.from(largeData));
|
||||
});
|
||||
|
||||
// Should flush immediately without waiting for timeout
|
||||
// Large data gets treated as paste event
|
||||
expect(keyHandler).toHaveBeenCalledTimes(1);
|
||||
expect(keyHandler).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
paste: true,
|
||||
sequence: largeData,
|
||||
}),
|
||||
);
|
||||
|
||||
// Advancing timer should not cause additional calls
|
||||
const callCountBefore = keyHandler.mock.calls.length;
|
||||
act(() => {
|
||||
vi.advanceTimersByTime(8);
|
||||
});
|
||||
|
||||
expect(keyHandler).toHaveBeenCalledTimes(callCountBefore);
|
||||
} finally {
|
||||
vi.useRealTimers();
|
||||
}
|
||||
});
|
||||
|
||||
it('should clear timeout when new data arrives', () => {
|
||||
vi.useFakeTimers();
|
||||
const keyHandler = vi.fn();
|
||||
|
||||
const { result } = renderHook(() => useKeypressContext(), {
|
||||
wrapper: ({ children }) => wrapper({ children, pasteWorkaround: true }),
|
||||
});
|
||||
|
||||
act(() => {
|
||||
result.current.subscribe(keyHandler);
|
||||
});
|
||||
|
||||
try {
|
||||
// Send first chunk
|
||||
act(() => {
|
||||
stdin.emit('data', Buffer.from('a'));
|
||||
});
|
||||
|
||||
// Advance timer almost to completion
|
||||
act(() => {
|
||||
vi.advanceTimersByTime(7);
|
||||
});
|
||||
|
||||
// Send second chunk (should reset timeout)
|
||||
act(() => {
|
||||
stdin.emit('data', Buffer.from('b'));
|
||||
});
|
||||
|
||||
// With the current implementation, both characters are processed immediately
|
||||
expect(keyHandler).toHaveBeenCalledTimes(2);
|
||||
|
||||
// First event should be 'a', second should be 'b'
|
||||
expect(keyHandler).toHaveBeenNthCalledWith(
|
||||
1,
|
||||
expect.objectContaining({
|
||||
name: 'a',
|
||||
sequence: 'a',
|
||||
paste: false,
|
||||
}),
|
||||
);
|
||||
expect(keyHandler).toHaveBeenNthCalledWith(
|
||||
2,
|
||||
expect.objectContaining({
|
||||
name: 'b',
|
||||
sequence: 'b',
|
||||
paste: false,
|
||||
}),
|
||||
);
|
||||
} finally {
|
||||
vi.useRealTimers();
|
||||
}
|
||||
});
|
||||
|
||||
it('should handle multiple separate keypress events', () => {
|
||||
vi.useFakeTimers();
|
||||
const keyHandler = vi.fn();
|
||||
|
||||
const { result } = renderHook(() => useKeypressContext(), {
|
||||
wrapper: ({ children }) => wrapper({ children, pasteWorkaround: true }),
|
||||
});
|
||||
|
||||
act(() => {
|
||||
result.current.subscribe(keyHandler);
|
||||
});
|
||||
|
||||
try {
|
||||
// First keypress
|
||||
act(() => {
|
||||
stdin.emit('data', Buffer.from('a'));
|
||||
});
|
||||
|
||||
act(() => {
|
||||
vi.advanceTimersByTime(8);
|
||||
});
|
||||
|
||||
expect(keyHandler).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
sequence: 'a',
|
||||
}),
|
||||
);
|
||||
|
||||
keyHandler.mockClear();
|
||||
|
||||
// Second keypress after first completed
|
||||
act(() => {
|
||||
stdin.emit('data', Buffer.from('b'));
|
||||
});
|
||||
|
||||
act(() => {
|
||||
vi.advanceTimersByTime(8);
|
||||
});
|
||||
|
||||
expect(keyHandler).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
sequence: 'b',
|
||||
}),
|
||||
);
|
||||
} finally {
|
||||
vi.useRealTimers();
|
||||
}
|
||||
});
|
||||
|
||||
it('should handle rapid sequential data within buffer limit', () => {
|
||||
vi.useFakeTimers();
|
||||
const keyHandler = vi.fn();
|
||||
|
||||
const { result } = renderHook(() => useKeypressContext(), {
|
||||
wrapper: ({ children }) => wrapper({ children, pasteWorkaround: true }),
|
||||
});
|
||||
|
||||
act(() => {
|
||||
result.current.subscribe(keyHandler);
|
||||
});
|
||||
|
||||
try {
|
||||
// Send multiple small chunks rapidly
|
||||
act(() => {
|
||||
stdin.emit('data', Buffer.from('h'));
|
||||
stdin.emit('data', Buffer.from('e'));
|
||||
stdin.emit('data', Buffer.from('l'));
|
||||
stdin.emit('data', Buffer.from('l'));
|
||||
stdin.emit('data', Buffer.from('o'));
|
||||
});
|
||||
|
||||
// With the current implementation, each character is processed immediately
|
||||
expect(keyHandler).toHaveBeenCalledTimes(5);
|
||||
|
||||
// Each character should be processed as individual keypress events
|
||||
expect(keyHandler).toHaveBeenNthCalledWith(
|
||||
1,
|
||||
expect.objectContaining({
|
||||
name: 'h',
|
||||
sequence: 'h',
|
||||
paste: false,
|
||||
}),
|
||||
);
|
||||
expect(keyHandler).toHaveBeenNthCalledWith(
|
||||
2,
|
||||
expect.objectContaining({
|
||||
name: 'e',
|
||||
sequence: 'e',
|
||||
paste: false,
|
||||
}),
|
||||
);
|
||||
expect(keyHandler).toHaveBeenNthCalledWith(
|
||||
3,
|
||||
expect.objectContaining({
|
||||
name: 'l',
|
||||
sequence: 'l',
|
||||
paste: false,
|
||||
}),
|
||||
);
|
||||
expect(keyHandler).toHaveBeenNthCalledWith(
|
||||
4,
|
||||
expect.objectContaining({
|
||||
name: 'l',
|
||||
sequence: 'l',
|
||||
paste: false,
|
||||
}),
|
||||
);
|
||||
expect(keyHandler).toHaveBeenNthCalledWith(
|
||||
5,
|
||||
expect.objectContaining({
|
||||
name: 'o',
|
||||
sequence: 'o',
|
||||
paste: false,
|
||||
}),
|
||||
);
|
||||
} finally {
|
||||
vi.useRealTimers();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('debug keystroke logging', () => {
|
||||
|
||||
@@ -71,11 +71,13 @@ export function useKeypressContext() {
|
||||
export function KeypressProvider({
|
||||
children,
|
||||
kittyProtocolEnabled,
|
||||
pasteWorkaround = false,
|
||||
config,
|
||||
debugKeystrokeLogging,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
children?: React.ReactNode;
|
||||
kittyProtocolEnabled: boolean;
|
||||
pasteWorkaround?: boolean;
|
||||
config?: Config;
|
||||
debugKeystrokeLogging?: boolean;
|
||||
}) {
|
||||
@@ -101,12 +103,8 @@ export function KeypressProvider({
|
||||
|
||||
const keypressStream = new PassThrough();
|
||||
let usePassthrough = false;
|
||||
const nodeMajorVersion = parseInt(process.versions.node.split('.')[0], 10);
|
||||
if (
|
||||
nodeMajorVersion < 20 ||
|
||||
process.env['PASTE_WORKAROUND'] === '1' ||
|
||||
process.env['PASTE_WORKAROUND'] === 'true'
|
||||
) {
|
||||
// Use passthrough mode when pasteWorkaround is enabled,
|
||||
if (pasteWorkaround) {
|
||||
usePassthrough = true;
|
||||
}
|
||||
|
||||
@@ -115,6 +113,8 @@ export function KeypressProvider({
|
||||
let kittySequenceBuffer = '';
|
||||
let backslashTimeout: NodeJS.Timeout | null = null;
|
||||
let waitingForEnterAfterBackslash = false;
|
||||
let rawDataBuffer = Buffer.alloc(0);
|
||||
let rawFlushTimeout: NodeJS.Timeout | null = null;
|
||||
|
||||
const parseKittySequence = (sequence: string): Key | null => {
|
||||
const kittyPattern = new RegExp(`^${ESC}\\[(\\d+)(;(\\d+))?([u~])$`);
|
||||
@@ -332,54 +332,111 @@ export function KeypressProvider({
|
||||
broadcast({ ...key, paste: isPaste });
|
||||
};
|
||||
|
||||
const handleRawKeypress = (data: Buffer) => {
|
||||
const clearRawFlushTimeout = () => {
|
||||
if (rawFlushTimeout) {
|
||||
clearTimeout(rawFlushTimeout);
|
||||
rawFlushTimeout = null;
|
||||
}
|
||||
};
|
||||
|
||||
const createPasteKeyEvent = (
|
||||
name: 'paste-start' | 'paste-end' | '' = '',
|
||||
sequence: string = '',
|
||||
): Key => ({
|
||||
name,
|
||||
ctrl: false,
|
||||
meta: false,
|
||||
shift: false,
|
||||
paste: false,
|
||||
sequence,
|
||||
});
|
||||
|
||||
const flushRawBuffer = () => {
|
||||
if (!rawDataBuffer.length) {
|
||||
return;
|
||||
}
|
||||
|
||||
const pasteModePrefixBuffer = Buffer.from(PASTE_MODE_PREFIX);
|
||||
const pasteModeSuffixBuffer = Buffer.from(PASTE_MODE_SUFFIX);
|
||||
const data = rawDataBuffer;
|
||||
let cursor = 0;
|
||||
|
||||
let pos = 0;
|
||||
while (pos < data.length) {
|
||||
const prefixPos = data.indexOf(pasteModePrefixBuffer, pos);
|
||||
const suffixPos = data.indexOf(pasteModeSuffixBuffer, pos);
|
||||
const isPrefixNext =
|
||||
prefixPos !== -1 && (suffixPos === -1 || prefixPos < suffixPos);
|
||||
const isSuffixNext =
|
||||
suffixPos !== -1 && (prefixPos === -1 || suffixPos < prefixPos);
|
||||
while (cursor < data.length) {
|
||||
const prefixPos = data.indexOf(pasteModePrefixBuffer, cursor);
|
||||
const suffixPos = data.indexOf(pasteModeSuffixBuffer, cursor);
|
||||
const hasPrefix =
|
||||
prefixPos !== -1 &&
|
||||
prefixPos + pasteModePrefixBuffer.length <= data.length;
|
||||
const hasSuffix =
|
||||
suffixPos !== -1 &&
|
||||
suffixPos + pasteModeSuffixBuffer.length <= data.length;
|
||||
|
||||
let nextMarkerPos = -1;
|
||||
let markerPos = -1;
|
||||
let markerLength = 0;
|
||||
let markerType: 'prefix' | 'suffix' | null = null;
|
||||
|
||||
if (isPrefixNext) {
|
||||
nextMarkerPos = prefixPos;
|
||||
} else if (isSuffixNext) {
|
||||
nextMarkerPos = suffixPos;
|
||||
}
|
||||
markerLength = pasteModeSuffixBuffer.length;
|
||||
|
||||
if (nextMarkerPos === -1) {
|
||||
keypressStream.write(data.slice(pos));
|
||||
return;
|
||||
if (hasPrefix && (!hasSuffix || prefixPos < suffixPos)) {
|
||||
markerPos = prefixPos;
|
||||
markerLength = pasteModePrefixBuffer.length;
|
||||
markerType = 'prefix';
|
||||
} else if (hasSuffix) {
|
||||
markerPos = suffixPos;
|
||||
markerLength = pasteModeSuffixBuffer.length;
|
||||
markerType = 'suffix';
|
||||
}
|
||||
|
||||
const nextData = data.slice(pos, nextMarkerPos);
|
||||
if (markerPos === -1) {
|
||||
break;
|
||||
}
|
||||
|
||||
const nextData = data.slice(cursor, markerPos);
|
||||
if (nextData.length > 0) {
|
||||
keypressStream.write(nextData);
|
||||
}
|
||||
const createPasteKeyEvent = (
|
||||
name: 'paste-start' | 'paste-end',
|
||||
): Key => ({
|
||||
name,
|
||||
ctrl: false,
|
||||
meta: false,
|
||||
shift: false,
|
||||
paste: false,
|
||||
sequence: '',
|
||||
});
|
||||
if (isPrefixNext) {
|
||||
if (markerType === 'prefix') {
|
||||
handleKeypress(undefined, createPasteKeyEvent('paste-start'));
|
||||
} else if (isSuffixNext) {
|
||||
} else if (markerType === 'suffix') {
|
||||
handleKeypress(undefined, createPasteKeyEvent('paste-end'));
|
||||
}
|
||||
pos = nextMarkerPos + markerLength;
|
||||
cursor = markerPos + markerLength;
|
||||
}
|
||||
|
||||
rawDataBuffer = data.slice(cursor);
|
||||
|
||||
if (rawDataBuffer.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (rawDataBuffer.length <= 2 || isPaste) {
|
||||
keypressStream.write(rawDataBuffer);
|
||||
} else {
|
||||
// Flush raw data buffer as a paste event
|
||||
handleKeypress(undefined, createPasteKeyEvent('paste-start'));
|
||||
keypressStream.write(rawDataBuffer);
|
||||
handleKeypress(undefined, createPasteKeyEvent('paste-end'));
|
||||
}
|
||||
|
||||
rawDataBuffer = Buffer.alloc(0);
|
||||
clearRawFlushTimeout();
|
||||
};
|
||||
|
||||
const handleRawKeypress = (_data: Buffer) => {
|
||||
const data = Buffer.isBuffer(_data) ? _data : Buffer.from(_data, 'utf8');
|
||||
|
||||
// Buffer the incoming data
|
||||
rawDataBuffer = Buffer.concat([rawDataBuffer, data]);
|
||||
|
||||
clearRawFlushTimeout();
|
||||
|
||||
// On some Windows terminals, during a paste, the terminal might send a
|
||||
// single return character chunk. In this case, we need to wait a time period
|
||||
// to know if it is part of a paste or just a return character.
|
||||
const isReturnChar =
|
||||
rawDataBuffer.length <= 2 && rawDataBuffer.includes(0x0d);
|
||||
if (isReturnChar) {
|
||||
rawFlushTimeout = setTimeout(flushRawBuffer, 100);
|
||||
} else {
|
||||
flushRawBuffer();
|
||||
}
|
||||
};
|
||||
|
||||
@@ -416,6 +473,11 @@ export function KeypressProvider({
|
||||
backslashTimeout = null;
|
||||
}
|
||||
|
||||
if (rawFlushTimeout) {
|
||||
clearTimeout(rawFlushTimeout);
|
||||
rawFlushTimeout = null;
|
||||
}
|
||||
|
||||
// Flush any pending paste data to avoid data loss on exit.
|
||||
if (isPaste) {
|
||||
broadcast({
|
||||
@@ -433,9 +495,10 @@ export function KeypressProvider({
|
||||
stdin,
|
||||
setRawMode,
|
||||
kittyProtocolEnabled,
|
||||
debugKeystrokeLogging,
|
||||
pasteWorkaround,
|
||||
config,
|
||||
subscribers,
|
||||
debugKeystrokeLogging,
|
||||
]);
|
||||
|
||||
return (
|
||||
|
||||
@@ -507,7 +507,7 @@ export const useSlashCommandProcessor = (
|
||||
setTimeout(async () => {
|
||||
await runExitCleanup();
|
||||
process.exit(0);
|
||||
}, 1000);
|
||||
}, 100);
|
||||
return { type: 'handled' };
|
||||
|
||||
case 'submit_prompt':
|
||||
|
||||
@@ -911,10 +911,13 @@ export const useGeminiStream = (
|
||||
],
|
||||
);
|
||||
|
||||
const pendingHistoryItems = [
|
||||
pendingHistoryItemRef.current,
|
||||
pendingToolCallGroupDisplay,
|
||||
].filter((i) => i !== undefined && i !== null);
|
||||
const pendingHistoryItems = useMemo(
|
||||
() =>
|
||||
[pendingHistoryItemRef.current, pendingToolCallGroupDisplay].filter(
|
||||
(i) => i !== undefined && i !== null,
|
||||
),
|
||||
[pendingHistoryItemRef, pendingToolCallGroupDisplay],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
const saveRestorableToolCalls = async () => {
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
*/
|
||||
|
||||
import React from 'react';
|
||||
import { renderHook, act } from '@testing-library/react';
|
||||
import { renderHook, act, waitFor } from '@testing-library/react';
|
||||
import type { Key } from './useKeypress.js';
|
||||
import { useKeypress } from './useKeypress.js';
|
||||
import { KeypressProvider } from '../contexts/KeypressContext.js';
|
||||
@@ -55,8 +55,8 @@ vi.mock('readline', () => {
|
||||
class MockStdin extends EventEmitter {
|
||||
isTTY = true;
|
||||
setRawMode = vi.fn();
|
||||
on = this.addListener;
|
||||
removeListener = this.removeListener;
|
||||
override on = this.addListener;
|
||||
override removeListener = super.removeListener;
|
||||
write = vi.fn();
|
||||
resume = vi.fn();
|
||||
|
||||
@@ -106,12 +106,33 @@ describe('useKeypress', () => {
|
||||
let originalNodeVersion: string;
|
||||
|
||||
const wrapper = ({ children }: { children: React.ReactNode }) =>
|
||||
React.createElement(KeypressProvider, null, children);
|
||||
React.createElement(
|
||||
KeypressProvider,
|
||||
{
|
||||
kittyProtocolEnabled: false,
|
||||
pasteWoraround: false,
|
||||
},
|
||||
children,
|
||||
);
|
||||
|
||||
const wrapperWithWindowsWorkaround = ({
|
||||
children,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
}) =>
|
||||
React.createElement(
|
||||
KeypressProvider,
|
||||
{
|
||||
kittyProtocolEnabled: false,
|
||||
pasteWoraround: true,
|
||||
},
|
||||
children,
|
||||
);
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
stdin = new MockStdin();
|
||||
(useStdin as vi.Mock).mockReturnValue({
|
||||
(useStdin as ReturnType<typeof vi.fn>).mockReturnValue({
|
||||
stdin,
|
||||
setRawMode: mockSetRawMode,
|
||||
});
|
||||
@@ -188,34 +209,33 @@ describe('useKeypress', () => {
|
||||
description: 'Modern Node (>= v20)',
|
||||
setup: () => setNodeVersion('20.0.0'),
|
||||
isLegacy: false,
|
||||
pasteWoraround: false,
|
||||
},
|
||||
{
|
||||
description: 'Legacy Node (< v20)',
|
||||
setup: () => setNodeVersion('18.0.0'),
|
||||
isLegacy: true,
|
||||
},
|
||||
{
|
||||
description: 'Workaround Env Var',
|
||||
description: 'PasteWorkaround Environment Variable',
|
||||
setup: () => {
|
||||
setNodeVersion('20.0.0');
|
||||
vi.stubEnv('PASTE_WORKAROUND', 'true');
|
||||
},
|
||||
isLegacy: true,
|
||||
isLegacy: false,
|
||||
pasteWoraround: true,
|
||||
},
|
||||
])('in $description', ({ setup, isLegacy }) => {
|
||||
])('in $description', ({ setup, isLegacy, pasteWoraround }) => {
|
||||
beforeEach(() => {
|
||||
setup();
|
||||
stdin.setLegacy(isLegacy);
|
||||
});
|
||||
|
||||
it('should process a paste as a single event', () => {
|
||||
it('should process a paste as a single event', async () => {
|
||||
renderHook(() => useKeypress(onKeypress, { isActive: true }), {
|
||||
wrapper,
|
||||
wrapper: pasteWoraround ? wrapperWithWindowsWorkaround : wrapper,
|
||||
});
|
||||
const pasteText = 'hello world';
|
||||
act(() => stdin.paste(pasteText));
|
||||
|
||||
expect(onKeypress).toHaveBeenCalledTimes(1);
|
||||
await waitFor(() => {
|
||||
expect(onKeypress).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
expect(onKeypress).toHaveBeenCalledWith({
|
||||
name: '',
|
||||
ctrl: false,
|
||||
@@ -226,47 +246,59 @@ describe('useKeypress', () => {
|
||||
});
|
||||
});
|
||||
|
||||
it('should handle keypress interspersed with pastes', () => {
|
||||
it('should handle keypress interspersed with pastes', async () => {
|
||||
renderHook(() => useKeypress(onKeypress, { isActive: true }), {
|
||||
wrapper,
|
||||
wrapper: pasteWoraround ? wrapperWithWindowsWorkaround : wrapper,
|
||||
});
|
||||
|
||||
const keyA = { name: 'a', sequence: 'a' };
|
||||
act(() => stdin.pressKey(keyA));
|
||||
expect(onKeypress).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ ...keyA, paste: false }),
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(onKeypress).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ ...keyA, paste: false }),
|
||||
);
|
||||
});
|
||||
|
||||
const pasteText = 'pasted';
|
||||
act(() => stdin.paste(pasteText));
|
||||
expect(onKeypress).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ paste: true, sequence: pasteText }),
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(onKeypress).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ paste: true, sequence: pasteText }),
|
||||
);
|
||||
});
|
||||
|
||||
const keyB = { name: 'b', sequence: 'b' };
|
||||
act(() => stdin.pressKey(keyB));
|
||||
expect(onKeypress).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ ...keyB, paste: false }),
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(onKeypress).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ ...keyB, paste: false }),
|
||||
);
|
||||
});
|
||||
|
||||
expect(onKeypress).toHaveBeenCalledTimes(3);
|
||||
});
|
||||
|
||||
it('should emit partial paste content if unmounted mid-paste', () => {
|
||||
it('should emit partial paste content if unmounted mid-paste', async () => {
|
||||
const { unmount } = renderHook(
|
||||
() => useKeypress(onKeypress, { isActive: true }),
|
||||
{ wrapper },
|
||||
{
|
||||
wrapper: pasteWoraround ? wrapperWithWindowsWorkaround : wrapper,
|
||||
},
|
||||
);
|
||||
const pasteText = 'incomplete paste';
|
||||
|
||||
act(() => stdin.startPaste(pasteText));
|
||||
|
||||
// No event should be fired yet.
|
||||
// No event should be fired yet for incomplete paste
|
||||
expect(onKeypress).not.toHaveBeenCalled();
|
||||
|
||||
// Unmounting should trigger the flush.
|
||||
// Unmounting should trigger the flush
|
||||
unmount();
|
||||
|
||||
// Both legacy and modern modes now flush partial paste content on unmount
|
||||
expect(onKeypress).toHaveBeenCalledTimes(1);
|
||||
expect(onKeypress).toHaveBeenCalledWith({
|
||||
name: '',
|
||||
|
||||
@@ -238,6 +238,7 @@ export interface ConfigParameters {
|
||||
skipNextSpeakerCheck?: boolean;
|
||||
extensionManagement?: boolean;
|
||||
enablePromptCompletion?: boolean;
|
||||
skipLoopDetection?: boolean;
|
||||
}
|
||||
|
||||
export class Config {
|
||||
@@ -328,6 +329,7 @@ export class Config {
|
||||
private readonly skipNextSpeakerCheck: boolean;
|
||||
private readonly extensionManagement: boolean;
|
||||
private readonly enablePromptCompletion: boolean = false;
|
||||
private readonly skipLoopDetection: boolean;
|
||||
private initialized: boolean = false;
|
||||
readonly storage: Storage;
|
||||
private readonly fileExclusions: FileExclusions;
|
||||
@@ -410,6 +412,9 @@ export class Config {
|
||||
this.chatCompression = params.chatCompression;
|
||||
this.interactive = params.interactive ?? false;
|
||||
this.trustedFolder = params.trustedFolder;
|
||||
this.shouldUseNodePtyShell = params.shouldUseNodePtyShell ?? false;
|
||||
this.skipNextSpeakerCheck = params.skipNextSpeakerCheck ?? false;
|
||||
this.skipLoopDetection = params.skipLoopDetection ?? false;
|
||||
|
||||
// Web search
|
||||
this.tavilyApiKey = params.tavilyApiKey;
|
||||
@@ -917,6 +922,10 @@ export class Config {
|
||||
return this.enablePromptCompletion;
|
||||
}
|
||||
|
||||
getSkipLoopDetection(): boolean {
|
||||
return this.skipLoopDetection;
|
||||
}
|
||||
|
||||
async getGitService(): Promise<GitService> {
|
||||
if (!this.gitService) {
|
||||
this.gitService = new GitService(this.targetDir, this.storage);
|
||||
|
||||
@@ -105,7 +105,7 @@ export class Storage {
|
||||
}
|
||||
|
||||
getExtensionsConfigPath(): string {
|
||||
return path.join(this.getExtensionsDir(), 'gemini-extension.json');
|
||||
return path.join(this.getExtensionsDir(), 'qwen-extension.json');
|
||||
}
|
||||
|
||||
getHistoryFilePath(): string {
|
||||
|
||||
@@ -226,6 +226,9 @@ describe('Gemini Client (client.ts)', () => {
|
||||
vertexai: false,
|
||||
authType: AuthType.USE_GEMINI,
|
||||
};
|
||||
const mockSubagentManager = {
|
||||
listSubagents: vi.fn().mockResolvedValue([]),
|
||||
};
|
||||
const mockConfigObject = {
|
||||
getContentGeneratorConfig: vi
|
||||
.fn()
|
||||
@@ -260,6 +263,8 @@ describe('Gemini Client (client.ts)', () => {
|
||||
getCliVersion: vi.fn().mockReturnValue('1.0.0'),
|
||||
getChatCompression: vi.fn().mockReturnValue(undefined),
|
||||
getSkipNextSpeakerCheck: vi.fn().mockReturnValue(false),
|
||||
getSubagentManager: vi.fn().mockReturnValue(mockSubagentManager),
|
||||
getSkipLoopDetection: vi.fn().mockReturnValue(false),
|
||||
};
|
||||
const MockedConfig = vi.mocked(Config, true);
|
||||
MockedConfig.mockImplementation(
|
||||
@@ -2291,6 +2296,100 @@ ${JSON.stringify(
|
||||
// Assert
|
||||
expect(mockCheckNextSpeaker).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('does not run loop checks when skipLoopDetection is true', async () => {
|
||||
// Arrange
|
||||
// Ensure config returns true for skipLoopDetection
|
||||
vi.spyOn(client['config'], 'getSkipLoopDetection').mockReturnValue(true);
|
||||
|
||||
// Replace loop detector with spies
|
||||
const ldMock = {
|
||||
turnStarted: vi.fn().mockResolvedValue(false),
|
||||
addAndCheck: vi.fn().mockReturnValue(false),
|
||||
reset: vi.fn(),
|
||||
};
|
||||
// @ts-expect-error override private for testing
|
||||
client['loopDetector'] = ldMock;
|
||||
|
||||
const mockStream = (async function* () {
|
||||
yield { type: 'content', value: 'Hello' };
|
||||
yield { type: 'content', value: 'World' };
|
||||
})();
|
||||
mockTurnRunFn.mockReturnValue(mockStream);
|
||||
|
||||
const mockChat: Partial<GeminiChat> = {
|
||||
addHistory: vi.fn(),
|
||||
getHistory: vi.fn().mockReturnValue([]),
|
||||
};
|
||||
client['chat'] = mockChat as GeminiChat;
|
||||
|
||||
const mockGenerator: Partial<ContentGenerator> = {
|
||||
countTokens: vi.fn().mockResolvedValue({ totalTokens: 0 }),
|
||||
generateContent: mockGenerateContentFn,
|
||||
};
|
||||
client['contentGenerator'] = mockGenerator as ContentGenerator;
|
||||
|
||||
// Act
|
||||
const stream = client.sendMessageStream(
|
||||
[{ text: 'Hi' }],
|
||||
new AbortController().signal,
|
||||
'prompt-id-loop-skip',
|
||||
);
|
||||
for await (const _ of stream) {
|
||||
// consume
|
||||
}
|
||||
|
||||
// Assert: methods not called due to skip
|
||||
const ld = client['loopDetector'] as unknown as {
|
||||
turnStarted: ReturnType<typeof vi.fn>;
|
||||
addAndCheck: ReturnType<typeof vi.fn>;
|
||||
};
|
||||
expect(ld.turnStarted).not.toHaveBeenCalled();
|
||||
expect(ld.addAndCheck).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('runs loop checks when skipLoopDetection is false', async () => {
|
||||
// Arrange
|
||||
vi.spyOn(client['config'], 'getSkipLoopDetection').mockReturnValue(false);
|
||||
|
||||
const turnStarted = vi.fn().mockResolvedValue(false);
|
||||
const addAndCheck = vi.fn().mockReturnValue(false);
|
||||
const reset = vi.fn();
|
||||
// @ts-expect-error override private for testing
|
||||
client['loopDetector'] = { turnStarted, addAndCheck, reset };
|
||||
|
||||
const mockStream = (async function* () {
|
||||
yield { type: 'content', value: 'Hello' };
|
||||
yield { type: 'content', value: 'World' };
|
||||
})();
|
||||
mockTurnRunFn.mockReturnValue(mockStream);
|
||||
|
||||
const mockChat: Partial<GeminiChat> = {
|
||||
addHistory: vi.fn(),
|
||||
getHistory: vi.fn().mockReturnValue([]),
|
||||
};
|
||||
client['chat'] = mockChat as GeminiChat;
|
||||
|
||||
const mockGenerator: Partial<ContentGenerator> = {
|
||||
countTokens: vi.fn().mockResolvedValue({ totalTokens: 0 }),
|
||||
generateContent: mockGenerateContentFn,
|
||||
};
|
||||
client['contentGenerator'] = mockGenerator as ContentGenerator;
|
||||
|
||||
// Act
|
||||
const stream = client.sendMessageStream(
|
||||
[{ text: 'Hi' }],
|
||||
new AbortController().signal,
|
||||
'prompt-id-loop-run',
|
||||
);
|
||||
for await (const _ of stream) {
|
||||
// consume
|
||||
}
|
||||
|
||||
// Assert
|
||||
expect(turnStarted).toHaveBeenCalledTimes(1);
|
||||
expect(addAndCheck).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('generateContent', () => {
|
||||
|
||||
@@ -29,6 +29,7 @@ import {
|
||||
makeChatCompressionEvent,
|
||||
NextSpeakerCheckEvent,
|
||||
} from '../telemetry/types.js';
|
||||
import { TaskTool } from '../tools/task.js';
|
||||
import {
|
||||
getDirectoryContextString,
|
||||
getEnvironmentContext,
|
||||
@@ -455,7 +456,8 @@ export class GeminiClient {
|
||||
turns: number = MAX_TURNS,
|
||||
originalModel?: string,
|
||||
): AsyncGenerator<ServerGeminiStreamEvent, Turn> {
|
||||
if (this.lastPromptId !== prompt_id) {
|
||||
const isNewPrompt = this.lastPromptId !== prompt_id;
|
||||
if (isNewPrompt) {
|
||||
this.loopDetector.reset(prompt_id);
|
||||
this.lastPromptId = prompt_id;
|
||||
}
|
||||
@@ -552,19 +554,41 @@ export class GeminiClient {
|
||||
this.forceFullIdeContext = false;
|
||||
}
|
||||
|
||||
if (isNewPrompt) {
|
||||
const taskTool = this.config.getToolRegistry().getTool(TaskTool.Name);
|
||||
const subagents = (
|
||||
await this.config.getSubagentManager().listSubagents()
|
||||
).filter((subagent) => subagent.level !== 'builtin');
|
||||
|
||||
if (taskTool && subagents.length > 0) {
|
||||
this.getChat().addHistory({
|
||||
role: 'user',
|
||||
parts: [
|
||||
{
|
||||
text: `<system-reminder>You have powerful specialized agents at your disposal, available agent types are: ${subagents.map((subagent) => subagent.name).join(', ')}. PROACTIVELY use the ${TaskTool.Name} tool to delegate user's task to appropriate agent when user's task matches agent capabilities. Ignore this message if user's task is not relevant to any agent. This message is for internal use only. Do not mention this to user in your response.</system-reminder>`,
|
||||
},
|
||||
],
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const turn = new Turn(this.getChat(), prompt_id);
|
||||
|
||||
const loopDetected = await this.loopDetector.turnStarted(signal);
|
||||
if (loopDetected) {
|
||||
yield { type: GeminiEventType.LoopDetected };
|
||||
return turn;
|
||||
if (!this.config.getSkipLoopDetection()) {
|
||||
const loopDetected = await this.loopDetector.turnStarted(signal);
|
||||
if (loopDetected) {
|
||||
yield { type: GeminiEventType.LoopDetected };
|
||||
return turn;
|
||||
}
|
||||
}
|
||||
|
||||
const resultStream = turn.run(request, signal);
|
||||
for await (const event of resultStream) {
|
||||
if (this.loopDetector.addAndCheck(event)) {
|
||||
yield { type: GeminiEventType.LoopDetected };
|
||||
return turn;
|
||||
if (!this.config.getSkipLoopDetection()) {
|
||||
if (this.loopDetector.addAndCheck(event)) {
|
||||
yield { type: GeminiEventType.LoopDetected };
|
||||
return turn;
|
||||
}
|
||||
}
|
||||
yield event;
|
||||
if (event.type === GeminiEventType.Error) {
|
||||
|
||||
@@ -176,7 +176,7 @@ describe('GitService', () => {
|
||||
await service.setupShadowGitRepository();
|
||||
|
||||
const expectedConfigContent =
|
||||
'[user]\n name = Gemini CLI\n email = gemini-cli@google.com\n[commit]\n gpgsign = false\n';
|
||||
'[user]\n name = Qwen Code\n email = qwen-code@qwen.ai\n[commit]\n gpgsign = false\n';
|
||||
const actualConfigContent = await fs.readFile(gitConfigPath, 'utf-8');
|
||||
expect(actualConfigContent).toBe(expectedConfigContent);
|
||||
});
|
||||
|
||||
@@ -65,7 +65,7 @@ export class GitService {
|
||||
// We don't want to inherit the user's name, email, or gpg signing
|
||||
// preferences for the shadow repository, so we create a dedicated gitconfig.
|
||||
const gitConfigContent =
|
||||
'[user]\n name = Gemini CLI\n email = gemini-cli@google.com\n[commit]\n gpgsign = false\n';
|
||||
'[user]\n name = Qwen Code\n email = qwen-code@qwen.ai\n[commit]\n gpgsign = false\n';
|
||||
await fs.writeFile(gitConfigPath, gitConfigContent);
|
||||
|
||||
const repo = simpleGit(repoDir);
|
||||
|
||||
@@ -185,6 +185,7 @@ You are a helpful assistant.
|
||||
const config = manager.parseSubagentContent(
|
||||
validMarkdown,
|
||||
validConfig.filePath,
|
||||
'project',
|
||||
);
|
||||
|
||||
expect(config.name).toBe('test-agent');
|
||||
@@ -209,6 +210,7 @@ You are a helpful assistant.
|
||||
const config = manager.parseSubagentContent(
|
||||
markdownWithTools,
|
||||
validConfig.filePath,
|
||||
'project',
|
||||
);
|
||||
|
||||
expect(config.tools).toEqual(['read_file', 'write_file']);
|
||||
@@ -229,6 +231,7 @@ You are a helpful assistant.
|
||||
const config = manager.parseSubagentContent(
|
||||
markdownWithModel,
|
||||
validConfig.filePath,
|
||||
'project',
|
||||
);
|
||||
|
||||
expect(config.modelConfig).toEqual({ model: 'custom-model', temp: 0.5 });
|
||||
@@ -249,6 +252,7 @@ You are a helpful assistant.
|
||||
const config = manager.parseSubagentContent(
|
||||
markdownWithRun,
|
||||
validConfig.filePath,
|
||||
'project',
|
||||
);
|
||||
|
||||
expect(config.runConfig).toEqual({ max_time_minutes: 5, max_turns: 10 });
|
||||
@@ -266,6 +270,7 @@ You are a helpful assistant.
|
||||
const config = manager.parseSubagentContent(
|
||||
markdownWithNumeric,
|
||||
validConfig.filePath,
|
||||
'project',
|
||||
);
|
||||
|
||||
expect(config.name).toBe('11');
|
||||
@@ -286,6 +291,7 @@ You are a helpful assistant.
|
||||
const config = manager.parseSubagentContent(
|
||||
markdownWithBoolean,
|
||||
validConfig.filePath,
|
||||
'project',
|
||||
);
|
||||
|
||||
expect(config.name).toBe('true');
|
||||
@@ -301,8 +307,13 @@ You are a helpful assistant.
|
||||
const projectConfig = manager.parseSubagentContent(
|
||||
validMarkdown,
|
||||
projectPath,
|
||||
'project',
|
||||
);
|
||||
const userConfig = manager.parseSubagentContent(
|
||||
validMarkdown,
|
||||
userPath,
|
||||
'user',
|
||||
);
|
||||
const userConfig = manager.parseSubagentContent(validMarkdown, userPath);
|
||||
|
||||
expect(projectConfig.level).toBe('project');
|
||||
expect(userConfig.level).toBe('user');
|
||||
@@ -313,7 +324,11 @@ You are a helpful assistant.
|
||||
Just content`;
|
||||
|
||||
expect(() =>
|
||||
manager.parseSubagentContent(invalidMarkdown, validConfig.filePath),
|
||||
manager.parseSubagentContent(
|
||||
invalidMarkdown,
|
||||
validConfig.filePath,
|
||||
'project',
|
||||
),
|
||||
).toThrow(SubagentError);
|
||||
});
|
||||
|
||||
@@ -326,7 +341,11 @@ You are a helpful assistant.
|
||||
`;
|
||||
|
||||
expect(() =>
|
||||
manager.parseSubagentContent(markdownWithoutName, validConfig.filePath),
|
||||
manager.parseSubagentContent(
|
||||
markdownWithoutName,
|
||||
validConfig.filePath,
|
||||
'project',
|
||||
),
|
||||
).toThrow(SubagentError);
|
||||
});
|
||||
|
||||
@@ -342,39 +361,20 @@ You are a helpful assistant.
|
||||
manager.parseSubagentContent(
|
||||
markdownWithoutDescription,
|
||||
validConfig.filePath,
|
||||
'project',
|
||||
),
|
||||
).toThrow(SubagentError);
|
||||
});
|
||||
|
||||
it('should warn when filename does not match subagent name', () => {
|
||||
const consoleSpy = vi.spyOn(console, 'warn').mockImplementation(() => {});
|
||||
const mismatchedPath = '/test/project/.qwen/agents/wrong-filename.md';
|
||||
|
||||
const config = manager.parseSubagentContent(
|
||||
validMarkdown,
|
||||
mismatchedPath,
|
||||
);
|
||||
|
||||
expect(config.name).toBe('test-agent');
|
||||
expect(consoleSpy).toHaveBeenCalledWith(
|
||||
expect.stringContaining(
|
||||
'Warning: Subagent file "wrong-filename.md" contains name "test-agent"',
|
||||
),
|
||||
);
|
||||
expect(consoleSpy).toHaveBeenCalledWith(
|
||||
expect.stringContaining(
|
||||
'Consider renaming the file to "test-agent.md"',
|
||||
),
|
||||
);
|
||||
|
||||
consoleSpy.mockRestore();
|
||||
});
|
||||
|
||||
it('should not warn when filename matches subagent name', () => {
|
||||
const consoleSpy = vi.spyOn(console, 'warn').mockImplementation(() => {});
|
||||
const matchingPath = '/test/project/.qwen/agents/test-agent.md';
|
||||
|
||||
const config = manager.parseSubagentContent(validMarkdown, matchingPath);
|
||||
const config = manager.parseSubagentContent(
|
||||
validMarkdown,
|
||||
matchingPath,
|
||||
'project',
|
||||
);
|
||||
|
||||
expect(config.name).toBe('test-agent');
|
||||
expect(consoleSpy).not.toHaveBeenCalled();
|
||||
|
||||
@@ -39,6 +39,7 @@ const AGENT_CONFIG_DIR = 'agents';
|
||||
*/
|
||||
export class SubagentManager {
|
||||
private readonly validator: SubagentValidator;
|
||||
private subagentsCache: Map<SubagentLevel, SubagentConfig[]> | null = null;
|
||||
|
||||
constructor(private readonly config: Config) {
|
||||
this.validator = new SubagentValidator();
|
||||
@@ -92,6 +93,8 @@ export class SubagentManager {
|
||||
|
||||
try {
|
||||
await fs.writeFile(filePath, content, 'utf8');
|
||||
// Clear cache after successful creation
|
||||
this.clearCache();
|
||||
} catch (error) {
|
||||
throw new SubagentError(
|
||||
`Failed to write subagent file: ${error instanceof Error ? error.message : 'Unknown error'}`,
|
||||
@@ -180,6 +183,8 @@ export class SubagentManager {
|
||||
|
||||
try {
|
||||
await fs.writeFile(existing.filePath, content, 'utf8');
|
||||
// Clear cache after successful update
|
||||
this.clearCache();
|
||||
} catch (error) {
|
||||
throw new SubagentError(
|
||||
`Failed to update subagent file: ${error instanceof Error ? error.message : 'Unknown error'}`,
|
||||
@@ -236,6 +241,9 @@ export class SubagentManager {
|
||||
name,
|
||||
);
|
||||
}
|
||||
|
||||
// Clear cache after successful deletion
|
||||
this.clearCache();
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -254,9 +262,17 @@ export class SubagentManager {
|
||||
? [options.level]
|
||||
: ['project', 'user', 'builtin'];
|
||||
|
||||
// Check if we should use cache or force refresh
|
||||
const shouldUseCache = !options.force && this.subagentsCache !== null;
|
||||
|
||||
// Initialize cache if it doesn't exist or we're forcing a refresh
|
||||
if (!shouldUseCache) {
|
||||
await this.refreshCache();
|
||||
}
|
||||
|
||||
// Collect subagents from each level (project takes precedence over user, user takes precedence over builtin)
|
||||
for (const level of levelsToCheck) {
|
||||
const levelSubagents = await this.listSubagentsAtLevel(level);
|
||||
const levelSubagents = this.subagentsCache?.get(level) || [];
|
||||
|
||||
for (const subagent of levelSubagents) {
|
||||
// Skip if we've already seen this name (precedence: project > user > builtin)
|
||||
@@ -304,6 +320,30 @@ export class SubagentManager {
|
||||
return subagents;
|
||||
}
|
||||
|
||||
/**
|
||||
* Refreshes the subagents cache by loading all subagents from disk.
|
||||
* This method is called automatically when cache is null or when force=true.
|
||||
*
|
||||
* @private
|
||||
*/
|
||||
private async refreshCache(): Promise<void> {
|
||||
this.subagentsCache = new Map();
|
||||
|
||||
const levels: SubagentLevel[] = ['project', 'user', 'builtin'];
|
||||
|
||||
for (const level of levels) {
|
||||
const levelSubagents = await this.listSubagentsAtLevel(level);
|
||||
this.subagentsCache.set(level, levelSubagents);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Clears the subagents cache, forcing the next listSubagents call to reload from disk.
|
||||
*/
|
||||
clearCache(): void {
|
||||
this.subagentsCache = null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Finds a subagent by name and returns its metadata.
|
||||
*
|
||||
@@ -329,7 +369,10 @@ export class SubagentManager {
|
||||
* @returns SubagentConfig
|
||||
* @throws SubagentError if parsing fails
|
||||
*/
|
||||
async parseSubagentFile(filePath: string): Promise<SubagentConfig> {
|
||||
async parseSubagentFile(
|
||||
filePath: string,
|
||||
level: SubagentLevel,
|
||||
): Promise<SubagentConfig> {
|
||||
let content: string;
|
||||
|
||||
try {
|
||||
@@ -341,7 +384,7 @@ export class SubagentManager {
|
||||
);
|
||||
}
|
||||
|
||||
return this.parseSubagentContent(content, filePath);
|
||||
return this.parseSubagentContent(content, filePath, level);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -352,7 +395,11 @@ export class SubagentManager {
|
||||
* @returns SubagentConfig
|
||||
* @throws SubagentError if parsing fails
|
||||
*/
|
||||
parseSubagentContent(content: string, filePath: string): SubagentConfig {
|
||||
parseSubagentContent(
|
||||
content: string,
|
||||
filePath: string,
|
||||
level: SubagentLevel,
|
||||
): SubagentConfig {
|
||||
try {
|
||||
// Split frontmatter and content
|
||||
const frontmatterRegex = /^---\n([\s\S]*?)\n---\n([\s\S]*)$/;
|
||||
@@ -393,31 +440,16 @@ export class SubagentManager {
|
||||
| undefined;
|
||||
const color = frontmatter['color'] as string | undefined;
|
||||
|
||||
// Determine level from file path using robust, cross-platform check
|
||||
// A project-level agent lives under <projectRoot>/.qwen/agents
|
||||
const projectAgentsDir = path.join(
|
||||
this.config.getProjectRoot(),
|
||||
QWEN_CONFIG_DIR,
|
||||
AGENT_CONFIG_DIR,
|
||||
);
|
||||
const rel = path.relative(
|
||||
path.normalize(projectAgentsDir),
|
||||
path.normalize(filePath),
|
||||
);
|
||||
const isProjectLevel =
|
||||
rel !== '' && !rel.startsWith('..') && !path.isAbsolute(rel);
|
||||
const level: SubagentLevel = isProjectLevel ? 'project' : 'user';
|
||||
|
||||
const config: SubagentConfig = {
|
||||
name,
|
||||
description,
|
||||
tools,
|
||||
systemPrompt: systemPrompt.trim(),
|
||||
level,
|
||||
filePath,
|
||||
modelConfig: modelConfig as Partial<ModelConfig>,
|
||||
runConfig: runConfig as Partial<RunConfig>,
|
||||
color,
|
||||
level,
|
||||
};
|
||||
|
||||
// Validate the parsed configuration
|
||||
@@ -426,16 +458,6 @@ export class SubagentManager {
|
||||
throw new Error(`Validation failed: ${validation.errors.join(', ')}`);
|
||||
}
|
||||
|
||||
// Warn if filename doesn't match subagent name (potential issue)
|
||||
const expectedFilename = `${config.name}.md`;
|
||||
const actualFilename = path.basename(filePath);
|
||||
if (actualFilename !== expectedFilename) {
|
||||
console.warn(
|
||||
`Warning: Subagent file "${actualFilename}" contains name "${config.name}" but filename suggests "${path.basename(actualFilename, '.md')}". ` +
|
||||
`Consider renaming the file to "${expectedFilename}" for consistency.`,
|
||||
);
|
||||
}
|
||||
|
||||
return config;
|
||||
} catch (error) {
|
||||
throw new SubagentError(
|
||||
@@ -678,14 +700,18 @@ export class SubagentManager {
|
||||
return BuiltinAgentRegistry.getBuiltinAgents();
|
||||
}
|
||||
|
||||
const baseDir =
|
||||
level === 'project'
|
||||
? path.join(
|
||||
this.config.getProjectRoot(),
|
||||
QWEN_CONFIG_DIR,
|
||||
AGENT_CONFIG_DIR,
|
||||
)
|
||||
: path.join(os.homedir(), QWEN_CONFIG_DIR, AGENT_CONFIG_DIR);
|
||||
const projectRoot = this.config.getProjectRoot();
|
||||
const homeDir = os.homedir();
|
||||
const isHomeDirectory = path.resolve(projectRoot) === path.resolve(homeDir);
|
||||
|
||||
// If project level is requested but project root is same as home directory,
|
||||
// return empty array to avoid conflicts between project and global agents
|
||||
if (level === 'project' && isHomeDirectory) {
|
||||
return [];
|
||||
}
|
||||
|
||||
let baseDir = level === 'project' ? projectRoot : homeDir;
|
||||
baseDir = path.join(baseDir, QWEN_CONFIG_DIR, AGENT_CONFIG_DIR);
|
||||
|
||||
try {
|
||||
const files = await fs.readdir(baseDir);
|
||||
@@ -697,7 +723,7 @@ export class SubagentManager {
|
||||
const filePath = path.join(baseDir, file);
|
||||
|
||||
try {
|
||||
const config = await this.parseSubagentFile(filePath);
|
||||
const config = await this.parseSubagentFile(filePath, level);
|
||||
subagents.push(config);
|
||||
} catch (_error) {
|
||||
// Ignore invalid files
|
||||
|
||||
@@ -116,6 +116,9 @@ export interface ListSubagentsOptions {
|
||||
|
||||
/** Sort direction */
|
||||
sortOrder?: 'asc' | 'desc';
|
||||
|
||||
/** Force refresh from disk, bypassing cache. Defaults to false. */
|
||||
force?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -62,6 +62,9 @@ describe('GlobTool', () => {
|
||||
// Ensure a noticeable difference in modification time
|
||||
await new Promise((resolve) => setTimeout(resolve, 50));
|
||||
await fs.writeFile(path.join(tempRootDir, 'newer.sortme'), 'newer_content');
|
||||
|
||||
// For type coercion testing
|
||||
await fs.mkdir(path.join(tempRootDir, '123'));
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
@@ -279,26 +282,20 @@ describe('GlobTool', () => {
|
||||
);
|
||||
});
|
||||
|
||||
it('should return error if path is provided but is not a string (schema validation)', () => {
|
||||
it('should pass if path is provided but is not a string (type coercion)', () => {
|
||||
const params = {
|
||||
pattern: '*.ts',
|
||||
path: 123,
|
||||
};
|
||||
// @ts-expect-error - We're intentionally creating invalid params for testing
|
||||
expect(globTool.validateToolParams(params)).toBe(
|
||||
'params/path must be string',
|
||||
);
|
||||
} as unknown as GlobToolParams; // Force incorrect type
|
||||
expect(globTool.validateToolParams(params)).toBeNull();
|
||||
});
|
||||
|
||||
it('should return error if case_sensitive is provided but is not a boolean (schema validation)', () => {
|
||||
it('should pass if case_sensitive is provided but is not a boolean (type coercion)', () => {
|
||||
const params = {
|
||||
pattern: '*.ts',
|
||||
case_sensitive: 'true',
|
||||
};
|
||||
// @ts-expect-error - We're intentionally creating invalid params for testing
|
||||
expect(globTool.validateToolParams(params)).toBe(
|
||||
'params/case_sensitive must be boolean',
|
||||
);
|
||||
} as unknown as GlobToolParams; // Force incorrect type
|
||||
expect(globTool.validateToolParams(params)).toBeNull();
|
||||
});
|
||||
|
||||
it("should return error if search path resolves outside the tool's root directory", () => {
|
||||
|
||||
@@ -191,14 +191,12 @@ describe('ReadManyFilesTool', () => {
|
||||
);
|
||||
});
|
||||
|
||||
it('should throw error if include array contains non-string elements', () => {
|
||||
it('should coerce non-string elements in include array', () => {
|
||||
const params = {
|
||||
paths: ['file1.txt'],
|
||||
include: ['*.ts', 123] as string[],
|
||||
};
|
||||
expect(() => tool.build(params)).toThrow(
|
||||
'params/include/1 must be string',
|
||||
);
|
||||
expect(() => tool.build(params)).toBeDefined();
|
||||
});
|
||||
|
||||
it('should throw error if exclude array contains non-string elements', () => {
|
||||
|
||||
@@ -419,6 +419,11 @@ export class ShellTool extends BaseDeclarativeTool<
|
||||
type: 'string',
|
||||
description: getCommandDescription(),
|
||||
},
|
||||
is_background: {
|
||||
type: 'boolean',
|
||||
description:
|
||||
'Whether to run the command in background. Default is false. Set to true for long-running processes like development servers, watchers, or daemons that should continue running without blocking further commands.',
|
||||
},
|
||||
description: {
|
||||
type: 'string',
|
||||
description:
|
||||
|
||||
@@ -220,14 +220,12 @@ describe('WriteFileTool', () => {
|
||||
);
|
||||
});
|
||||
|
||||
it('should throw an error if the content is null', () => {
|
||||
const dirAsFilePath = path.join(rootDir, 'a_directory');
|
||||
fs.mkdirSync(dirAsFilePath);
|
||||
it('should coerce null content into an empty string', () => {
|
||||
const params = {
|
||||
file_path: dirAsFilePath,
|
||||
file_path: path.join(rootDir, 'test.txt'),
|
||||
content: null,
|
||||
} as unknown as WriteFileToolParams; // Intentionally non-conforming
|
||||
expect(() => tool.build(params)).toThrow('params/content must be string');
|
||||
expect(() => tool.build(params)).toBeDefined();
|
||||
});
|
||||
|
||||
it('should throw error if the file_path is empty', () => {
|
||||
|
||||
@@ -9,7 +9,7 @@ import * as addFormats from 'ajv-formats';
|
||||
// Ajv's ESM/CJS interop: use 'any' for compatibility as recommended by Ajv docs
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const AjvClass = (AjvPkg as any).default || AjvPkg;
|
||||
const ajValidator = new AjvClass();
|
||||
const ajValidator = new AjvClass({ coerceTypes: true });
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const addFormatsFunc = (addFormats as any).default || addFormats;
|
||||
addFormatsFunc(ajValidator);
|
||||
@@ -32,8 +32,27 @@ export class SchemaValidator {
|
||||
const validate = ajValidator.compile(schema);
|
||||
const valid = validate(data);
|
||||
if (!valid && validate.errors) {
|
||||
return ajValidator.errorsText(validate.errors, { dataVar: 'params' });
|
||||
// Find any True or False values and lowercase them
|
||||
fixBooleanCasing(data as Record<string, unknown>);
|
||||
|
||||
const validate = ajValidator.compile(schema);
|
||||
const valid = validate(data);
|
||||
|
||||
if (!valid && validate.errors) {
|
||||
return ajValidator.errorsText(validate.errors, { dataVar: 'params' });
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function fixBooleanCasing(data: Record<string, unknown>) {
|
||||
for (const key of Object.keys(data)) {
|
||||
if (!(key in data)) continue;
|
||||
|
||||
if (typeof data[key] === 'object') {
|
||||
fixBooleanCasing(data[key] as Record<string, unknown>);
|
||||
} else if (data[key] === 'True') data[key] = 'true';
|
||||
else if (data[key] === 'False') data[key] = 'false';
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user