Compare commits

...

44 Commits

Author SHA1 Message Date
github-actions[bot]
09e00aec73 chore(release): v0.0.14-nightly.0 2025-09-25 09:05:13 +00:00
tanzhenxin
673854b446 fix: Remove unreliable editCorrector that injects extra escape characters (#713) 2025-09-25 16:46:58 +08:00
tanzhenxin
4e7a7e2656 feat: Implement Plan Mode for Safe Code Planning (#658) 2025-09-24 14:26:17 +08:00
Mingholy
8379bc4d81 chore: bump version to 0.0.13 (#695) 2025-09-24 13:58:18 +08:00
tanzhenxin
e148e4be28 🐛 Fix: Resolve Markdown list display issues on Windows (#693) 2025-09-24 11:00:47 +08:00
Mingholy
48d8587bf9 feat: add yolo mode support to auto vision model switch (#652)
* feat: add yolo mode support to auto vision model switch

* feat: add cli args & env variables for switch behavoir

* fix: use dedicated model names and settings

* docs: add vision model instructions

* fix: failed test case

* fix: setModel failure
2025-09-24 10:21:09 +08:00
tanzhenxin
5ecb4a2430 fix: make ripgrep lazy load, to fix vscode ide companion unable to start (#676) 2025-09-23 14:44:48 +08:00
Mingholy
9c1d7228cb fix: auth hang when select qwen-oauth (#684) 2025-09-23 14:30:22 +08:00
hokupod
deb99a3b21 feat: add OpenAI and Qwen OAuth auth support to Zed ACP integration (#678)
- Add USE_OPENAI and QWEN_OAUTH authentication methods to GeminiAgent's authMethods array
- Enables Zed editor integration to support all available authentication options
- Add test case for QWEN_OAUTH authentication configuration
- Maintains backward compatibility with existing Google authentication methods

This allows Zed users to authenticate using:
- OpenAI API key (requires OPENAI_API_KEY environment variable)
- Qwen OAuth (2000 daily requests with OAuth2 flow)
- Existing Google authentication methods (unchanged)
2025-09-23 14:29:29 +08:00
Mingholy
014059e8a6 fix: output token limit for qwen (#664) 2025-09-23 14:28:59 +08:00
Mingholy
3579d6555a chore: bump version to 0.0.12 (#662) 2025-09-19 20:13:31 +08:00
Mingholy
9a56560eb4 fix: arrow keys on windows (#661) 2025-09-19 19:44:57 +08:00
Mingholy
da0863b943 fix: missing tool call chunks for openai logging (#657) 2025-09-19 15:19:30 +08:00
Mingholy
5f68a8b6b3 fix: switch system prompt to avoid malformed tool_calls (#650)
* fix: switch system prompt to avoid malformed tool_calls

* fix: circular dependency issue and configurable tool-call style

* fix: regExp issue
2025-09-18 21:10:03 +08:00
Mingholy
761833c915 Vision model support for Qwen-OAuth (#525)
* refactor: openaiContentGenerator

* refactor: optimize stream handling

* refactor: re-organize refactored files

* fix: unit test cases

* feat: `/model` command for switching to vision model

* fix: lint error

* feat: add image tokenizer to fit vlm context window

* fix: lint and type errors

* feat: add `visionModelPreview` to control default visibility of vision models

* fix: remove deprecated files

* fix: align supported image formats with bailian doc
2025-09-18 13:32:00 +08:00
Mingholy
56808ac210 fix: reset is_background (#644) 2025-09-18 13:27:09 +08:00
Peter Stewart
724c24933c Enable tool call type coersion (#477)
* feat: enable tool call type coercion

* fix: tests for type coercion

---------

Co-authored-by: Mingholy <mingholy.lmh@gmail.com>
2025-09-18 13:04:27 +08:00
pomelo
17cdce6298 Merge pull request #638 from QwenLM/fix/subagent-update
fix: subagent system improvements and UI fixes
2025-09-18 11:12:12 +08:00
tanzhenxin
de468f0525 fix: merge issue 2025-09-17 19:52:12 +08:00
tanzhenxin
50199288ec Merge branch 'main' into fix/subagent-update 2025-09-17 19:12:22 +08:00
tanzhenxin
8803b2eb76 feat: add system-reminder to help model use subagent 2025-09-17 18:56:30 +08:00
Mingholy
b99de25e38 Merge pull request #605 from QwenLM/chore/sync-gemini-cli-v0.3.4
Chore/sync gemini cli v0.3.4
2025-09-17 18:15:26 +08:00
tanzhenxin
e552bc9609 fix: terminal flicker when subagent is executing 2025-09-17 17:01:06 +08:00
tanzhenxin
5f90472a7d fix: duplicate subagents config if qwen-code runs in home dir 2025-09-17 11:32:52 +08:00
Mingholy
d3476a2d47 fix: lint errors 2025-09-16 20:19:44 +08:00
Mingholy
f599cda7d2 fix: replace branding keywords to avoid ambiguity 2025-09-16 19:57:33 +08:00
Mingholy
9df193ca42 Merge branch 'main' into chore/sync-gemini-cli-v0.3.4 2025-09-16 19:51:32 +08:00
tanzhenxin
19950e5b7c chore: update subagent docs 2025-09-16 16:03:35 +08:00
pomelo
6dcec540d6 Merge pull request #624 from QwenLM/chore-pomelo
feat: Enhance /init command with confirmation prompt
2025-09-16 15:32:30 +08:00
pomelo
0581344d48 Merge pull request #610 from QwenLM/feat/skip-loop-detection
Add `skipLoopDetection` Configuration Option
2025-09-16 15:31:51 +08:00
pomelo
da78a9ff94 Merge pull request #627 from QwenLM/fix/win-paste-multi-line
Fix: Windows Multi-line Paste Handling with Debounced Data Processing
2025-09-16 15:28:44 +08:00
tanzhenxin
8e2fc76c15 fix: Esc unable to cancel subagent dialog 2025-09-16 15:24:58 +08:00
tanzhenxin
35efa9f04a fix: try to fix test case failures on Windows 2025-09-16 14:59:03 +08:00
mingholy.lmh
484f8642c4 docs: improve docs to avoid ambiguity 2025-09-16 14:48:49 +08:00
tanzhenxin
c093bed38c fix: add test code again 2025-09-16 14:04:07 +08:00
tanzhenxin
b9fd4737c9 fix: add debounce timer for paste event 2025-09-16 11:25:33 +08:00
pomelo-nwu
8d17864959 chore: remove QWEN.md from repo and add to .gitignore
- Delete QWEN.md from repository as it should be generated locally
- Add QWEN.md to .gitignore to prevent accidental commits
- Context files should be created by developers using /init command locally
2025-09-16 11:06:46 +08:00
pomelo-nwu
7109914a86 feat: add confirmation prompt for /init command when context file exists
- Add confirmation dialog when QWEN.md already exists and has content
- Use React.createElement to maintain .ts file compatibility
- Allow users to choose whether to regenerate existing context file
2025-09-16 11:04:35 +08:00
pomelo-nwu
4721a6f324 fix: reduce exit cleanup timeout in slash command processor
Decreased the exit cleanup timeout from 1000ms to 100ms to speed up the shutdown process when using slash commands.

Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>
2025-09-16 10:46:41 +08:00
tanzhenxin
3ad533c50b fix: test fix again 2025-09-15 20:13:10 +08:00
tanzhenxin
6af52e74ab fix: test fix again 2025-09-15 16:23:10 +08:00
tanzhenxin
6dad6b7f31 fix: test fix 2025-09-15 16:03:51 +08:00
tanzhenxin
2253c7b263 chore: update documentation 2025-09-15 14:19:45 +08:00
tanzhenxin
386538521b feat: Add skipLoopDetection Configuration Option 2025-09-15 14:02:46 +08:00
158 changed files with 12555 additions and 9914 deletions

2
.gitignore vendored
View File

@@ -47,3 +47,5 @@ packages/vscode-ide-companion/*.vsix
logs/
# GHA credentials
gha-creds-*.json
QWEN.md

13
.vscode/launch.json vendored
View File

@@ -101,6 +101,13 @@
"env": {
"GEMINI_SANDBOX": "false"
}
},
{
"name": "Attach by Process ID",
"processId": "${command:PickProcess}",
"request": "attach",
"skipFiles": ["<node_internals>/**"],
"type": "node"
}
],
"inputs": [
@@ -115,6 +122,12 @@
"type": "promptString",
"description": "Enter your prompt for non-interactive mode",
"default": "Explain this code"
},
{
"id": "debugPort",
"type": "promptString",
"description": "Enter the debug port number (default: 9229)",
"default": "9229"
}
]
}

View File

@@ -1,5 +1,36 @@
# Changelog
## 0.0.13
- Added YOLO mode support for automatic vision model switching with CLI arguments and environment variables.
- Fixed ripgrep lazy loading to resolve VS Code IDE companion startup issues.
- Fixed authentication hang when selecting Qwen OAuth.
- Added OpenAI and Qwen OAuth authentication support to Zed ACP integration.
- Fixed output token limit for Qwen models.
- Fixed Markdown list display issues on Windows.
- Enhanced vision model instructions and documentation.
- Improved authentication method compatibility across different IDE integrations.
## 0.0.12
- Added vision model support for Qwen-OAuth authentication.
- Synced upstream `gemini-cli` to v0.3.4 with numerous improvements and bug fixes.
- Enhanced subagent functionality with system reminders and improved user experience.
- Added tool call type coercion for better compatibility.
- Fixed arrow key navigation issues on Windows.
- Fixed missing tool call chunks for OpenAI logging.
- Fixed system prompt issues to avoid malformed tool calls.
- Fixed terminal flicker when subagent is executing.
- Fixed duplicate subagents configuration when running in home directory.
- Fixed Esc key unable to cancel subagent dialog.
- Added confirmation prompt for `/init` command when context file exists.
- Added `skipLoopDetection` configuration option.
- Fixed `is_background` parameter reset issues.
- Enhanced Windows compatibility with multi-line paste handling.
- Improved subagent documentation and branding consistency.
- Fixed various linting errors and improved code quality.
- Miscellaneous improvements and bug fixes.
## 0.0.11
- Added subagents feature with file-based configuration system for specialized AI assistants.

193
QWEN.md
View File

@@ -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`).

View File

@@ -1,162 +0,0 @@
# Gemini CLI
[![Gemini CLI CI](https://github.com/google-gemini/gemini-cli/actions/workflows/ci.yml/badge.svg)](https://github.com/google-gemini/gemini-cli/actions/workflows/ci.yml)
![Gemini CLI Screenshot](./docs/assets/gemini-screenshot.png)
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).

View File

@@ -54,6 +54,7 @@ For detailed setup instructions, see [Authorization](#authorization).
- **Code Understanding & Editing** - Query and edit large codebases beyond traditional context window limits
- **Workflow Automation** - Automate operational tasks like handling pull requests and complex rebases
- **Enhanced Parser** - Adapted parser specifically optimized for Qwen-Coder models
- **Vision Model Support** - Automatically detect images in your input and seamlessly switch to vision-capable models for multimodal analysis
## Installation
@@ -121,6 +122,58 @@ Create or edit `.qwen/settings.json` in your home directory:
> 📝 **Note**: Session token limit applies to a single conversation, not cumulative API calls.
### Vision Model Configuration
Qwen Code includes intelligent vision model auto-switching that detects images in your input and can automatically switch to vision-capable models for multimodal analysis. **This feature is enabled by default** - when you include images in your queries, you'll see a dialog asking how you'd like to handle the vision model switch.
#### Skip the Switch Dialog (Optional)
If you don't want to see the interactive dialog each time, configure the default behavior in your `.qwen/settings.json`:
```json
{
"experimental": {
"vlmSwitchMode": "once"
}
}
```
**Available modes:**
- **`"once"`** - Switch to vision model for this query only, then revert
- **`"session"`** - Switch to vision model for the entire session
- **`"persist"`** - Continue with current model (no switching)
- **Not set** - Show interactive dialog each time (default)
#### Command Line Override
You can also set the behavior via command line:
```bash
# Switch once per query
qwen --vlm-switch-mode once
# Switch for entire session
qwen --vlm-switch-mode session
# Never switch automatically
qwen --vlm-switch-mode persist
```
#### Disable Vision Models (Optional)
To completely disable vision model support, add to your `.qwen/settings.json`:
```json
{
"experimental": {
"visionModelPreview": false
}
}
```
> 💡 **Tip**: In YOLO mode (`--yolo`), vision switching happens automatically without prompts when images are detected.
### Authorization
Choose your preferred authentication method based on your needs:

View File

@@ -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 isnt 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!

View File

@@ -124,6 +124,18 @@ Slash commands provide meta-level control over the CLI itself.
- **`/auth`**
- **Description:** Open a dialog that lets you change the authentication method.
- **`/approval-mode`**
- **Description:** Change the approval mode for tool usage.
- **Usage:** `/approval-mode [mode] [--session|--project|--user]`
- **Available Modes:**
- **`plan`**: Analyze only; do not modify files or execute commands
- **`default`**: Require approval for file edits or shell commands
- **`auto-edit`**: Automatically approve file edits
- **`yolo`**: Automatically approve all tools
- **Examples:**
- `/approval-mode plan --project` (persist plan mode for this project)
- `/approval-mode yolo --user` (persist YOLO mode for this user across projects)
- **`/about`**
- **Description:** Show version info. Please share this information when filing issues.

View File

@@ -346,6 +346,34 @@ 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
```
- **`approvalMode`** (string):
- **Description:** Sets the default approval mode for tool usage. Accepted values are:
- `plan`: Analyze only, do not modify files or execute commands.
- `default`: Require approval before file edits or shell commands run.
- `auto-edit`: Automatically approve file edits.
- `yolo`: Automatically approve all tool calls.
- **Default:** `"default"`
- **Example:**
```json
"approvalMode": "plan"
```
### Example `settings.json`:
```json
@@ -373,6 +401,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": {
@@ -468,12 +498,13 @@ Arguments passed directly when running the CLI can override other configurations
- **`--yolo`**:
- Enables YOLO mode, which automatically approves all tool calls.
- **`--approval-mode <mode>`**:
- Sets the approval mode for tool calls. Available modes:
- `default`: Prompt for approval on each tool call (default behavior)
- `auto_edit`: Automatically approve edit tools (edit, write_file) while prompting for others
- `yolo`: Automatically approve all tool calls (equivalent to `--yolo`)
- Sets the approval mode for tool calls. Supported modes:
- `plan`: Analyze only—do not modify files or execute commands.
- `default`: Require approval for file edits or shell commands (default behavior).
- `auto-edit`: Automatically approve edit tools (edit, write_file) while prompting for others.
- `yolo`: Automatically approve all tool calls (equivalent to `--yolo`).
- Cannot be used together with `--yolo`. Use `--approval-mode=yolo` instead of `--yolo` for the new unified approach.
- Example: `qwen --approval-mode auto_edit`
- Example: `qwen --approval-mode auto-edit`
- **`--allowed-tools <tool1,tool2,...>`**:
- A comma-separated list of tool names that will bypass the confirmation dialog.
- Example: `qwen --allowed-tools "ShellTool(git status)"`

View File

@@ -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.

View File

@@ -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). |

View File

@@ -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.

View File

@@ -4,16 +4,16 @@ This document lists the available keyboard shortcuts in Qwen Code.
## General
| Shortcut | Description |
| -------- | --------------------------------------------------------------------------------------------------------------------- |
| `Esc` | Close dialogs and suggestions. |
| `Ctrl+C` | Cancel the ongoing request and clear the input. Press twice to exit the application. |
| `Ctrl+D` | Exit the application if the input is empty. Press twice to confirm. |
| `Ctrl+L` | Clear the screen. |
| `Ctrl+O` | Toggle the display of the debug console. |
| `Ctrl+S` | Allows long responses to print fully, disabling truncation. Use your terminal's scrollback to view the entire output. |
| `Ctrl+T` | Toggle the display of tool descriptions. |
| `Ctrl+Y` | Toggle auto-approval (YOLO mode) for all tool calls. |
| Shortcut | Description |
| ----------- | --------------------------------------------------------------------------------------------------------------------- |
| `Esc` | Close dialogs and suggestions. |
| `Ctrl+C` | Cancel the ongoing request and clear the input. Press twice to exit the application. |
| `Ctrl+D` | Exit the application if the input is empty. Press twice to confirm. |
| `Ctrl+L` | Clear the screen. |
| `Ctrl+O` | Toggle the display of the debug console. |
| `Ctrl+S` | Allows long responses to print fully, disabling truncation. Use your terminal's scrollback to view the entire output. |
| `Ctrl+T` | Toggle the display of tool descriptions. |
| `Shift+Tab` | Cycle approval modes (`plan``default``auto-edit``yolo`). |
## Input Prompt

View File

@@ -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

View File

@@ -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`)
![How to run a release](assets/release_patch.png)
### 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.

View File

@@ -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

View File

@@ -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 |
| --------- | -------------------------- | --------------------------------------------------------------------------------------------------- |

13
package-lock.json generated
View File

@@ -1,12 +1,12 @@
{
"name": "@qwen-code/qwen-code",
"version": "0.0.11",
"version": "0.0.14-nightly.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "@qwen-code/qwen-code",
"version": "0.0.11",
"version": "0.0.14-nightly.0",
"workspaces": [
"packages/*"
],
@@ -13454,7 +13454,7 @@
},
"packages/cli": {
"name": "@qwen-code/qwen-code",
"version": "0.0.11",
"version": "0.0.14-nightly.0",
"dependencies": {
"@google/genai": "1.9.0",
"@iarna/toml": "^2.2.5",
@@ -13662,7 +13662,7 @@
},
"packages/core": {
"name": "@qwen-code/qwen-code-core",
"version": "0.0.11",
"version": "0.0.14-nightly.0",
"dependencies": {
"@google/genai": "1.13.0",
"@lvce-editor/ripgrep": "^1.6.0",
@@ -13788,7 +13788,8 @@
},
"packages/test-utils": {
"name": "@qwen-code/qwen-code-test-utils",
"version": "0.0.11",
"version": "0.0.14-nightly.0",
"dev": true,
"license": "Apache-2.0",
"devDependencies": {
"typescript": "^5.3.3"
@@ -13799,7 +13800,7 @@
},
"packages/vscode-ide-companion": {
"name": "qwen-code-vscode-ide-companion",
"version": "0.0.11",
"version": "0.0.14-nightly.0",
"license": "LICENSE",
"dependencies": {
"@modelcontextprotocol/sdk": "^1.15.1",

View File

@@ -1,6 +1,6 @@
{
"name": "@qwen-code/qwen-code",
"version": "0.0.11",
"version": "0.0.14-nightly.0",
"engines": {
"node": ">=20.0.0"
},
@@ -13,7 +13,7 @@
"url": "git+https://github.com/QwenLM/qwen-code.git"
},
"config": {
"sandboxImageUri": "ghcr.io/qwenlm/qwen-code:0.0.11"
"sandboxImageUri": "ghcr.io/qwenlm/qwen-code:0.0.14-nightly.0"
},
"scripts": {
"start": "node scripts/start.js",

View File

@@ -1,6 +1,6 @@
{
"name": "@qwen-code/qwen-code",
"version": "0.0.11",
"version": "0.0.14-nightly.0",
"description": "Qwen Code",
"repository": {
"type": "git",
@@ -25,7 +25,7 @@
"dist"
],
"config": {
"sandboxImageUri": "ghcr.io/qwenlm/qwen-code:0.0.11"
"sandboxImageUri": "ghcr.io/qwenlm/qwen-code:0.0.14-nightly.0"
},
"dependencies": {
"@google/genai": "1.9.0",

View File

@@ -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)

View File

@@ -269,7 +269,7 @@ describe('Configuration Integration Tests', () => {
parseArguments = parseArgs;
});
it('should parse --approval-mode=auto_edit correctly through the full argument parsing flow', async () => {
it('should parse --approval-mode=auto-edit correctly through the full argument parsing flow', async () => {
const originalArgv = process.argv;
try {
@@ -277,7 +277,7 @@ describe('Configuration Integration Tests', () => {
'node',
'script.js',
'--approval-mode',
'auto_edit',
'auto-edit',
'-p',
'test',
];
@@ -285,7 +285,30 @@ describe('Configuration Integration Tests', () => {
const argv = await parseArguments({} as Settings);
// Verify that the argument was parsed correctly
expect(argv.approvalMode).toBe('auto_edit');
expect(argv.approvalMode).toBe('auto-edit');
expect(argv.prompt).toBe('test');
expect(argv.yolo).toBe(false);
} finally {
process.argv = originalArgv;
}
});
it('should parse --approval-mode=plan correctly through the full argument parsing flow', async () => {
const originalArgv = process.argv;
try {
process.argv = [
'node',
'script.js',
'--approval-mode',
'plan',
'-p',
'test',
];
const argv = await parseArguments({} as Settings);
expect(argv.approvalMode).toBe('plan');
expect(argv.prompt).toBe('test');
expect(argv.yolo).toBe(false);
} finally {

View File

@@ -262,9 +262,9 @@ describe('parseArguments', () => {
});
it('should allow --approval-mode without --yolo', async () => {
process.argv = ['node', 'script.js', '--approval-mode', 'auto_edit'];
process.argv = ['node', 'script.js', '--approval-mode', 'auto-edit'];
const argv = await parseArguments({} as Settings);
expect(argv.approvalMode).toBe('auto_edit');
expect(argv.approvalMode).toBe('auto-edit');
expect(argv.yolo).toBe(false);
});
@@ -1087,6 +1087,32 @@ describe('Approval mode tool exclusion logic', () => {
expect(excludedTools).toContain(WriteFileTool.Name);
});
it('should exclude all interactive tools in non-interactive mode with plan approval mode', async () => {
process.argv = [
'node',
'script.js',
'--approval-mode',
'plan',
'-p',
'test',
];
const argv = await parseArguments({} as Settings);
const settings: Settings = {};
const extensions: Extension[] = [];
const config = await loadCliConfig(
settings,
extensions,
'test-session',
argv,
);
const excludedTools = config.getExcludeTools();
expect(excludedTools).toContain(ShellTool.Name);
expect(excludedTools).toContain(EditTool.Name);
expect(excludedTools).toContain(WriteFileTool.Name);
});
it('should exclude all interactive tools in non-interactive mode with explicit default approval mode', async () => {
process.argv = [
'node',
@@ -1113,12 +1139,12 @@ describe('Approval mode tool exclusion logic', () => {
expect(excludedTools).toContain(WriteFileTool.Name);
});
it('should exclude only shell tools in non-interactive mode with auto_edit approval mode', async () => {
it('should exclude only shell tools in non-interactive mode with auto-edit approval mode', async () => {
process.argv = [
'node',
'script.js',
'--approval-mode',
'auto_edit',
'auto-edit',
'-p',
'test',
];
@@ -1189,8 +1215,9 @@ describe('Approval mode tool exclusion logic', () => {
const testCases = [
{ args: ['node', 'script.js'] }, // default
{ args: ['node', 'script.js', '--approval-mode', 'plan'] },
{ args: ['node', 'script.js', '--approval-mode', 'default'] },
{ args: ['node', 'script.js', '--approval-mode', 'auto_edit'] },
{ args: ['node', 'script.js', '--approval-mode', 'auto-edit'] },
{ args: ['node', 'script.js', '--approval-mode', 'yolo'] },
{ args: ['node', 'script.js', '--yolo'] },
];
@@ -1215,12 +1242,12 @@ describe('Approval mode tool exclusion logic', () => {
}
});
it('should merge approval mode exclusions with settings exclusions in auto_edit mode', async () => {
it('should merge approval mode exclusions with settings exclusions in auto-edit mode', async () => {
process.argv = [
'node',
'script.js',
'--approval-mode',
'auto_edit',
'auto-edit',
'-p',
'test',
];
@@ -1238,8 +1265,8 @@ describe('Approval mode tool exclusion logic', () => {
const excludedTools = config.getExcludeTools();
expect(excludedTools).toContain('custom_tool'); // From settings
expect(excludedTools).toContain(ShellTool.Name); // From approval mode
expect(excludedTools).not.toContain(EditTool.Name); // Should be allowed in auto_edit
expect(excludedTools).not.toContain(WriteFileTool.Name); // Should be allowed in auto_edit
expect(excludedTools).not.toContain(EditTool.Name); // Should be allowed in auto-edit
expect(excludedTools).not.toContain(WriteFileTool.Name); // Should be allowed in auto-edit
});
it('should throw an error for invalid approval mode values in loadCliConfig', async () => {
@@ -1262,7 +1289,7 @@ describe('Approval mode tool exclusion logic', () => {
invalidArgv as CliArgs,
),
).rejects.toThrow(
'Invalid approval mode: invalid_mode. Valid values are: yolo, auto_edit, default',
'Invalid approval mode: invalid_mode. Valid values are: plan, default, auto-edit, yolo',
);
});
});
@@ -1514,7 +1541,7 @@ describe('loadCliConfig model selection', () => {
argv,
);
expect(config.getModel()).toBe('qwen3-coder-plus');
expect(config.getModel()).toBe('coder-model');
});
it('always prefers model from argvs', async () => {
@@ -1929,6 +1956,13 @@ describe('loadCliConfig approval mode', () => {
expect(config.getApprovalMode()).toBe(ServerConfig.ApprovalMode.DEFAULT);
});
it('should set PLAN approval mode when --approval-mode=plan', async () => {
process.argv = ['node', 'script.js', '--approval-mode', 'plan'];
const argv = await parseArguments({} as Settings);
const config = await loadCliConfig({}, [], 'test-session', argv);
expect(config.getApprovalMode()).toBe(ServerConfig.ApprovalMode.PLAN);
});
it('should set YOLO approval mode when --yolo flag is used', async () => {
process.argv = ['node', 'script.js', '--yolo'];
const argv = await parseArguments({} as Settings);
@@ -1950,8 +1984,8 @@ describe('loadCliConfig approval mode', () => {
expect(config.getApprovalMode()).toBe(ServerConfig.ApprovalMode.DEFAULT);
});
it('should set AUTO_EDIT approval mode when --approval-mode=auto_edit', async () => {
process.argv = ['node', 'script.js', '--approval-mode', 'auto_edit'];
it('should set AUTO_EDIT approval mode when --approval-mode=auto-edit', async () => {
process.argv = ['node', 'script.js', '--approval-mode', 'auto-edit'];
const argv = await parseArguments({} as Settings);
const config = await loadCliConfig({}, [], 'test-session', argv);
expect(config.getApprovalMode()).toBe(ServerConfig.ApprovalMode.AUTO_EDIT);
@@ -1964,6 +1998,33 @@ describe('loadCliConfig approval mode', () => {
expect(config.getApprovalMode()).toBe(ServerConfig.ApprovalMode.YOLO);
});
it('should use approval mode from settings when CLI flags are not provided', async () => {
process.argv = ['node', 'script.js'];
const argv = await parseArguments({} as Settings);
const settings: Settings = { approvalMode: 'plan' };
const config = await loadCliConfig(settings, [], 'test-session', argv);
expect(config.getApprovalMode()).toBe(ServerConfig.ApprovalMode.PLAN);
});
it('should normalize approval mode values from settings', async () => {
process.argv = ['node', 'script.js'];
const argv = await parseArguments({} as Settings);
const settings: Settings = { approvalMode: 'AutoEdit' };
const config = await loadCliConfig(settings, [], 'test-session', argv);
expect(config.getApprovalMode()).toBe(ServerConfig.ApprovalMode.AUTO_EDIT);
});
it('should throw when approval mode in settings is invalid', async () => {
process.argv = ['node', 'script.js'];
const argv = await parseArguments({} as Settings);
const settings: Settings = { approvalMode: 'invalid_mode' };
await expect(
loadCliConfig(settings, [], 'test-session', argv),
).rejects.toThrow(
'Invalid approval mode: invalid_mode. Valid values are: plan, default, auto-edit, yolo',
);
});
it('should prioritize --approval-mode over --yolo when both would be valid (but validation prevents this)', async () => {
// Note: This test documents the intended behavior, but in practice the validation
// prevents both flags from being used together
@@ -1995,8 +2056,8 @@ describe('loadCliConfig approval mode', () => {
expect(config.getApprovalMode()).toBe(ServerConfig.ApprovalMode.DEFAULT);
});
it('should override --approval-mode=auto_edit to DEFAULT', async () => {
process.argv = ['node', 'script.js', '--approval-mode', 'auto_edit'];
it('should override --approval-mode=auto-edit to DEFAULT', async () => {
process.argv = ['node', 'script.js', '--approval-mode', 'auto-edit'];
const argv = await parseArguments({} as Settings);
const config = await loadCliConfig({}, [], 'test-session', argv);
expect(config.getApprovalMode()).toBe(ServerConfig.ApprovalMode.DEFAULT);
@@ -2015,6 +2076,13 @@ describe('loadCliConfig approval mode', () => {
const config = await loadCliConfig({}, [], 'test-session', argv);
expect(config.getApprovalMode()).toBe(ServerConfig.ApprovalMode.DEFAULT);
});
it('should allow PLAN approval mode in untrusted folders', async () => {
process.argv = ['node', 'script.js', '--approval-mode', 'plan'];
const argv = await parseArguments({} as Settings);
const config = await loadCliConfig({}, [], 'test-session', argv);
expect(config.getApprovalMode()).toBe(ServerConfig.ApprovalMode.PLAN);
});
});
});

View File

@@ -52,6 +52,39 @@ const logger = {
error: (...args: any[]) => console.error('[ERROR]', ...args),
};
const VALID_APPROVAL_MODE_VALUES = [
'plan',
'default',
'auto-edit',
'yolo',
] as const;
function formatApprovalModeError(value: string): Error {
return new Error(
`Invalid approval mode: ${value}. Valid values are: ${VALID_APPROVAL_MODE_VALUES.join(
', ',
)}`,
);
}
function parseApprovalModeValue(value: string): ApprovalMode {
const normalized = value.trim().toLowerCase();
switch (normalized) {
case 'plan':
return ApprovalMode.PLAN;
case 'default':
return ApprovalMode.DEFAULT;
case 'yolo':
return ApprovalMode.YOLO;
case 'auto_edit':
case 'autoedit':
case 'auto-edit':
return ApprovalMode.AUTO_EDIT;
default:
throw formatApprovalModeError(value);
}
}
export interface CliArgs {
model: string | undefined;
sandbox: boolean | string | undefined;
@@ -82,6 +115,7 @@ export interface CliArgs {
includeDirectories: string[] | undefined;
tavilyApiKey: string | undefined;
screenReader: boolean | undefined;
vlmSwitchMode: string | undefined;
}
export async function parseArguments(settings: Settings): Promise<CliArgs> {
@@ -146,9 +180,9 @@ export async function parseArguments(settings: Settings): Promise<CliArgs> {
})
.option('approval-mode', {
type: 'string',
choices: ['default', 'auto_edit', 'yolo'],
choices: ['plan', 'default', 'auto-edit', 'yolo'],
description:
'Set the approval mode: default (prompt for approval), auto_edit (auto-approve edit tools), yolo (auto-approve all tools)',
'Set the approval mode: plan (plan only), default (prompt for approval), auto-edit (auto-approve edit tools), yolo (auto-approve all tools)',
})
.option('telemetry', {
type: 'boolean',
@@ -249,6 +283,13 @@ export async function parseArguments(settings: Settings): Promise<CliArgs> {
description: 'Enable screen reader mode for accessibility.',
default: false,
})
.option('vlm-switch-mode', {
type: 'string',
choices: ['once', 'session', 'persist'],
description:
'Default behavior when images are detected in input. Values: once (one-time switch), session (switch for entire session), persist (continue with current model). Overrides settings files.',
default: process.env['VLM_SWITCH_MODE'],
})
.check((argv) => {
if (argv.prompt && argv['promptInteractive']) {
throw new Error(
@@ -430,30 +471,21 @@ export async function loadCliConfig(
// Determine approval mode with backward compatibility
let approvalMode: ApprovalMode;
if (argv.approvalMode) {
// New --approval-mode flag takes precedence
switch (argv.approvalMode) {
case 'yolo':
approvalMode = ApprovalMode.YOLO;
break;
case 'auto_edit':
approvalMode = ApprovalMode.AUTO_EDIT;
break;
case 'default':
approvalMode = ApprovalMode.DEFAULT;
break;
default:
throw new Error(
`Invalid approval mode: ${argv.approvalMode}. Valid values are: yolo, auto_edit, default`,
);
}
approvalMode = parseApprovalModeValue(argv.approvalMode);
} else if (argv.yolo) {
approvalMode = ApprovalMode.YOLO;
} else if (settings.approvalMode) {
approvalMode = parseApprovalModeValue(settings.approvalMode);
} else {
// Fallback to legacy --yolo flag behavior
approvalMode =
argv.yolo || false ? ApprovalMode.YOLO : ApprovalMode.DEFAULT;
approvalMode = ApprovalMode.DEFAULT;
}
// Force approval mode to default if the folder is not trusted.
if (!trustedFolder && approvalMode !== ApprovalMode.DEFAULT) {
if (
!trustedFolder &&
approvalMode !== ApprovalMode.DEFAULT &&
approvalMode !== ApprovalMode.PLAN
) {
logger.warn(
`Approval mode overridden to "default" because the current folder is not trusted.`,
);
@@ -466,6 +498,7 @@ export async function loadCliConfig(
const extraExcludes: string[] = [];
if (!interactive && !argv.experimentalAcp) {
switch (approvalMode) {
case ApprovalMode.PLAN:
case ApprovalMode.DEFAULT:
// In default non-interactive mode, all tools that require approval are excluded.
extraExcludes.push(ShellTool.Name, EditTool.Name, WriteFileTool.Name);
@@ -524,6 +557,9 @@ export async function loadCliConfig(
argv.screenReader !== undefined
? argv.screenReader
: (settings.ui?.accessibility?.screenReader ?? false);
const vlmSwitchMode =
argv.vlmSwitchMode || settings.experimental?.vlmSwitchMode;
return new Config({
sessionId,
embeddingModel: DEFAULT_GEMINI_EMBEDDING_MODEL,
@@ -629,6 +665,8 @@ export async function loadCliConfig(
shouldUseNodePtyShell: settings.tools?.usePty,
skipNextSpeakerCheck: settings.model?.skipNextSpeakerCheck,
enablePromptCompletion: settings.general?.enablePromptCompletion ?? false,
skipLoopDetection: settings.skipLoopDetection ?? false,
vlmSwitchMode,
});
}

View File

@@ -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);

View File

@@ -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;

View File

@@ -69,7 +69,11 @@ const MOCK_WORKSPACE_SETTINGS_PATH = pathActual.join(
);
// A more flexible type for test data that allows arbitrary properties.
type TestSettings = Settings & { [key: string]: unknown };
type TestSettings = Settings & {
[key: string]: unknown;
nested?: { [key: string]: unknown };
nestedObj?: { [key: string]: unknown };
};
vi.mock('fs', async (importOriginal) => {
// Get all the functions from the real 'fs' module
@@ -137,6 +141,9 @@ describe('Settings Loading and Merging', () => {
advanced: {
excludedEnvVars: [],
},
experimental: {},
contentGenerator: {},
systemPromptMappings: {},
extensions: {
disabled: [],
workspacesWithMigrationNudge: [],
@@ -197,6 +204,9 @@ describe('Settings Loading and Merging', () => {
advanced: {
excludedEnvVars: [],
},
experimental: {},
contentGenerator: {},
systemPromptMappings: {},
extensions: {
disabled: [],
workspacesWithMigrationNudge: [],
@@ -260,6 +270,9 @@ describe('Settings Loading and Merging', () => {
advanced: {
excludedEnvVars: [],
},
experimental: {},
contentGenerator: {},
systemPromptMappings: {},
extensions: {
disabled: [],
workspacesWithMigrationNudge: [],
@@ -320,6 +333,9 @@ describe('Settings Loading and Merging', () => {
advanced: {
excludedEnvVars: [],
},
experimental: {},
contentGenerator: {},
systemPromptMappings: {},
extensions: {
disabled: [],
workspacesWithMigrationNudge: [],
@@ -385,6 +401,9 @@ describe('Settings Loading and Merging', () => {
advanced: {
excludedEnvVars: [],
},
experimental: {},
contentGenerator: {},
systemPromptMappings: {},
extensions: {
disabled: [],
workspacesWithMigrationNudge: [],
@@ -477,6 +496,9 @@ describe('Settings Loading and Merging', () => {
advanced: {
excludedEnvVars: [],
},
experimental: {},
contentGenerator: {},
systemPromptMappings: {},
extensions: {
disabled: [],
workspacesWithMigrationNudge: [],
@@ -562,6 +584,9 @@ describe('Settings Loading and Merging', () => {
advanced: {
excludedEnvVars: [],
},
experimental: {},
contentGenerator: {},
systemPromptMappings: {},
extensions: {
disabled: [],
workspacesWithMigrationNudge: [],
@@ -691,6 +716,9 @@ describe('Settings Loading and Merging', () => {
'/system/dir',
],
},
experimental: {},
contentGenerator: {},
systemPromptMappings: {},
extensions: {
disabled: [],
workspacesWithMigrationNudge: [],
@@ -1431,6 +1459,9 @@ describe('Settings Loading and Merging', () => {
advanced: {
excludedEnvVars: [],
},
experimental: {},
contentGenerator: {},
systemPromptMappings: {},
extensions: {
disabled: [],
workspacesWithMigrationNudge: [],
@@ -1516,7 +1547,11 @@ describe('Settings Loading and Merging', () => {
'workspace_endpoint_from_env/api',
);
expect(
(settings.workspace.settings as TestSettings)['nested']['value'],
(
(settings.workspace.settings as TestSettings).nested as {
[key: string]: unknown;
}
)['value'],
).toBe('workspace_endpoint_from_env');
expect((settings.merged as TestSettings)['endpoint']).toBe(
'workspace_endpoint_from_env/api',
@@ -1766,19 +1801,39 @@ describe('Settings Loading and Merging', () => {
).toBeUndefined();
expect(
(settings.user.settings as TestSettings)['nestedObj']['nestedNull'],
(
(settings.user.settings as TestSettings).nestedObj as {
[key: string]: unknown;
}
)['nestedNull'],
).toBeNull();
expect(
(settings.user.settings as TestSettings)['nestedObj']['nestedBool'],
(
(settings.user.settings as TestSettings).nestedObj as {
[key: string]: unknown;
}
)['nestedBool'],
).toBe(true);
expect(
(settings.user.settings as TestSettings)['nestedObj']['nestedNum'],
(
(settings.user.settings as TestSettings).nestedObj as {
[key: string]: unknown;
}
)['nestedNum'],
).toBe(0);
expect(
(settings.user.settings as TestSettings)['nestedObj']['nestedString'],
(
(settings.user.settings as TestSettings).nestedObj as {
[key: string]: unknown;
}
)['nestedString'],
).toBe('literal');
expect(
(settings.user.settings as TestSettings)['nestedObj']['anotherEnv'],
(
(settings.user.settings as TestSettings).nestedObj as {
[key: string]: unknown;
}
)['anotherEnv'],
).toBe('env_string_nested_value');
delete process.env['MY_ENV_STRING'];
@@ -1864,6 +1919,9 @@ describe('Settings Loading and Merging', () => {
advanced: {
excludedEnvVars: [],
},
experimental: {},
contentGenerator: {},
systemPromptMappings: {},
extensions: {
disabled: [],
workspacesWithMigrationNudge: [],
@@ -2336,14 +2394,14 @@ describe('Settings Loading and Merging', () => {
vimMode: false,
},
model: {
maxSessionTurns: 0,
maxSessionTurns: -1,
},
context: {
includeDirectories: [],
},
security: {
folderTrust: {
enabled: null,
enabled: false,
},
},
};
@@ -2352,9 +2410,9 @@ describe('Settings Loading and Merging', () => {
expect(v1Settings).toEqual({
vimMode: false,
maxSessionTurns: 0,
maxSessionTurns: -1,
includeDirectories: [],
folderTrust: null,
folderTrust: false,
});
});

View File

@@ -396,6 +396,24 @@ function mergeSettings(
]),
],
},
experimental: {
...(systemDefaults.experimental || {}),
...(user.experimental || {}),
...(safeWorkspaceWithoutFolderTrust.experimental || {}),
...(system.experimental || {}),
},
contentGenerator: {
...(systemDefaults.contentGenerator || {}),
...(user.contentGenerator || {}),
...(safeWorkspaceWithoutFolderTrust.contentGenerator || {}),
...(system.contentGenerator || {}),
},
systemPromptMappings: {
...(systemDefaults.systemPromptMappings || {}),
...(user.systemPromptMappings || {}),
...(safeWorkspaceWithoutFolderTrust.systemPromptMappings || {}),
...(system.systemPromptMappings || {}),
},
extensions: {
...(systemDefaults.extensions || {}),
...(user.extensions || {}),

View File

@@ -741,6 +741,26 @@ export const SETTINGS_SCHEMA = {
description: 'Enable extension management features.',
showInDialog: false,
},
visionModelPreview: {
type: 'boolean',
label: 'Vision Model Preview',
category: 'Experimental',
requiresRestart: false,
default: true,
description:
'Enable vision model support and auto-switching functionality. When disabled, vision models like qwen-vl-max-latest will be hidden and auto-switching will not occur.',
showInDialog: true,
},
vlmSwitchMode: {
type: 'string',
label: 'VLM Switch Mode',
category: 'Experimental',
requiresRestart: false,
default: undefined as string | undefined,
description:
'Default behavior when images are detected in input. Values: once (one-time switch), session (switch for entire session), persist (continue with current model). If not set, user will be prompted each time. This is a temporary experimental feature.',
showInDialog: false,
},
},
},
@@ -863,6 +883,25 @@ 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,
},
approvalMode: {
type: 'string',
label: 'Default Approval Mode',
category: 'General',
requiresRestart: false,
default: 'default',
description:
'Default approval mode for tool usage. Valid values: plan, default, auto-edit, yolo.',
showInDialog: true,
},
enableWelcomeBack: {
type: 'boolean',
label: 'Enable Welcome Back',

View File

@@ -15,6 +15,14 @@ vi.mock('../ui/commands/aboutCommand.js', async () => {
};
});
vi.mock('../ui/commands/approvalModeCommand.js', () => ({
approvalModeCommand: {
name: 'approval-mode',
description: 'Approval mode command',
kind: 'built-in',
},
}));
vi.mock('../ui/commands/ideCommand.js', () => ({ ideCommand: vi.fn() }));
vi.mock('../ui/commands/restoreCommand.js', () => ({
restoreCommand: vi.fn(),
@@ -56,6 +64,13 @@ vi.mock('../ui/commands/mcpCommand.js', () => ({
kind: 'BUILT_IN',
},
}));
vi.mock('../ui/commands/modelCommand.js', () => ({
modelCommand: {
name: 'model',
description: 'Model command',
kind: 'BUILT_IN',
},
}));
describe('BuiltinCommandLoader', () => {
let mockConfig: Config;
@@ -121,10 +136,17 @@ describe('BuiltinCommandLoader', () => {
expect(aboutCmd).toBeDefined();
expect(aboutCmd?.kind).toBe(CommandKind.BUILT_IN);
const approvalModeCmd = commands.find((c) => c.name === 'approval-mode');
expect(approvalModeCmd).toBeDefined();
expect(approvalModeCmd?.kind).toBe(CommandKind.BUILT_IN);
const ideCmd = commands.find((c) => c.name === 'ide');
expect(ideCmd).toBeDefined();
const mcpCmd = commands.find((c) => c.name === 'mcp');
expect(mcpCmd).toBeDefined();
const modelCmd = commands.find((c) => c.name === 'model');
expect(modelCmd).toBeDefined();
});
});

View File

@@ -8,6 +8,8 @@ import type { ICommandLoader } from './types.js';
import type { SlashCommand } from '../ui/commands/types.js';
import type { Config } from '@qwen-code/qwen-code-core';
import { aboutCommand } from '../ui/commands/aboutCommand.js';
import { agentsCommand } from '../ui/commands/agentsCommand.js';
import { approvalModeCommand } from '../ui/commands/approvalModeCommand.js';
import { authCommand } from '../ui/commands/authCommand.js';
import { bugCommand } from '../ui/commands/bugCommand.js';
import { chatCommand } from '../ui/commands/chatCommand.js';
@@ -24,18 +26,18 @@ import { ideCommand } from '../ui/commands/ideCommand.js';
import { initCommand } from '../ui/commands/initCommand.js';
import { mcpCommand } from '../ui/commands/mcpCommand.js';
import { memoryCommand } from '../ui/commands/memoryCommand.js';
import { modelCommand } from '../ui/commands/modelCommand.js';
import { privacyCommand } from '../ui/commands/privacyCommand.js';
import { quitCommand, quitConfirmCommand } from '../ui/commands/quitCommand.js';
import { restoreCommand } from '../ui/commands/restoreCommand.js';
import { settingsCommand } from '../ui/commands/settingsCommand.js';
import { statsCommand } from '../ui/commands/statsCommand.js';
import { summaryCommand } from '../ui/commands/summaryCommand.js';
import { terminalSetupCommand } from '../ui/commands/terminalSetupCommand.js';
import { themeCommand } from '../ui/commands/themeCommand.js';
import { toolsCommand } from '../ui/commands/toolsCommand.js';
import { settingsCommand } from '../ui/commands/settingsCommand.js';
import { vimCommand } from '../ui/commands/vimCommand.js';
import { setupGithubCommand } from '../ui/commands/setupGithubCommand.js';
import { terminalSetupCommand } from '../ui/commands/terminalSetupCommand.js';
import { agentsCommand } from '../ui/commands/agentsCommand.js';
/**
* Loads the core, hard-coded slash commands that are an integral part
@@ -55,6 +57,7 @@ export class BuiltinCommandLoader implements ICommandLoader {
const allDefinitions: Array<SlashCommand | null> = [
aboutCommand,
agentsCommand,
approvalModeCommand,
authCommand,
bugCommand,
chatCommand,
@@ -71,6 +74,7 @@ export class BuiltinCommandLoader implements ICommandLoader {
initCommand,
mcpCommand,
memoryCommand,
modelCommand,
privacyCommand,
quitCommand,
quitConfirmCommand,

View File

@@ -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',
}),

View File

@@ -35,7 +35,10 @@ export const createMockCommandContext = (
},
services: {
config: null,
settings: { merged: {} } as LoadedSettings,
settings: {
merged: {},
setValue: vi.fn(),
} as unknown as LoadedSettings,
git: undefined as GitService | undefined,
logger: {
log: vi.fn(),

View File

@@ -53,6 +53,17 @@ import { FolderTrustDialog } from './components/FolderTrustDialog.js';
import { ShellConfirmationDialog } from './components/ShellConfirmationDialog.js';
import { QuitConfirmationDialog } from './components/QuitConfirmationDialog.js';
import { RadioButtonSelect } from './components/shared/RadioButtonSelect.js';
import { ModelSelectionDialog } from './components/ModelSelectionDialog.js';
import {
ModelSwitchDialog,
type VisionSwitchOutcome,
} from './components/ModelSwitchDialog.js';
import {
getOpenAIAvailableModelFromEnv,
getFilteredQwenModels,
type AvailableModel,
} from './models/availableModels.js';
import { processVisionSwitchOutcome } from './hooks/useVisionAutoSwitch.js';
import {
AgentCreationWizard,
AgentsManagerDialog,
@@ -142,9 +153,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
@@ -246,6 +259,20 @@ const App = ({ config, settings, startupWarnings = [], version }: AppProps) => {
onWorkspaceMigrationDialogClose,
} = useWorkspaceMigration(settings);
// Model selection dialog states
const [isModelSelectionDialogOpen, setIsModelSelectionDialogOpen] =
useState(false);
const [isVisionSwitchDialogOpen, setIsVisionSwitchDialogOpen] =
useState(false);
const [visionSwitchResolver, setVisionSwitchResolver] = useState<{
resolve: (result: {
modelOverride?: string;
persistSessionModel?: string;
showGuidance?: boolean;
}) => void;
reject: () => void;
} | null>(null);
useEffect(() => {
const unsubscribe = ideContext.subscribeToIdeContext(setIdeContextState);
// Set the initial value
@@ -539,7 +566,9 @@ const App = ({ config, settings, startupWarnings = [], version }: AppProps) => {
}
// Switch model for future use but return false to stop current retry
config.setModel(fallbackModel);
config.setModel(fallbackModel).catch((error) => {
console.error('Failed to switch to fallback model:', error);
});
config.setFallbackMode(true);
logFlashFallback(
config,
@@ -588,6 +617,86 @@ const App = ({ config, settings, startupWarnings = [], version }: AppProps) => {
openAuthDialog();
}, [openAuthDialog, setAuthError]);
// Vision switch handler for auto-switch functionality
const handleVisionSwitchRequired = useCallback(
async (_query: unknown) =>
new Promise<{
modelOverride?: string;
persistSessionModel?: string;
showGuidance?: boolean;
}>((resolve, reject) => {
setVisionSwitchResolver({ resolve, reject });
setIsVisionSwitchDialogOpen(true);
}),
[],
);
const handleVisionSwitchSelect = useCallback(
(outcome: VisionSwitchOutcome) => {
setIsVisionSwitchDialogOpen(false);
if (visionSwitchResolver) {
const result = processVisionSwitchOutcome(outcome);
visionSwitchResolver.resolve(result);
setVisionSwitchResolver(null);
}
},
[visionSwitchResolver],
);
const handleModelSelectionOpen = useCallback(() => {
setIsModelSelectionDialogOpen(true);
}, []);
const handleModelSelectionClose = useCallback(() => {
setIsModelSelectionDialogOpen(false);
}, []);
const handleModelSelect = useCallback(
async (modelId: string) => {
try {
await config.setModel(modelId);
setCurrentModel(modelId);
setIsModelSelectionDialogOpen(false);
addItem(
{
type: MessageType.INFO,
text: `Switched model to \`${modelId}\` for this session.`,
},
Date.now(),
);
} catch (error) {
console.error('Failed to switch model:', error);
addItem(
{
type: MessageType.ERROR,
text: `Failed to switch to model \`${modelId}\`. Please try again.`,
},
Date.now(),
);
}
},
[config, setCurrentModel, addItem],
);
const getAvailableModelsForCurrentAuth = useCallback((): AvailableModel[] => {
const contentGeneratorConfig = config.getContentGeneratorConfig();
if (!contentGeneratorConfig) return [];
const visionModelPreviewEnabled =
settings.merged.experimental?.visionModelPreview ?? true;
switch (contentGeneratorConfig.authType) {
case AuthType.QWEN_OAUTH:
return getFilteredQwenModels(visionModelPreviewEnabled);
case AuthType.USE_OPENAI: {
const openAIModel = getOpenAIAvailableModelFromEnv();
return openAIModel ? [openAIModel] : [];
}
default:
return [];
}
}, [config, settings.merged.experimental?.visionModelPreview]);
// Core hooks and processors
const {
vimEnabled: vimModeEnabled,
@@ -618,6 +727,7 @@ const App = ({ config, settings, startupWarnings = [], version }: AppProps) => {
setQuittingMessages,
openPrivacyNotice,
openSettingsDialog,
handleModelSelectionOpen,
openSubagentCreateDialog,
openAgentsManagerDialog,
toggleVimEnabled,
@@ -662,10 +772,18 @@ const App = ({ config, settings, startupWarnings = [], version }: AppProps) => {
setModelSwitchedFromQuotaError,
refreshStatic,
() => cancelHandlerRef.current(),
settings.merged.experimental?.visionModelPreview ?? true,
handleVisionSwitchRequired,
);
const pendingHistoryItems = useMemo(
() => [...pendingSlashCommandHistoryItems, ...pendingGeminiHistoryItems],
() =>
[...pendingSlashCommandHistoryItems, ...pendingGeminiHistoryItems].map(
(item, index) => ({
...item,
id: index,
}),
),
[pendingSlashCommandHistoryItems, pendingGeminiHistoryItems],
);
@@ -1026,6 +1144,8 @@ const App = ({ config, settings, startupWarnings = [], version }: AppProps) => {
!isAuthDialogOpen &&
!isThemeDialogOpen &&
!isEditorDialogOpen &&
!isModelSelectionDialogOpen &&
!isVisionSwitchDialogOpen &&
!isSubagentCreateDialogOpen &&
!showPrivacyNotice &&
!showWelcomeBackDialog &&
@@ -1047,6 +1167,8 @@ const App = ({ config, settings, startupWarnings = [], version }: AppProps) => {
showWelcomeBackDialog,
welcomeBackChoice,
geminiClient,
isModelSelectionDialogOpen,
isVisionSwitchDialogOpen,
]);
if (quittingMessages) {
@@ -1119,16 +1241,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}
@@ -1316,6 +1436,15 @@ const App = ({ config, settings, startupWarnings = [], version }: AppProps) => {
onExit={exitEditorDialog}
/>
</Box>
) : isModelSelectionDialogOpen ? (
<ModelSelectionDialog
availableModels={getAvailableModelsForCurrentAuth()}
currentModel={currentModel}
onSelect={handleModelSelect}
onCancel={handleModelSelectionClose}
/>
) : isVisionSwitchDialogOpen ? (
<ModelSwitchDialog onSelect={handleVisionSwitchSelect} />
) : showPrivacyNotice ? (
<PrivacyNotice
onExit={() => setShowPrivacyNotice(false)}

View File

@@ -0,0 +1,495 @@
/**
* @license
* Copyright 2025 Qwen
* SPDX-License-Identifier: Apache-2.0
*/
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import { approvalModeCommand } from './approvalModeCommand.js';
import {
type CommandContext,
CommandKind,
type MessageActionReturn,
} from './types.js';
import { createMockCommandContext } from '../../test-utils/mockCommandContext.js';
import { ApprovalMode } from '@qwen-code/qwen-code-core';
import { SettingScope, type LoadedSettings } from '../../config/settings.js';
describe('approvalModeCommand', () => {
let mockContext: CommandContext;
let setApprovalModeMock: ReturnType<typeof vi.fn>;
let setSettingsValueMock: ReturnType<typeof vi.fn>;
const originalEnv = { ...process.env };
const userSettingsPath = '/mock/user/settings.json';
const projectSettingsPath = '/mock/project/settings.json';
const userSettingsFile = { path: userSettingsPath, settings: {} };
const projectSettingsFile = { path: projectSettingsPath, settings: {} };
const getModeSubCommand = (mode: ApprovalMode) =>
approvalModeCommand.subCommands?.find((cmd) => cmd.name === mode);
const getScopeSubCommand = (
mode: ApprovalMode,
scope: '--session' | '--user' | '--project',
) => getModeSubCommand(mode)?.subCommands?.find((cmd) => cmd.name === scope);
beforeEach(() => {
setApprovalModeMock = vi.fn();
setSettingsValueMock = vi.fn();
mockContext = createMockCommandContext({
services: {
config: {
getApprovalMode: vi.fn().mockReturnValue(ApprovalMode.DEFAULT),
setApprovalMode: setApprovalModeMock,
},
settings: {
merged: {},
setValue: setSettingsValueMock,
forScope: vi
.fn()
.mockImplementation((scope: SettingScope) =>
scope === SettingScope.User
? userSettingsFile
: scope === SettingScope.Workspace
? projectSettingsFile
: { path: '', settings: {} },
),
} as unknown as LoadedSettings,
},
} as unknown as CommandContext);
});
afterEach(() => {
process.env = { ...originalEnv };
vi.clearAllMocks();
});
it('should have the correct command properties', () => {
expect(approvalModeCommand.name).toBe('approval-mode');
expect(approvalModeCommand.kind).toBe(CommandKind.BUILT_IN);
expect(approvalModeCommand.description).toBe(
'View or change the approval mode for tool usage',
);
});
it('should show current mode, options, and usage when no arguments provided', async () => {
if (!approvalModeCommand.action) {
throw new Error('approvalModeCommand must have an action.');
}
const result = (await approvalModeCommand.action(
mockContext,
'',
)) as MessageActionReturn;
expect(result.type).toBe('message');
expect(result.messageType).toBe('info');
const expectedMessage = [
'Current approval mode: default',
'',
'Available approval modes:',
' - plan: Plan mode - Analyze only, do not modify files or execute commands',
' - default: Default mode - Require approval for file edits or shell commands',
' - auto-edit: Auto-edit mode - Automatically approve file edits',
' - yolo: YOLO mode - Automatically approve all tools',
'',
'Usage: /approval-mode <mode> [--session|--user|--project]',
].join('\n');
expect(result.content).toBe(expectedMessage);
});
it('should display error when config is not available', async () => {
if (!approvalModeCommand.action) {
throw new Error('approvalModeCommand must have an action.');
}
const nullConfigContext = createMockCommandContext({
services: {
config: null,
},
} as unknown as CommandContext);
const result = (await approvalModeCommand.action(
nullConfigContext,
'',
)) as MessageActionReturn;
expect(result.type).toBe('message');
expect(result.messageType).toBe('error');
expect(result.content).toBe('Configuration not available.');
});
it('should change approval mode when valid mode is provided', async () => {
if (!approvalModeCommand.action) {
throw new Error('approvalModeCommand must have an action.');
}
const result = (await approvalModeCommand.action(
mockContext,
'plan',
)) as MessageActionReturn;
expect(setApprovalModeMock).toHaveBeenCalledWith(ApprovalMode.PLAN);
expect(setSettingsValueMock).not.toHaveBeenCalled();
expect(result.type).toBe('message');
expect(result.messageType).toBe('info');
expect(result.content).toBe('Approval mode changed to: plan');
});
it('should accept canonical auto-edit mode value', async () => {
if (!approvalModeCommand.action) {
throw new Error('approvalModeCommand must have an action.');
}
const result = (await approvalModeCommand.action(
mockContext,
'auto-edit',
)) as MessageActionReturn;
expect(setApprovalModeMock).toHaveBeenCalledWith(ApprovalMode.AUTO_EDIT);
expect(setSettingsValueMock).not.toHaveBeenCalled();
expect(result.type).toBe('message');
expect(result.messageType).toBe('info');
expect(result.content).toBe('Approval mode changed to: auto-edit');
});
it('should accept auto-edit alias for compatibility', async () => {
if (!approvalModeCommand.action) {
throw new Error('approvalModeCommand must have an action.');
}
const result = (await approvalModeCommand.action(
mockContext,
'auto-edit',
)) as MessageActionReturn;
expect(setApprovalModeMock).toHaveBeenCalledWith(ApprovalMode.AUTO_EDIT);
expect(setSettingsValueMock).not.toHaveBeenCalled();
expect(result.content).toBe('Approval mode changed to: auto-edit');
});
it('should display error when invalid mode is provided', async () => {
if (!approvalModeCommand.action) {
throw new Error('approvalModeCommand must have an action.');
}
const result = (await approvalModeCommand.action(
mockContext,
'invalid',
)) as MessageActionReturn;
expect(result.type).toBe('message');
expect(result.messageType).toBe('error');
expect(result.content).toContain('Invalid approval mode: invalid');
expect(result.content).toContain('Available approval modes:');
expect(result.content).toContain(
'Usage: /approval-mode <mode> [--session|--user|--project]',
);
});
it('should display error when setApprovalMode throws an error', async () => {
if (!approvalModeCommand.action) {
throw new Error('approvalModeCommand must have an action.');
}
const errorMessage = 'Failed to set approval mode';
mockContext.services.config!.setApprovalMode = vi
.fn()
.mockImplementation(() => {
throw new Error(errorMessage);
});
const result = (await approvalModeCommand.action(
mockContext,
'plan',
)) as MessageActionReturn;
expect(result.type).toBe('message');
expect(result.messageType).toBe('error');
expect(result.content).toBe(
`Failed to change approval mode: ${errorMessage}`,
);
});
it('should allow selecting auto-edit with user scope via nested subcommands', async () => {
if (!approvalModeCommand.subCommands) {
throw new Error('approvalModeCommand must have subCommands.');
}
const userSubCommand = getScopeSubCommand(ApprovalMode.AUTO_EDIT, '--user');
if (!userSubCommand?.action) {
throw new Error('--user scope subcommand must have an action.');
}
const result = (await userSubCommand.action(
mockContext,
'',
)) as MessageActionReturn;
expect(setApprovalModeMock).toHaveBeenCalledWith(ApprovalMode.AUTO_EDIT);
expect(setSettingsValueMock).toHaveBeenCalledWith(
SettingScope.User,
'approvalMode',
'auto-edit',
);
expect(result.content).toBe(
`Approval mode changed to: auto-edit (saved to user settings at ${userSettingsPath})`,
);
});
it('should allow selecting plan with project scope via nested subcommands', async () => {
if (!approvalModeCommand.subCommands) {
throw new Error('approvalModeCommand must have subCommands.');
}
const projectSubCommand = getScopeSubCommand(
ApprovalMode.PLAN,
'--project',
);
if (!projectSubCommand?.action) {
throw new Error('--project scope subcommand must have an action.');
}
const result = (await projectSubCommand.action(
mockContext,
'',
)) as MessageActionReturn;
expect(setApprovalModeMock).toHaveBeenCalledWith(ApprovalMode.PLAN);
expect(setSettingsValueMock).toHaveBeenCalledWith(
SettingScope.Workspace,
'approvalMode',
'plan',
);
expect(result.content).toBe(
`Approval mode changed to: plan (saved to project settings at ${projectSettingsPath})`,
);
});
it('should allow selecting plan with session scope via nested subcommands', async () => {
if (!approvalModeCommand.subCommands) {
throw new Error('approvalModeCommand must have subCommands.');
}
const sessionSubCommand = getScopeSubCommand(
ApprovalMode.PLAN,
'--session',
);
if (!sessionSubCommand?.action) {
throw new Error('--session scope subcommand must have an action.');
}
const result = (await sessionSubCommand.action(
mockContext,
'',
)) as MessageActionReturn;
expect(setApprovalModeMock).toHaveBeenCalledWith(ApprovalMode.PLAN);
expect(setSettingsValueMock).not.toHaveBeenCalled();
expect(result.content).toBe('Approval mode changed to: plan');
});
it('should allow providing a scope argument after selecting a mode subcommand', async () => {
if (!approvalModeCommand.subCommands) {
throw new Error('approvalModeCommand must have subCommands.');
}
const planSubCommand = getModeSubCommand(ApprovalMode.PLAN);
if (!planSubCommand?.action) {
throw new Error('plan subcommand must have an action.');
}
const result = (await planSubCommand.action(
mockContext,
'--user',
)) as MessageActionReturn;
expect(setApprovalModeMock).toHaveBeenCalledWith(ApprovalMode.PLAN);
expect(setSettingsValueMock).toHaveBeenCalledWith(
SettingScope.User,
'approvalMode',
'plan',
);
expect(result.content).toBe(
`Approval mode changed to: plan (saved to user settings at ${userSettingsPath})`,
);
});
it('should support --user plan pattern (scope first)', async () => {
if (!approvalModeCommand.action) {
throw new Error('approvalModeCommand must have an action.');
}
const result = (await approvalModeCommand.action(
mockContext,
'--user plan',
)) as MessageActionReturn;
expect(setApprovalModeMock).toHaveBeenCalledWith(ApprovalMode.PLAN);
expect(setSettingsValueMock).toHaveBeenCalledWith(
SettingScope.User,
'approvalMode',
'plan',
);
expect(result.content).toBe(
`Approval mode changed to: plan (saved to user settings at ${userSettingsPath})`,
);
});
it('should support plan --user pattern (mode first)', async () => {
if (!approvalModeCommand.action) {
throw new Error('approvalModeCommand must have an action.');
}
const result = (await approvalModeCommand.action(
mockContext,
'plan --user',
)) as MessageActionReturn;
expect(setApprovalModeMock).toHaveBeenCalledWith(ApprovalMode.PLAN);
expect(setSettingsValueMock).toHaveBeenCalledWith(
SettingScope.User,
'approvalMode',
'plan',
);
expect(result.content).toBe(
`Approval mode changed to: plan (saved to user settings at ${userSettingsPath})`,
);
});
it('should support --project auto-edit pattern', async () => {
if (!approvalModeCommand.action) {
throw new Error('approvalModeCommand must have an action.');
}
const result = (await approvalModeCommand.action(
mockContext,
'--project auto-edit',
)) as MessageActionReturn;
expect(setApprovalModeMock).toHaveBeenCalledWith(ApprovalMode.AUTO_EDIT);
expect(setSettingsValueMock).toHaveBeenCalledWith(
SettingScope.Workspace,
'approvalMode',
'auto-edit',
);
expect(result.content).toBe(
`Approval mode changed to: auto-edit (saved to project settings at ${projectSettingsPath})`,
);
});
it('should display error when only scope flag is provided', async () => {
if (!approvalModeCommand.action) {
throw new Error('approvalModeCommand must have an action.');
}
const result = (await approvalModeCommand.action(
mockContext,
'--user',
)) as MessageActionReturn;
expect(result.type).toBe('message');
expect(result.messageType).toBe('error');
expect(result.content).toContain('Missing approval mode');
expect(setApprovalModeMock).not.toHaveBeenCalled();
expect(setSettingsValueMock).not.toHaveBeenCalled();
});
it('should display error when multiple scope flags are provided', async () => {
if (!approvalModeCommand.action) {
throw new Error('approvalModeCommand must have an action.');
}
const result = (await approvalModeCommand.action(
mockContext,
'--user --project plan',
)) as MessageActionReturn;
expect(result.type).toBe('message');
expect(result.messageType).toBe('error');
expect(result.content).toContain('Multiple scope flags provided');
expect(setApprovalModeMock).not.toHaveBeenCalled();
expect(setSettingsValueMock).not.toHaveBeenCalled();
});
it('should surface a helpful error when scope subcommands receive extra arguments', async () => {
if (!approvalModeCommand.subCommands) {
throw new Error('approvalModeCommand must have subCommands.');
}
const userSubCommand = getScopeSubCommand(ApprovalMode.DEFAULT, '--user');
if (!userSubCommand?.action) {
throw new Error('--user scope subcommand must have an action.');
}
const result = (await userSubCommand.action(
mockContext,
'extra',
)) as MessageActionReturn;
expect(result.type).toBe('message');
expect(result.messageType).toBe('error');
expect(result.content).toBe(
'Scope subcommands do not accept additional arguments.',
);
expect(setApprovalModeMock).not.toHaveBeenCalled();
expect(setSettingsValueMock).not.toHaveBeenCalled();
});
it('should provide completion for approval modes', async () => {
if (!approvalModeCommand.completion) {
throw new Error('approvalModeCommand must have a completion function.');
}
// Test partial mode completion
const result = await approvalModeCommand.completion(mockContext, 'p');
expect(result).toEqual(['plan']);
const result2 = await approvalModeCommand.completion(mockContext, 'a');
expect(result2).toEqual(['auto-edit']);
// Test empty completion - should suggest available modes first
const result3 = await approvalModeCommand.completion(mockContext, '');
expect(result3).toEqual(['plan', 'default', 'auto-edit', 'yolo']);
const result4 = await approvalModeCommand.completion(mockContext, 'AUTO');
expect(result4).toEqual(['auto-edit']);
// Test mode first pattern: 'plan ' should suggest scope flags
const result5 = await approvalModeCommand.completion(mockContext, 'plan ');
expect(result5).toEqual(['--session', '--project', '--user']);
const result6 = await approvalModeCommand.completion(
mockContext,
'plan --u',
);
expect(result6).toEqual(['--user']);
// Test scope first pattern: '--user ' should suggest modes
const result7 = await approvalModeCommand.completion(
mockContext,
'--user ',
);
expect(result7).toEqual(['plan', 'default', 'auto-edit', 'yolo']);
const result8 = await approvalModeCommand.completion(
mockContext,
'--user p',
);
expect(result8).toEqual(['plan']);
// Test completed patterns should return empty
const result9 = await approvalModeCommand.completion(
mockContext,
'plan --user ',
);
expect(result9).toEqual([]);
const result10 = await approvalModeCommand.completion(
mockContext,
'--user plan ',
);
expect(result10).toEqual([]);
});
});

View File

@@ -0,0 +1,434 @@
/**
* @license
* Copyright 2025 Qwen
* SPDX-License-Identifier: Apache-2.0
*/
import type {
SlashCommand,
CommandContext,
MessageActionReturn,
} from './types.js';
import { CommandKind } from './types.js';
import { ApprovalMode, APPROVAL_MODES } from '@qwen-code/qwen-code-core';
import { SettingScope } from '../../config/settings.js';
const USAGE_MESSAGE =
'Usage: /approval-mode <mode> [--session|--user|--project]';
const normalizeInputMode = (value: string): string =>
value.trim().toLowerCase();
const tokenizeArgs = (args: string): string[] => {
const matches = args.match(/(?:"[^"]*"|'[^']*'|[^\s"']+)/g);
if (!matches) {
return [];
}
return matches.map((token) => {
if (
(token.startsWith('"') && token.endsWith('"')) ||
(token.startsWith("'") && token.endsWith("'"))
) {
return token.slice(1, -1);
}
return token;
});
};
const parseApprovalMode = (value: string | null): ApprovalMode | null => {
if (!value) {
return null;
}
const normalized = normalizeInputMode(value).replace(/_/g, '-');
const matchIndex = APPROVAL_MODES.findIndex(
(candidate) => candidate === normalized,
);
return matchIndex === -1 ? null : APPROVAL_MODES[matchIndex];
};
const formatModeDescription = (mode: ApprovalMode): string => {
switch (mode) {
case ApprovalMode.PLAN:
return 'Plan mode - Analyze only, do not modify files or execute commands';
case ApprovalMode.DEFAULT:
return 'Default mode - Require approval for file edits or shell commands';
case ApprovalMode.AUTO_EDIT:
return 'Auto-edit mode - Automatically approve file edits';
case ApprovalMode.YOLO:
return 'YOLO mode - Automatically approve all tools';
default:
return `${mode} mode`;
}
};
const parseApprovalArgs = (
args: string,
): {
mode: string | null;
scope: 'session' | 'user' | 'project';
error?: string;
} => {
const trimmedArgs = args.trim();
if (!trimmedArgs) {
return { mode: null, scope: 'session' };
}
const tokens = tokenizeArgs(trimmedArgs);
let mode: string | null = null;
let scope: 'session' | 'user' | 'project' = 'session';
let scopeFlag: string | null = null;
// Find scope flag and mode
for (const token of tokens) {
if (token === '--session' || token === '--user' || token === '--project') {
if (scopeFlag) {
return {
mode: null,
scope: 'session',
error: 'Multiple scope flags provided',
};
}
scopeFlag = token;
scope = token.substring(2) as 'session' | 'user' | 'project';
} else if (!mode) {
mode = token;
} else {
return {
mode: null,
scope: 'session',
error: 'Invalid arguments provided',
};
}
}
if (!mode) {
return { mode: null, scope: 'session', error: 'Missing approval mode' };
}
return { mode, scope };
};
const setApprovalModeWithScope = async (
context: CommandContext,
mode: ApprovalMode,
scope: 'session' | 'user' | 'project',
): Promise<MessageActionReturn> => {
const { services } = context;
const { config } = services;
if (!config) {
return {
type: 'message',
messageType: 'error',
content: 'Configuration not available.',
};
}
try {
// Always set the mode in the current session
config.setApprovalMode(mode);
// If scope is not session, also persist to settings
if (scope !== 'session') {
const { settings } = context.services;
if (!settings || typeof settings.setValue !== 'function') {
return {
type: 'message',
messageType: 'error',
content:
'Settings service is not available; unable to persist the approval mode.',
};
}
const settingScope =
scope === 'user' ? SettingScope.User : SettingScope.Workspace;
const scopeLabel = scope === 'user' ? 'user' : 'project';
let settingsPath: string | undefined;
try {
if (typeof settings.forScope === 'function') {
settingsPath = settings.forScope(settingScope)?.path;
}
} catch (_error) {
settingsPath = undefined;
}
try {
settings.setValue(settingScope, 'approvalMode', mode);
} catch (error) {
return {
type: 'message',
messageType: 'error',
content: `Failed to save approval mode: ${(error as Error).message}`,
};
}
const locationSuffix = settingsPath ? ` at ${settingsPath}` : '';
const scopeSuffix = ` (saved to ${scopeLabel} settings${locationSuffix})`;
return {
type: 'message',
messageType: 'info',
content: `Approval mode changed to: ${mode}${scopeSuffix}`,
};
}
return {
type: 'message',
messageType: 'info',
content: `Approval mode changed to: ${mode}`,
};
} catch (error) {
return {
type: 'message',
messageType: 'error',
content: `Failed to change approval mode: ${(error as Error).message}`,
};
}
};
export const approvalModeCommand: SlashCommand = {
name: 'approval-mode',
description: 'View or change the approval mode for tool usage',
kind: CommandKind.BUILT_IN,
action: async (
context: CommandContext,
args: string,
): Promise<MessageActionReturn> => {
const { config } = context.services;
if (!config) {
return {
type: 'message',
messageType: 'error',
content: 'Configuration not available.',
};
}
// If no arguments provided, show current mode and available options
if (!args || args.trim() === '') {
const currentMode =
typeof config.getApprovalMode === 'function'
? config.getApprovalMode()
: null;
const messageLines: string[] = [];
if (currentMode) {
messageLines.push(`Current approval mode: ${currentMode}`);
messageLines.push('');
}
messageLines.push('Available approval modes:');
for (const mode of APPROVAL_MODES) {
messageLines.push(` - ${mode}: ${formatModeDescription(mode)}`);
}
messageLines.push('');
messageLines.push(USAGE_MESSAGE);
return {
type: 'message',
messageType: 'info',
content: messageLines.join('\n'),
};
}
// Parse arguments flexibly
const parsed = parseApprovalArgs(args);
if (parsed.error) {
return {
type: 'message',
messageType: 'error',
content: `${parsed.error}. ${USAGE_MESSAGE}`,
};
}
if (!parsed.mode) {
return {
type: 'message',
messageType: 'info',
content: USAGE_MESSAGE,
};
}
const requestedMode = parseApprovalMode(parsed.mode);
if (!requestedMode) {
let message = `Invalid approval mode: ${parsed.mode}\n\n`;
message += 'Available approval modes:\n';
for (const mode of APPROVAL_MODES) {
message += ` - ${mode}: ${formatModeDescription(mode)}\n`;
}
message += `\n${USAGE_MESSAGE}`;
return {
type: 'message',
messageType: 'error',
content: message,
};
}
return setApprovalModeWithScope(context, requestedMode, parsed.scope);
},
subCommands: APPROVAL_MODES.map((mode) => ({
name: mode,
description: formatModeDescription(mode),
kind: CommandKind.BUILT_IN,
subCommands: [
{
name: '--session',
description: 'Apply to current session only (temporary)',
kind: CommandKind.BUILT_IN,
action: async (
context: CommandContext,
args: string,
): Promise<MessageActionReturn> => {
if (args.trim().length > 0) {
return {
type: 'message',
messageType: 'error',
content: 'Scope subcommands do not accept additional arguments.',
};
}
return setApprovalModeWithScope(context, mode, 'session');
},
},
{
name: '--project',
description: 'Persist for this project/workspace',
kind: CommandKind.BUILT_IN,
action: async (
context: CommandContext,
args: string,
): Promise<MessageActionReturn> => {
if (args.trim().length > 0) {
return {
type: 'message',
messageType: 'error',
content: 'Scope subcommands do not accept additional arguments.',
};
}
return setApprovalModeWithScope(context, mode, 'project');
},
},
{
name: '--user',
description: 'Persist for this user on this machine',
kind: CommandKind.BUILT_IN,
action: async (
context: CommandContext,
args: string,
): Promise<MessageActionReturn> => {
if (args.trim().length > 0) {
return {
type: 'message',
messageType: 'error',
content: 'Scope subcommands do not accept additional arguments.',
};
}
return setApprovalModeWithScope(context, mode, 'user');
},
},
],
action: async (
context: CommandContext,
args: string,
): Promise<MessageActionReturn> => {
if (args.trim().length > 0) {
// Allow users who type `/approval-mode plan --user` via the subcommand path
const parsed = parseApprovalArgs(`${mode} ${args}`);
if (parsed.error) {
return {
type: 'message',
messageType: 'error',
content: `${parsed.error}. ${USAGE_MESSAGE}`,
};
}
const normalizedMode = parseApprovalMode(parsed.mode);
if (!normalizedMode) {
return {
type: 'message',
messageType: 'error',
content: `Invalid approval mode: ${parsed.mode}. ${USAGE_MESSAGE}`,
};
}
return setApprovalModeWithScope(context, normalizedMode, parsed.scope);
}
return setApprovalModeWithScope(context, mode, 'session');
},
})),
completion: async (_context: CommandContext, partialArg: string) => {
const tokens = tokenizeArgs(partialArg);
const hasTrailingSpace = /\s$/.test(partialArg);
const currentSegment = hasTrailingSpace
? ''
: tokens.length > 0
? tokens[tokens.length - 1]
: '';
const normalizedCurrent = normalizeInputMode(currentSegment).replace(
/_/g,
'-',
);
const scopeValues = ['--session', '--project', '--user'];
const normalizeToken = (token: string) =>
normalizeInputMode(token).replace(/_/g, '-');
const normalizedTokens = tokens.map(normalizeToken);
if (tokens.length === 0) {
if (currentSegment.startsWith('-')) {
return scopeValues.filter((scope) => scope.startsWith(currentSegment));
}
return APPROVAL_MODES;
}
if (tokens.length === 1 && !hasTrailingSpace) {
const originalToken = tokens[0];
if (originalToken.startsWith('-')) {
return scopeValues.filter((scope) =>
scope.startsWith(normalizedCurrent),
);
}
return APPROVAL_MODES.filter((mode) =>
mode.startsWith(normalizedCurrent),
);
}
if (tokens.length === 1 && hasTrailingSpace) {
const normalizedFirst = normalizedTokens[0];
if (scopeValues.includes(tokens[0])) {
return APPROVAL_MODES;
}
if (APPROVAL_MODES.includes(normalizedFirst as ApprovalMode)) {
return scopeValues;
}
return APPROVAL_MODES;
}
if (tokens.length === 2 && !hasTrailingSpace) {
const normalizedFirst = normalizedTokens[0];
if (scopeValues.includes(tokens[0])) {
return APPROVAL_MODES.filter((mode) =>
mode.startsWith(normalizedCurrent),
);
}
if (APPROVAL_MODES.includes(normalizedFirst as ApprovalMode)) {
return scopeValues.filter((scope) =>
scope.startsWith(normalizedCurrent),
);
}
return [];
}
return [];
},
};

View File

@@ -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 () => {

View File

@@ -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

View File

@@ -0,0 +1,179 @@
/**
* @license
* Copyright 2025 Qwen
* SPDX-License-Identifier: Apache-2.0
*/
import { describe, it, expect, beforeEach, vi } from 'vitest';
import { modelCommand } from './modelCommand.js';
import { type CommandContext } from './types.js';
import { createMockCommandContext } from '../../test-utils/mockCommandContext.js';
import {
AuthType,
type ContentGeneratorConfig,
type Config,
} from '@qwen-code/qwen-code-core';
import * as availableModelsModule from '../models/availableModels.js';
// Mock the availableModels module
vi.mock('../models/availableModels.js', () => ({
AVAILABLE_MODELS_QWEN: [
{ id: 'qwen3-coder-plus', label: 'qwen3-coder-plus' },
{ id: 'qwen-vl-max-latest', label: 'qwen-vl-max', isVision: true },
],
getOpenAIAvailableModelFromEnv: vi.fn(),
}));
// Helper function to create a mock config
function createMockConfig(
contentGeneratorConfig: ContentGeneratorConfig | null,
): Partial<Config> {
return {
getContentGeneratorConfig: vi.fn().mockReturnValue(contentGeneratorConfig),
};
}
describe('modelCommand', () => {
let mockContext: CommandContext;
const mockGetOpenAIAvailableModelFromEnv = vi.mocked(
availableModelsModule.getOpenAIAvailableModelFromEnv,
);
beforeEach(() => {
mockContext = createMockCommandContext();
vi.clearAllMocks();
});
it('should have the correct name and description', () => {
expect(modelCommand.name).toBe('model');
expect(modelCommand.description).toBe('Switch the model for this session');
});
it('should return error when config is not available', async () => {
mockContext.services.config = null;
const result = await modelCommand.action!(mockContext, '');
expect(result).toEqual({
type: 'message',
messageType: 'error',
content: 'Configuration not available.',
});
});
it('should return error when content generator config is not available', async () => {
const mockConfig = createMockConfig(null);
mockContext.services.config = mockConfig as Config;
const result = await modelCommand.action!(mockContext, '');
expect(result).toEqual({
type: 'message',
messageType: 'error',
content: 'Content generator configuration not available.',
});
});
it('should return error when auth type is not available', async () => {
const mockConfig = createMockConfig({
model: 'test-model',
authType: undefined,
});
mockContext.services.config = mockConfig as Config;
const result = await modelCommand.action!(mockContext, '');
expect(result).toEqual({
type: 'message',
messageType: 'error',
content: 'Authentication type not available.',
});
});
it('should return dialog action for QWEN_OAUTH auth type', async () => {
const mockConfig = createMockConfig({
model: 'test-model',
authType: AuthType.QWEN_OAUTH,
});
mockContext.services.config = mockConfig as Config;
const result = await modelCommand.action!(mockContext, '');
expect(result).toEqual({
type: 'dialog',
dialog: 'model',
});
});
it('should return dialog action for USE_OPENAI auth type when model is available', async () => {
mockGetOpenAIAvailableModelFromEnv.mockReturnValue({
id: 'gpt-4',
label: 'gpt-4',
});
const mockConfig = createMockConfig({
model: 'test-model',
authType: AuthType.USE_OPENAI,
});
mockContext.services.config = mockConfig as Config;
const result = await modelCommand.action!(mockContext, '');
expect(result).toEqual({
type: 'dialog',
dialog: 'model',
});
});
it('should return error for USE_OPENAI auth type when no model is available', async () => {
mockGetOpenAIAvailableModelFromEnv.mockReturnValue(null);
const mockConfig = createMockConfig({
model: 'test-model',
authType: AuthType.USE_OPENAI,
});
mockContext.services.config = mockConfig as Config;
const result = await modelCommand.action!(mockContext, '');
expect(result).toEqual({
type: 'message',
messageType: 'error',
content:
'No models available for the current authentication type (openai).',
});
});
it('should return error for unsupported auth types', async () => {
const mockConfig = createMockConfig({
model: 'test-model',
authType: 'UNSUPPORTED_AUTH_TYPE' as AuthType,
});
mockContext.services.config = mockConfig as Config;
const result = await modelCommand.action!(mockContext, '');
expect(result).toEqual({
type: 'message',
messageType: 'error',
content:
'No models available for the current authentication type (UNSUPPORTED_AUTH_TYPE).',
});
});
it('should handle undefined auth type', async () => {
const mockConfig = createMockConfig({
model: 'test-model',
authType: undefined,
});
mockContext.services.config = mockConfig as Config;
const result = await modelCommand.action!(mockContext, '');
expect(result).toEqual({
type: 'message',
messageType: 'error',
content: 'Authentication type not available.',
});
});
});

View File

@@ -0,0 +1,88 @@
/**
* @license
* Copyright 2025 Qwen
* SPDX-License-Identifier: Apache-2.0
*/
import { AuthType } from '@qwen-code/qwen-code-core';
import type {
SlashCommand,
CommandContext,
OpenDialogActionReturn,
MessageActionReturn,
} from './types.js';
import { CommandKind } from './types.js';
import {
AVAILABLE_MODELS_QWEN,
getOpenAIAvailableModelFromEnv,
type AvailableModel,
} from '../models/availableModels.js';
function getAvailableModelsForAuthType(authType: AuthType): AvailableModel[] {
switch (authType) {
case AuthType.QWEN_OAUTH:
return AVAILABLE_MODELS_QWEN;
case AuthType.USE_OPENAI: {
const openAIModel = getOpenAIAvailableModelFromEnv();
return openAIModel ? [openAIModel] : [];
}
default:
// For other auth types, return empty array for now
// This can be expanded later according to the design doc
return [];
}
}
export const modelCommand: SlashCommand = {
name: 'model',
description: 'Switch the model for this session',
kind: CommandKind.BUILT_IN,
action: async (
context: CommandContext,
): Promise<OpenDialogActionReturn | MessageActionReturn> => {
const { services } = context;
const { config } = services;
if (!config) {
return {
type: 'message',
messageType: 'error',
content: 'Configuration not available.',
};
}
const contentGeneratorConfig = config.getContentGeneratorConfig();
if (!contentGeneratorConfig) {
return {
type: 'message',
messageType: 'error',
content: 'Content generator configuration not available.',
};
}
const authType = contentGeneratorConfig.authType;
if (!authType) {
return {
type: 'message',
messageType: 'error',
content: 'Authentication type not available.',
};
}
const availableModels = getAvailableModelsForAuthType(authType);
if (availableModels.length === 0) {
return {
type: 'message',
messageType: 'error',
content: `No models available for the current authentication type (${authType}).`,
};
}
// Trigger model selection dialog
return {
type: 'dialog',
dialog: 'model',
};
},
};

View File

@@ -116,6 +116,7 @@ export interface OpenDialogActionReturn {
| 'editor'
| 'privacy'
| 'settings'
| 'model'
| 'subagent_create'
| 'subagent_list';
}

View File

@@ -21,15 +21,20 @@ export const AutoAcceptIndicator: React.FC<AutoAcceptIndicatorProps> = ({
let subText = '';
switch (approvalMode) {
case ApprovalMode.PLAN:
textColor = Colors.AccentBlue;
textContent = 'plan mode';
subText = ' (shift + tab to cycle)';
break;
case ApprovalMode.AUTO_EDIT:
textColor = Colors.AccentGreen;
textContent = 'accepting edits';
subText = ' (shift + tab to toggle)';
textContent = 'auto-accept edits';
subText = ' (shift + tab to cycle)';
break;
case ApprovalMode.YOLO:
textColor = Colors.AccentRed;
textContent = 'YOLO mode';
subText = ' (ctrl + y to toggle)';
subText = ' (shift + tab to cycle)';
break;
case ApprovalMode.DEFAULT:
default:

View 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',
);
});

View File

@@ -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>

View File

@@ -133,12 +133,6 @@ export const Help: React.FC<Help> = ({ commands }) => (
</Text>{' '}
- Open input in external editor
</Text>
<Text color={Colors.Foreground}>
<Text bold color={Colors.AccentPurple}>
Ctrl+Y
</Text>{' '}
- Toggle YOLO mode
</Text>
<Text color={Colors.Foreground}>
<Text bold color={Colors.AccentPurple}>
Enter
@@ -155,7 +149,7 @@ export const Help: React.FC<Help> = ({ commands }) => (
<Text bold color={Colors.AccentPurple}>
Shift+Tab
</Text>{' '}
- Toggle auto-accepting edits
- Cycle approval modes
</Text>
<Text color={Colors.Foreground}>
<Text bold color={Colors.AccentPurple}>

View File

@@ -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);

View File

@@ -0,0 +1,246 @@
/**
* @license
* Copyright 2025 Qwen
* SPDX-License-Identifier: Apache-2.0
*/
import React from 'react';
import { render } from 'ink-testing-library';
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { ModelSelectionDialog } from './ModelSelectionDialog.js';
import type { AvailableModel } from '../models/availableModels.js';
import type { RadioSelectItem } from './shared/RadioButtonSelect.js';
// Mock the useKeypress hook
const mockUseKeypress = vi.hoisted(() => vi.fn());
vi.mock('../hooks/useKeypress.js', () => ({
useKeypress: mockUseKeypress,
}));
// Mock the RadioButtonSelect component
const mockRadioButtonSelect = vi.hoisted(() => vi.fn());
vi.mock('./shared/RadioButtonSelect.js', () => ({
RadioButtonSelect: mockRadioButtonSelect,
}));
describe('ModelSelectionDialog', () => {
const mockAvailableModels: AvailableModel[] = [
{ id: 'qwen3-coder-plus', label: 'qwen3-coder-plus' },
{ id: 'qwen-vl-max-latest', label: 'qwen-vl-max', isVision: true },
{ id: 'gpt-4', label: 'GPT-4' },
];
const mockOnSelect = vi.fn();
const mockOnCancel = vi.fn();
beforeEach(() => {
vi.clearAllMocks();
// Mock RadioButtonSelect to return a simple div
mockRadioButtonSelect.mockReturnValue(
React.createElement('div', { 'data-testid': 'radio-select' }),
);
});
it('should setup escape key handler to call onCancel', () => {
render(
<ModelSelectionDialog
availableModels={mockAvailableModels}
currentModel="qwen3-coder-plus"
onSelect={mockOnSelect}
onCancel={mockOnCancel}
/>,
);
expect(mockUseKeypress).toHaveBeenCalledWith(expect.any(Function), {
isActive: true,
});
// Simulate escape key press
const keypressHandler = mockUseKeypress.mock.calls[0][0];
keypressHandler({ name: 'escape' });
expect(mockOnCancel).toHaveBeenCalled();
});
it('should not call onCancel for non-escape keys', () => {
render(
<ModelSelectionDialog
availableModels={mockAvailableModels}
currentModel="qwen3-coder-plus"
onSelect={mockOnSelect}
onCancel={mockOnCancel}
/>,
);
const keypressHandler = mockUseKeypress.mock.calls[0][0];
keypressHandler({ name: 'enter' });
expect(mockOnCancel).not.toHaveBeenCalled();
});
it('should set correct initial index for current model', () => {
render(
<ModelSelectionDialog
availableModels={mockAvailableModels}
currentModel="qwen-vl-max-latest"
onSelect={mockOnSelect}
onCancel={mockOnCancel}
/>,
);
const callArgs = mockRadioButtonSelect.mock.calls[0][0];
expect(callArgs.initialIndex).toBe(1); // qwen-vl-max-latest is at index 1
});
it('should set initial index to 0 when current model is not found', () => {
render(
<ModelSelectionDialog
availableModels={mockAvailableModels}
currentModel="non-existent-model"
onSelect={mockOnSelect}
onCancel={mockOnCancel}
/>,
);
const callArgs = mockRadioButtonSelect.mock.calls[0][0];
expect(callArgs.initialIndex).toBe(0);
});
it('should call onSelect when a model is selected', () => {
render(
<ModelSelectionDialog
availableModels={mockAvailableModels}
currentModel="qwen3-coder-plus"
onSelect={mockOnSelect}
onCancel={mockOnCancel}
/>,
);
const callArgs = mockRadioButtonSelect.mock.calls[0][0];
expect(typeof callArgs.onSelect).toBe('function');
// Simulate selection
const onSelectCallback = mockRadioButtonSelect.mock.calls[0][0].onSelect;
onSelectCallback('qwen-vl-max-latest');
expect(mockOnSelect).toHaveBeenCalledWith('qwen-vl-max-latest');
});
it('should handle empty models array', () => {
render(
<ModelSelectionDialog
availableModels={[]}
currentModel=""
onSelect={mockOnSelect}
onCancel={mockOnCancel}
/>,
);
const callArgs = mockRadioButtonSelect.mock.calls[0][0];
expect(callArgs.items).toEqual([]);
expect(callArgs.initialIndex).toBe(0);
});
it('should create correct option items with proper labels', () => {
render(
<ModelSelectionDialog
availableModels={mockAvailableModels}
currentModel="qwen3-coder-plus"
onSelect={mockOnSelect}
onCancel={mockOnCancel}
/>,
);
const expectedItems = [
{
label: 'qwen3-coder-plus (current)',
value: 'qwen3-coder-plus',
},
{
label: 'qwen-vl-max [Vision]',
value: 'qwen-vl-max-latest',
},
{
label: 'GPT-4',
value: 'gpt-4',
},
];
const callArgs = mockRadioButtonSelect.mock.calls[0][0];
expect(callArgs.items).toEqual(expectedItems);
});
it('should show vision indicator for vision models', () => {
render(
<ModelSelectionDialog
availableModels={mockAvailableModels}
currentModel="gpt-4"
onSelect={mockOnSelect}
onCancel={mockOnCancel}
/>,
);
const callArgs = mockRadioButtonSelect.mock.calls[0][0];
const visionModelItem = callArgs.items.find(
(item: RadioSelectItem<string>) => item.value === 'qwen-vl-max-latest',
);
expect(visionModelItem?.label).toContain('[Vision]');
});
it('should show current indicator for the current model', () => {
render(
<ModelSelectionDialog
availableModels={mockAvailableModels}
currentModel="qwen-vl-max-latest"
onSelect={mockOnSelect}
onCancel={mockOnCancel}
/>,
);
const callArgs = mockRadioButtonSelect.mock.calls[0][0];
const currentModelItem = callArgs.items.find(
(item: RadioSelectItem<string>) => item.value === 'qwen-vl-max-latest',
);
expect(currentModelItem?.label).toContain('(current)');
});
it('should pass isFocused prop to RadioButtonSelect', () => {
render(
<ModelSelectionDialog
availableModels={mockAvailableModels}
currentModel="qwen3-coder-plus"
onSelect={mockOnSelect}
onCancel={mockOnCancel}
/>,
);
const callArgs = mockRadioButtonSelect.mock.calls[0][0];
expect(callArgs.isFocused).toBe(true);
});
it('should handle multiple onSelect calls correctly', () => {
render(
<ModelSelectionDialog
availableModels={mockAvailableModels}
currentModel="qwen3-coder-plus"
onSelect={mockOnSelect}
onCancel={mockOnCancel}
/>,
);
const onSelectCallback = mockRadioButtonSelect.mock.calls[0][0].onSelect;
// Call multiple times
onSelectCallback('qwen3-coder-plus');
onSelectCallback('qwen-vl-max-latest');
onSelectCallback('gpt-4');
expect(mockOnSelect).toHaveBeenCalledTimes(3);
expect(mockOnSelect).toHaveBeenNthCalledWith(1, 'qwen3-coder-plus');
expect(mockOnSelect).toHaveBeenNthCalledWith(2, 'qwen-vl-max-latest');
expect(mockOnSelect).toHaveBeenNthCalledWith(3, 'gpt-4');
});
});

View File

@@ -0,0 +1,87 @@
/**
* @license
* Copyright 2025 Qwen
* SPDX-License-Identifier: Apache-2.0
*/
import type React from 'react';
import { Box, Text } from 'ink';
import { Colors } from '../colors.js';
import {
RadioButtonSelect,
type RadioSelectItem,
} from './shared/RadioButtonSelect.js';
import { useKeypress } from '../hooks/useKeypress.js';
import type { AvailableModel } from '../models/availableModels.js';
export interface ModelSelectionDialogProps {
availableModels: AvailableModel[];
currentModel: string;
onSelect: (modelId: string) => void;
onCancel: () => void;
}
export const ModelSelectionDialog: React.FC<ModelSelectionDialogProps> = ({
availableModels,
currentModel,
onSelect,
onCancel,
}) => {
useKeypress(
(key) => {
if (key.name === 'escape') {
onCancel();
}
},
{ isActive: true },
);
const options: Array<RadioSelectItem<string>> = availableModels.map(
(model) => {
const visionIndicator = model.isVision ? ' [Vision]' : '';
const currentIndicator = model.id === currentModel ? ' (current)' : '';
return {
label: `${model.label}${visionIndicator}${currentIndicator}`,
value: model.id,
};
},
);
const initialIndex = Math.max(
0,
availableModels.findIndex((model) => model.id === currentModel),
);
const handleSelect = (modelId: string) => {
onSelect(modelId);
};
return (
<Box
flexDirection="column"
borderStyle="round"
borderColor={Colors.AccentBlue}
padding={1}
width="100%"
marginLeft={1}
>
<Box flexDirection="column" marginBottom={1}>
<Text bold>Select Model</Text>
<Text>Choose a model for this session:</Text>
</Box>
<Box marginBottom={1}>
<RadioButtonSelect
items={options}
initialIndex={initialIndex}
onSelect={handleSelect}
isFocused
/>
</Box>
<Box>
<Text color={Colors.Gray}>Press Enter to select, Esc to cancel</Text>
</Box>
</Box>
);
};

View File

@@ -0,0 +1,181 @@
/**
* @license
* Copyright 2025 Qwen
* SPDX-License-Identifier: Apache-2.0
*/
import React from 'react';
import { render } from 'ink-testing-library';
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { ModelSwitchDialog, VisionSwitchOutcome } from './ModelSwitchDialog.js';
// Mock the useKeypress hook
const mockUseKeypress = vi.hoisted(() => vi.fn());
vi.mock('../hooks/useKeypress.js', () => ({
useKeypress: mockUseKeypress,
}));
// Mock the RadioButtonSelect component
const mockRadioButtonSelect = vi.hoisted(() => vi.fn());
vi.mock('./shared/RadioButtonSelect.js', () => ({
RadioButtonSelect: mockRadioButtonSelect,
}));
describe('ModelSwitchDialog', () => {
const mockOnSelect = vi.fn();
beforeEach(() => {
vi.clearAllMocks();
// Mock RadioButtonSelect to return a simple div
mockRadioButtonSelect.mockReturnValue(
React.createElement('div', { 'data-testid': 'radio-select' }),
);
});
it('should setup RadioButtonSelect with correct options', () => {
render(<ModelSwitchDialog onSelect={mockOnSelect} />);
const expectedItems = [
{
label: 'Switch for this request only',
value: VisionSwitchOutcome.SwitchOnce,
},
{
label: 'Switch session to vision model',
value: VisionSwitchOutcome.SwitchSessionToVL,
},
{
label: 'Continue with current model',
value: VisionSwitchOutcome.ContinueWithCurrentModel,
},
];
const callArgs = mockRadioButtonSelect.mock.calls[0][0];
expect(callArgs.items).toEqual(expectedItems);
expect(callArgs.initialIndex).toBe(0);
expect(callArgs.isFocused).toBe(true);
});
it('should call onSelect when an option is selected', () => {
render(<ModelSwitchDialog onSelect={mockOnSelect} />);
const callArgs = mockRadioButtonSelect.mock.calls[0][0];
expect(typeof callArgs.onSelect).toBe('function');
// Simulate selection of "Switch for this request only"
const onSelectCallback = mockRadioButtonSelect.mock.calls[0][0].onSelect;
onSelectCallback(VisionSwitchOutcome.SwitchOnce);
expect(mockOnSelect).toHaveBeenCalledWith(VisionSwitchOutcome.SwitchOnce);
});
it('should call onSelect with SwitchSessionToVL when second option is selected', () => {
render(<ModelSwitchDialog onSelect={mockOnSelect} />);
const onSelectCallback = mockRadioButtonSelect.mock.calls[0][0].onSelect;
onSelectCallback(VisionSwitchOutcome.SwitchSessionToVL);
expect(mockOnSelect).toHaveBeenCalledWith(
VisionSwitchOutcome.SwitchSessionToVL,
);
});
it('should call onSelect with ContinueWithCurrentModel when third option is selected', () => {
render(<ModelSwitchDialog onSelect={mockOnSelect} />);
const onSelectCallback = mockRadioButtonSelect.mock.calls[0][0].onSelect;
onSelectCallback(VisionSwitchOutcome.ContinueWithCurrentModel);
expect(mockOnSelect).toHaveBeenCalledWith(
VisionSwitchOutcome.ContinueWithCurrentModel,
);
});
it('should setup escape key handler to call onSelect with ContinueWithCurrentModel', () => {
render(<ModelSwitchDialog onSelect={mockOnSelect} />);
expect(mockUseKeypress).toHaveBeenCalledWith(expect.any(Function), {
isActive: true,
});
// Simulate escape key press
const keypressHandler = mockUseKeypress.mock.calls[0][0];
keypressHandler({ name: 'escape' });
expect(mockOnSelect).toHaveBeenCalledWith(
VisionSwitchOutcome.ContinueWithCurrentModel,
);
});
it('should not call onSelect for non-escape keys', () => {
render(<ModelSwitchDialog onSelect={mockOnSelect} />);
const keypressHandler = mockUseKeypress.mock.calls[0][0];
keypressHandler({ name: 'enter' });
expect(mockOnSelect).not.toHaveBeenCalled();
});
it('should set initial index to 0 (first option)', () => {
render(<ModelSwitchDialog onSelect={mockOnSelect} />);
const callArgs = mockRadioButtonSelect.mock.calls[0][0];
expect(callArgs.initialIndex).toBe(0);
});
describe('VisionSwitchOutcome enum', () => {
it('should have correct enum values', () => {
expect(VisionSwitchOutcome.SwitchOnce).toBe('once');
expect(VisionSwitchOutcome.SwitchSessionToVL).toBe('session');
expect(VisionSwitchOutcome.ContinueWithCurrentModel).toBe('persist');
});
});
it('should handle multiple onSelect calls correctly', () => {
render(<ModelSwitchDialog onSelect={mockOnSelect} />);
const onSelectCallback = mockRadioButtonSelect.mock.calls[0][0].onSelect;
// Call multiple times
onSelectCallback(VisionSwitchOutcome.SwitchOnce);
onSelectCallback(VisionSwitchOutcome.SwitchSessionToVL);
onSelectCallback(VisionSwitchOutcome.ContinueWithCurrentModel);
expect(mockOnSelect).toHaveBeenCalledTimes(3);
expect(mockOnSelect).toHaveBeenNthCalledWith(
1,
VisionSwitchOutcome.SwitchOnce,
);
expect(mockOnSelect).toHaveBeenNthCalledWith(
2,
VisionSwitchOutcome.SwitchSessionToVL,
);
expect(mockOnSelect).toHaveBeenNthCalledWith(
3,
VisionSwitchOutcome.ContinueWithCurrentModel,
);
});
it('should pass isFocused prop to RadioButtonSelect', () => {
render(<ModelSwitchDialog onSelect={mockOnSelect} />);
const callArgs = mockRadioButtonSelect.mock.calls[0][0];
expect(callArgs.isFocused).toBe(true);
});
it('should handle escape key multiple times', () => {
render(<ModelSwitchDialog onSelect={mockOnSelect} />);
const keypressHandler = mockUseKeypress.mock.calls[0][0];
// Call escape multiple times
keypressHandler({ name: 'escape' });
keypressHandler({ name: 'escape' });
expect(mockOnSelect).toHaveBeenCalledTimes(2);
expect(mockOnSelect).toHaveBeenCalledWith(
VisionSwitchOutcome.ContinueWithCurrentModel,
);
});
});

View File

@@ -0,0 +1,89 @@
/**
* @license
* Copyright 2025 Qwen
* SPDX-License-Identifier: Apache-2.0
*/
import type React from 'react';
import { Box, Text } from 'ink';
import { Colors } from '../colors.js';
import {
RadioButtonSelect,
type RadioSelectItem,
} from './shared/RadioButtonSelect.js';
import { useKeypress } from '../hooks/useKeypress.js';
export enum VisionSwitchOutcome {
SwitchOnce = 'once',
SwitchSessionToVL = 'session',
ContinueWithCurrentModel = 'persist',
}
export interface ModelSwitchDialogProps {
onSelect: (outcome: VisionSwitchOutcome) => void;
}
export const ModelSwitchDialog: React.FC<ModelSwitchDialogProps> = ({
onSelect,
}) => {
useKeypress(
(key) => {
if (key.name === 'escape') {
onSelect(VisionSwitchOutcome.ContinueWithCurrentModel);
}
},
{ isActive: true },
);
const options: Array<RadioSelectItem<VisionSwitchOutcome>> = [
{
label: 'Switch for this request only',
value: VisionSwitchOutcome.SwitchOnce,
},
{
label: 'Switch session to vision model',
value: VisionSwitchOutcome.SwitchSessionToVL,
},
{
label: 'Continue with current model',
value: VisionSwitchOutcome.ContinueWithCurrentModel,
},
];
const handleSelect = (outcome: VisionSwitchOutcome) => {
onSelect(outcome);
};
return (
<Box
flexDirection="column"
borderStyle="round"
borderColor={Colors.AccentYellow}
padding={1}
width="100%"
marginLeft={1}
>
<Box flexDirection="column" marginBottom={1}>
<Text bold>Vision Model Switch Required</Text>
<Text>
Your message contains an image, but the current model doesn&apos;t
support vision.
</Text>
<Text>How would you like to proceed?</Text>
</Box>
<Box marginBottom={1}>
<RadioButtonSelect
items={options}
initialIndex={0}
onSelect={handleSelect}
isFocused
/>
</Box>
<Box>
<Text color={Colors.Gray}>Press Enter to select, Esc to cancel</Text>
</Box>
</Box>
);
};

View File

@@ -0,0 +1,41 @@
/**
* @license
* Copyright 2025 Qwen
* SPDX-License-Identifier: Apache-2.0
*/
import type React from 'react';
import { Box, Text } from 'ink';
import { MarkdownDisplay } from '../utils/MarkdownDisplay.js';
import { Colors } from '../colors.js';
import type { PlanResultDisplay } from '@qwen-code/qwen-code-core';
interface PlanSummaryDisplayProps {
data: PlanResultDisplay;
availableHeight?: number;
childWidth: number;
}
export const PlanSummaryDisplay: React.FC<PlanSummaryDisplayProps> = ({
data,
availableHeight,
childWidth,
}) => {
const { message, plan } = data;
return (
<Box flexDirection="column">
<Box marginBottom={1}>
<Text color={Colors.AccentGreen} wrap="wrap">
{message}
</Text>
</Box>
<MarkdownDisplay
text={plan}
isPending={false}
availableTerminalHeight={availableHeight}
terminalWidth={childWidth}
/>
</Box>
);
};

View File

@@ -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();

View File

@@ -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>
)}

View File

@@ -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>
)}

View File

@@ -5,6 +5,7 @@
*/
import { describe, it, expect, vi } from 'vitest';
import { EOL } from 'node:os';
import { ToolConfirmationMessage } from './ToolConfirmationMessage.js';
import type {
ToolCallConfirmationDetails,
@@ -66,6 +67,30 @@ describe('ToolConfirmationMessage', () => {
);
});
it('should render plan confirmation with markdown plan content', () => {
const confirmationDetails: ToolCallConfirmationDetails = {
type: 'plan',
title: 'Would you like to proceed?',
plan: '# Implementation Plan\n- Step one\n- Step two'.replace(/\n/g, EOL),
onConfirm: vi.fn(),
};
const { lastFrame } = renderWithProviders(
<ToolConfirmationMessage
confirmationDetails={confirmationDetails}
config={mockConfig}
availableTerminalHeight={30}
terminalWidth={80}
/>,
);
expect(lastFrame()).toContain('Yes, and auto-accept edits');
expect(lastFrame()).toContain('Yes, and manually approve edits');
expect(lastFrame()).toContain('No, keep planning');
expect(lastFrame()).toContain('Implementation Plan');
expect(lastFrame()).toContain('Step one');
});
describe('with folder trust', () => {
const editConfirmationDetails: ToolCallConfirmationDetails = {
type: 'edit',

View File

@@ -9,6 +9,7 @@ import { Box, Text } from 'ink';
import { DiffRenderer } from './DiffRenderer.js';
import { Colors } from '../../colors.js';
import { RenderInline } from '../../utils/InlineMarkdownRenderer.js';
import { MarkdownDisplay } from '../../utils/MarkdownDisplay.js';
import type {
ToolCallConfirmationDetails,
ToolExecuteConfirmationDetails,
@@ -27,6 +28,7 @@ export interface ToolConfirmationMessageProps {
isFocused?: boolean;
availableTerminalHeight?: number;
terminalWidth: number;
compactMode?: boolean;
}
export const ToolConfirmationMessage: React.FC<
@@ -37,6 +39,7 @@ export const ToolConfirmationMessage: React.FC<
isFocused = true,
availableTerminalHeight,
terminalWidth,
compactMode = false,
}) => {
const { onConfirm } = confirmationDetails;
const childWidth = terminalWidth - 2; // 2 for padding
@@ -70,6 +73,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;
@@ -199,6 +236,33 @@ export const ToolConfirmationMessage: React.FC<
</Box>
</Box>
);
} else if (confirmationDetails.type === 'plan') {
const planProps = confirmationDetails;
question = planProps.title;
options.push({
label: 'Yes, and auto-accept edits',
value: ToolConfirmationOutcome.ProceedAlways,
});
options.push({
label: 'Yes, and manually approve edits',
value: ToolConfirmationOutcome.ProceedOnce,
});
options.push({
label: 'No, keep planning (esc)',
value: ToolConfirmationOutcome.Cancel,
});
bodyContent = (
<Box flexDirection="column" paddingX={1} marginLeft={1}>
<MarkdownDisplay
text={planProps.plan}
isPending={false}
availableTerminalHeight={availableBodyContentHeight()}
terminalWidth={childWidth}
/>
</Box>
);
} else if (confirmationDetails.type === 'info') {
const infoProps = confirmationDetails;
const displayUrls =

View File

@@ -18,9 +18,11 @@ import { TOOL_STATUS } from '../../constants.js';
import type {
TodoResultDisplay,
TaskResultDisplay,
PlanResultDisplay,
Config,
} from '@qwen-code/qwen-code-core';
import { AgentExecutionDisplay } from '../subagents/index.js';
import { PlanSummaryDisplay } from '../PlanSummaryDisplay.js';
const STATIC_HEIGHT = 1;
const RESERVED_LINE_COUNT = 5; // for tool name, status, padding etc.
@@ -35,6 +37,7 @@ export type TextEmphasis = 'high' | 'medium' | 'low';
type DisplayRendererResult =
| { type: 'none' }
| { type: 'todo'; data: TodoResultDisplay }
| { type: 'plan'; data: PlanResultDisplay }
| { type: 'string'; data: string }
| { type: 'diff'; data: { fileDiff: string; fileName: string } }
| { type: 'task'; data: TaskResultDisplay };
@@ -63,6 +66,18 @@ const useResultDisplayRenderer = (
};
}
if (
typeof resultDisplay === 'object' &&
resultDisplay !== null &&
'type' in resultDisplay &&
resultDisplay.type === 'plan_summary'
) {
return {
type: 'plan',
data: resultDisplay as PlanResultDisplay,
};
}
// Check for SubagentExecutionResultDisplay (for non-task tools)
if (
typeof resultDisplay === 'object' &&
@@ -102,6 +117,18 @@ const TodoResultRenderer: React.FC<{ data: TodoResultDisplay }> = ({
data,
}) => <TodoDisplay todos={data.todos} />;
const PlanResultRenderer: React.FC<{
data: PlanResultDisplay;
availableHeight?: number;
childWidth: number;
}> = ({ data, availableHeight, childWidth }) => (
<PlanSummaryDisplay
data={data}
availableHeight={availableHeight}
childWidth={childWidth}
/>
);
/**
* Component to render subagent execution results
*/
@@ -229,6 +256,13 @@ export const ToolMessage: React.FC<ToolMessageProps> = ({
{displayRenderer.type === 'todo' && (
<TodoResultRenderer data={displayRenderer.data} />
)}
{displayRenderer.type === 'plan' && (
<PlanResultRenderer
data={displayRenderer.data}
availableHeight={availableHeight}
childWidth={childWidth}
/>
)}
{displayRenderer.type === 'task' && (
<SubagentExecutionRenderer
data={displayRenderer.data}

View File

@@ -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(
() => ({

View File

@@ -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 ? '●' : ' '}

View File

@@ -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({ force: true });
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(

View File

@@ -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>

View File

@@ -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,782 @@ 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(6); // 1 paste event + 5 individual chars for 'after'
});
// Should emit paste event first
expect(keyHandler).toHaveBeenNthCalledWith(
1,
expect.objectContaining({
paste: true,
sequence: 'pasted',
}),
);
// Then process 'after' as individual characters (since it doesn't contain return)
expect(keyHandler).toHaveBeenNthCalledWith(
2,
expect.objectContaining({
name: 'a',
paste: false,
}),
);
expect(keyHandler).toHaveBeenNthCalledWith(
3,
expect.objectContaining({
name: 'f',
paste: false,
}),
);
expect(keyHandler).toHaveBeenNthCalledWith(
4,
expect.objectContaining({
name: 't',
paste: false,
}),
);
expect(keyHandler).toHaveBeenNthCalledWith(
5,
expect.objectContaining({
name: 'e',
paste: false,
}),
);
expect(keyHandler).toHaveBeenNthCalledWith(
6,
expect.objectContaining({
name: 'r',
paste: false,
}),
);
});
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(16); // 5 + 1 + 6 + 1 + 3 = 16 calls
});
// 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 individual characters (since it doesn't contain return)
expect(keyHandler).toHaveBeenNthCalledWith(
callIndex++,
expect.objectContaining({ name: 'e' }),
);
expect(keyHandler).toHaveBeenNthCalledWith(
callIndex++,
expect.objectContaining({ name: 'n' }),
);
expect(keyHandler).toHaveBeenNthCalledWith(
callIndex++,
expect.objectContaining({ name: 'd' }),
);
});
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 paste markers get reconstructed
// into a single paste event for 'content'
expect(keyHandler).toHaveBeenCalledTimes(1);
});
// Should reconstruct the fragmented paste markers into a single paste event
expect(keyHandler).toHaveBeenCalledWith(
expect.objectContaining({
paste: true,
sequence: 'content',
}),
);
});
});
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 individual characters
// since 'hel' doesn't contain return (0x0d)
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,
}),
);
// Second chunk 'lo' is also processed as individual characters
expect(keyHandler).toHaveBeenNthCalledWith(
4,
expect.objectContaining({
name: 'l',
sequence: 'l',
paste: false,
}),
);
expect(keyHandler).toHaveBeenNthCalledWith(
5,
expect.objectContaining({
name: 'o',
sequence: 'o',
paste: false,
}),
);
expect(keyHandler).toHaveBeenCalledTimes(5);
} 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 without return gets treated as individual characters
expect(keyHandler).toHaveBeenCalledTimes(65);
// Each character should be processed individually
for (let i = 0; i < 65; i++) {
expect(keyHandler).toHaveBeenNthCalledWith(
i + 1,
expect.objectContaining({
name: 'x',
sequence: 'x',
paste: false,
}),
);
}
// 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', () => {

View File

@@ -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,115 @@ 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 && rawDataBuffer.includes(0x0d)) ||
!rawDataBuffer.includes(0x0d) ||
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 +477,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 +499,10 @@ export function KeypressProvider({
stdin,
setRawMode,
kittyProtocolEnabled,
debugKeystrokeLogging,
pasteWorkaround,
config,
subscribers,
debugKeystrokeLogging,
]);
return (

View File

@@ -106,6 +106,7 @@ describe('useSlashCommandProcessor', () => {
const mockLoadHistory = vi.fn();
const mockOpenThemeDialog = vi.fn();
const mockOpenAuthDialog = vi.fn();
const mockOpenModelSelectionDialog = vi.fn();
const mockSetQuittingMessages = vi.fn();
const mockConfig = makeFakeConfig({});
@@ -122,6 +123,7 @@ describe('useSlashCommandProcessor', () => {
mockBuiltinLoadCommands.mockResolvedValue([]);
mockFileLoadCommands.mockResolvedValue([]);
mockMcpLoadCommands.mockResolvedValue([]);
mockOpenModelSelectionDialog.mockClear();
});
const setupProcessorHook = (
@@ -150,11 +152,13 @@ describe('useSlashCommandProcessor', () => {
mockSetQuittingMessages,
vi.fn(), // openPrivacyNotice
vi.fn(), // openSettingsDialog
mockOpenModelSelectionDialog,
vi.fn(), // openSubagentCreateDialog
vi.fn(), // openAgentsManagerDialog
vi.fn(), // toggleVimEnabled
setIsProcessing,
vi.fn(), // setGeminiMdFileCount
vi.fn(), // _showQuitConfirmation
),
);
@@ -395,6 +399,21 @@ describe('useSlashCommandProcessor', () => {
expect(mockOpenThemeDialog).toHaveBeenCalled();
});
it('should handle "dialog: model" action', async () => {
const command = createTestCommand({
name: 'modelcmd',
action: vi.fn().mockResolvedValue({ type: 'dialog', dialog: 'model' }),
});
const result = setupProcessorHook([command]);
await waitFor(() => expect(result.current.slashCommands).toHaveLength(1));
await act(async () => {
await result.current.handleSlashCommand('/modelcmd');
});
expect(mockOpenModelSelectionDialog).toHaveBeenCalled();
});
it('should handle "load_history" action', async () => {
const command = createTestCommand({
name: 'load',
@@ -904,11 +923,13 @@ describe('useSlashCommandProcessor', () => {
mockSetQuittingMessages,
vi.fn(), // openPrivacyNotice
vi.fn(), // openSettingsDialog
vi.fn(), // openModelSelectionDialog
vi.fn(), // openSubagentCreateDialog
vi.fn(), // openAgentsManagerDialog
vi.fn(), // toggleVimEnabled
vi.fn(), // setIsProcessing
vi.fn(), // setGeminiMdFileCount
vi.fn(), // _showQuitConfirmation
),
);

View File

@@ -53,6 +53,7 @@ export const useSlashCommandProcessor = (
setQuittingMessages: (message: HistoryItem[]) => void,
openPrivacyNotice: () => void,
openSettingsDialog: () => void,
openModelSelectionDialog: () => void,
openSubagentCreateDialog: () => void,
openAgentsManagerDialog: () => void,
toggleVimEnabled: () => Promise<boolean>,
@@ -404,6 +405,9 @@ export const useSlashCommandProcessor = (
case 'settings':
openSettingsDialog();
return { type: 'handled' };
case 'model':
openModelSelectionDialog();
return { type: 'handled' };
case 'subagent_create':
openSubagentCreateDialog();
return { type: 'handled' };
@@ -507,7 +511,7 @@ export const useSlashCommandProcessor = (
setTimeout(async () => {
await runExitCleanup();
process.exit(0);
}, 1000);
}, 100);
return { type: 'handled' };
case 'submit_prompt':
@@ -663,6 +667,7 @@ export const useSlashCommandProcessor = (
setSessionShellAllowlist,
setIsProcessing,
setConfirmationRequest,
openModelSelectionDialog,
session.stats,
],
);

View File

@@ -158,7 +158,19 @@ describe('useAutoAcceptIndicator', () => {
expect(mockConfigInstance.getApprovalMode).toHaveBeenCalledTimes(1);
});
it('should toggle the indicator and update config when Shift+Tab or Ctrl+Y is pressed', () => {
it('should initialize with ApprovalMode.PLAN if config.getApprovalMode returns ApprovalMode.PLAN', () => {
mockConfigInstance.getApprovalMode.mockReturnValue(ApprovalMode.PLAN);
const { result } = renderHook(() =>
useAutoAcceptIndicator({
config: mockConfigInstance as unknown as ActualConfigType,
addItem: vi.fn(),
}),
);
expect(result.current).toBe(ApprovalMode.PLAN);
expect(mockConfigInstance.getApprovalMode).toHaveBeenCalledTimes(1);
});
it('should cycle approval modes when Shift+Tab is pressed', () => {
mockConfigInstance.getApprovalMode.mockReturnValue(ApprovalMode.DEFAULT);
const { result } = renderHook(() =>
useAutoAcceptIndicator({
@@ -180,23 +192,10 @@ describe('useAutoAcceptIndicator', () => {
expect(result.current).toBe(ApprovalMode.AUTO_EDIT);
act(() => {
capturedUseKeypressHandler({ name: 'y', ctrl: true } as Key);
});
expect(mockConfigInstance.setApprovalMode).toHaveBeenCalledWith(
ApprovalMode.YOLO,
);
expect(result.current).toBe(ApprovalMode.YOLO);
act(() => {
capturedUseKeypressHandler({ name: 'y', ctrl: true } as Key);
});
expect(mockConfigInstance.setApprovalMode).toHaveBeenCalledWith(
ApprovalMode.DEFAULT,
);
expect(result.current).toBe(ApprovalMode.DEFAULT);
act(() => {
capturedUseKeypressHandler({ name: 'y', ctrl: true } as Key);
capturedUseKeypressHandler({
name: 'tab',
shift: true,
} as Key);
});
expect(mockConfigInstance.setApprovalMode).toHaveBeenCalledWith(
ApprovalMode.YOLO,
@@ -210,9 +209,9 @@ describe('useAutoAcceptIndicator', () => {
} as Key);
});
expect(mockConfigInstance.setApprovalMode).toHaveBeenCalledWith(
ApprovalMode.AUTO_EDIT,
ApprovalMode.PLAN,
);
expect(result.current).toBe(ApprovalMode.AUTO_EDIT);
expect(result.current).toBe(ApprovalMode.PLAN);
act(() => {
capturedUseKeypressHandler({
@@ -314,118 +313,10 @@ describe('useAutoAcceptIndicator', () => {
mockConfigInstance.isTrustedFolder.mockReturnValue(false);
});
it('should not enable YOLO mode when Ctrl+Y is pressed', () => {
mockConfigInstance.getApprovalMode.mockReturnValue(ApprovalMode.DEFAULT);
mockConfigInstance.setApprovalMode.mockImplementation(() => {
throw new Error(
'Cannot enable privileged approval modes in an untrusted folder.',
);
});
const mockAddItem = vi.fn();
const { result } = renderHook(() =>
useAutoAcceptIndicator({
config: mockConfigInstance as unknown as ActualConfigType,
addItem: mockAddItem,
}),
);
expect(result.current).toBe(ApprovalMode.DEFAULT);
act(() => {
capturedUseKeypressHandler({ name: 'y', ctrl: true } as Key);
});
// We expect setApprovalMode to be called, and the error to be caught.
expect(mockConfigInstance.setApprovalMode).toHaveBeenCalledWith(
ApprovalMode.YOLO,
);
expect(mockAddItem).toHaveBeenCalled();
// Verify the underlying config value was not changed
expect(mockConfigInstance.getApprovalMode()).toBe(ApprovalMode.DEFAULT);
});
it('should not enable AUTO_EDIT mode when Shift+Tab is pressed', () => {
mockConfigInstance.getApprovalMode.mockReturnValue(ApprovalMode.DEFAULT);
mockConfigInstance.setApprovalMode.mockImplementation(() => {
throw new Error(
'Cannot enable privileged approval modes in an untrusted folder.',
);
});
const mockAddItem = vi.fn();
const { result } = renderHook(() =>
useAutoAcceptIndicator({
config: mockConfigInstance as unknown as ActualConfigType,
addItem: mockAddItem,
}),
);
expect(result.current).toBe(ApprovalMode.DEFAULT);
act(() => {
capturedUseKeypressHandler({
name: 'tab',
shift: true,
} as Key);
});
// We expect setApprovalMode to be called, and the error to be caught.
expect(mockConfigInstance.setApprovalMode).toHaveBeenCalledWith(
ApprovalMode.AUTO_EDIT,
);
expect(mockAddItem).toHaveBeenCalled();
// Verify the underlying config value was not changed
expect(mockConfigInstance.getApprovalMode()).toBe(ApprovalMode.DEFAULT);
});
it('should disable YOLO mode when Ctrl+Y is pressed', () => {
mockConfigInstance.getApprovalMode.mockReturnValue(ApprovalMode.YOLO);
const mockAddItem = vi.fn();
renderHook(() =>
useAutoAcceptIndicator({
config: mockConfigInstance as unknown as ActualConfigType,
addItem: mockAddItem,
}),
);
act(() => {
capturedUseKeypressHandler({ name: 'y', ctrl: true } as Key);
});
expect(mockConfigInstance.setApprovalMode).toHaveBeenCalledWith(
ApprovalMode.DEFAULT,
);
expect(mockConfigInstance.getApprovalMode()).toBe(ApprovalMode.DEFAULT);
});
it('should disable AUTO_EDIT mode when Shift+Tab is pressed', () => {
mockConfigInstance.getApprovalMode.mockReturnValue(
ApprovalMode.AUTO_EDIT,
);
const mockAddItem = vi.fn();
renderHook(() =>
useAutoAcceptIndicator({
config: mockConfigInstance as unknown as ActualConfigType,
addItem: mockAddItem,
}),
);
act(() => {
capturedUseKeypressHandler({
name: 'tab',
shift: true,
} as Key);
});
expect(mockConfigInstance.setApprovalMode).toHaveBeenCalledWith(
ApprovalMode.DEFAULT,
);
expect(mockConfigInstance.getApprovalMode()).toBe(ApprovalMode.DEFAULT);
});
it('should show a warning when trying to enable privileged modes', () => {
// Mock the error thrown by setApprovalMode
it('should show a warning when cycling from DEFAULT to AUTO_EDIT', () => {
const errorMessage =
'Cannot enable privileged approval modes in an untrusted folder.';
mockConfigInstance.getApprovalMode.mockReturnValue(ApprovalMode.DEFAULT);
mockConfigInstance.setApprovalMode.mockImplementation(() => {
throw new Error(errorMessage);
});
@@ -438,11 +329,13 @@ describe('useAutoAcceptIndicator', () => {
}),
);
// Try to enable YOLO mode
act(() => {
capturedUseKeypressHandler({ name: 'y', ctrl: true } as Key);
capturedUseKeypressHandler({ name: 'tab', shift: true } as Key);
});
expect(mockConfigInstance.setApprovalMode).toHaveBeenCalledWith(
ApprovalMode.AUTO_EDIT,
);
expect(mockAddItem).toHaveBeenCalledWith(
{
type: MessageType.INFO,
@@ -450,15 +343,33 @@ describe('useAutoAcceptIndicator', () => {
},
expect.any(Number),
);
});
// Try to enable AUTO_EDIT mode
act(() => {
capturedUseKeypressHandler({
name: 'tab',
shift: true,
} as Key);
it('should show a warning when cycling from AUTO_EDIT to YOLO', () => {
const errorMessage =
'Cannot enable privileged approval modes in an untrusted folder.';
mockConfigInstance.getApprovalMode.mockReturnValue(
ApprovalMode.AUTO_EDIT,
);
mockConfigInstance.setApprovalMode.mockImplementation(() => {
throw new Error(errorMessage);
});
const mockAddItem = vi.fn();
renderHook(() =>
useAutoAcceptIndicator({
config: mockConfigInstance as unknown as ActualConfigType,
addItem: mockAddItem,
}),
);
act(() => {
capturedUseKeypressHandler({ name: 'tab', shift: true } as Key);
});
expect(mockConfigInstance.setApprovalMode).toHaveBeenCalledWith(
ApprovalMode.YOLO,
);
expect(mockAddItem).toHaveBeenCalledWith(
{
type: MessageType.INFO,
@@ -466,8 +377,27 @@ describe('useAutoAcceptIndicator', () => {
},
expect.any(Number),
);
});
expect(mockAddItem).toHaveBeenCalledTimes(2);
it('should cycle from YOLO to PLAN when Shift+Tab is pressed', () => {
mockConfigInstance.getApprovalMode.mockReturnValue(ApprovalMode.YOLO);
const mockAddItem = vi.fn();
renderHook(() =>
useAutoAcceptIndicator({
config: mockConfigInstance as unknown as ActualConfigType,
addItem: mockAddItem,
}),
);
act(() => {
capturedUseKeypressHandler({ name: 'tab', shift: true } as Key);
});
expect(mockConfigInstance.setApprovalMode).toHaveBeenCalledWith(
ApprovalMode.PLAN,
);
expect(mockConfigInstance.getApprovalMode()).toBe(ApprovalMode.PLAN);
expect(mockAddItem).not.toHaveBeenCalled();
});
});
});

View File

@@ -4,7 +4,11 @@
* SPDX-License-Identifier: Apache-2.0
*/
import { ApprovalMode, type Config } from '@qwen-code/qwen-code-core';
import {
type ApprovalMode,
APPROVAL_MODES,
type Config,
} from '@qwen-code/qwen-code-core';
import { useEffect, useState } from 'react';
import { useKeypress } from './useKeypress.js';
import type { HistoryItemWithoutId } from '../types.js';
@@ -29,34 +33,28 @@ export function useAutoAcceptIndicator({
useKeypress(
(key) => {
let nextApprovalMode: ApprovalMode | undefined;
if (key.ctrl && key.name === 'y') {
nextApprovalMode =
config.getApprovalMode() === ApprovalMode.YOLO
? ApprovalMode.DEFAULT
: ApprovalMode.YOLO;
} else if (key.shift && key.name === 'tab') {
nextApprovalMode =
config.getApprovalMode() === ApprovalMode.AUTO_EDIT
? ApprovalMode.DEFAULT
: ApprovalMode.AUTO_EDIT;
if (!(key.shift && key.name === 'tab')) {
return;
}
if (nextApprovalMode) {
try {
config.setApprovalMode(nextApprovalMode);
// Update local state immediately for responsiveness
setShowAutoAcceptIndicator(nextApprovalMode);
} catch (e) {
addItem(
{
type: MessageType.INFO,
text: (e as Error).message,
},
Date.now(),
);
}
const currentMode = config.getApprovalMode();
const currentIndex = APPROVAL_MODES.indexOf(currentMode);
const nextIndex =
currentIndex === -1 ? 0 : (currentIndex + 1) % APPROVAL_MODES.length;
const nextApprovalMode = APPROVAL_MODES[nextIndex];
try {
config.setApprovalMode(nextApprovalMode);
// Update local state immediately for responsiveness
setShowAutoAcceptIndicator(nextApprovalMode);
} catch (e) {
addItem(
{
type: MessageType.INFO,
text: (e as Error).message,
},
Date.now(),
);
}
},
{ isActive: true },

View File

@@ -56,6 +56,14 @@ const MockedUserPromptEvent = vi.hoisted(() =>
);
const mockParseAndFormatApiError = vi.hoisted(() => vi.fn());
// Vision auto-switch mocks (hoisted)
const mockHandleVisionSwitch = vi.hoisted(() =>
vi.fn().mockResolvedValue({ shouldProceed: true }),
);
const mockRestoreOriginalModel = vi.hoisted(() =>
vi.fn().mockResolvedValue(undefined),
);
vi.mock('@qwen-code/qwen-code-core', async (importOriginal) => {
const actualCoreModule = (await importOriginal()) as any;
return {
@@ -76,6 +84,13 @@ vi.mock('./useReactToolScheduler.js', async (importOriginal) => {
};
});
vi.mock('./useVisionAutoSwitch.js', () => ({
useVisionAutoSwitch: vi.fn(() => ({
handleVisionSwitch: mockHandleVisionSwitch,
restoreOriginalModel: mockRestoreOriginalModel,
})),
}));
vi.mock('./useKeypress.js', () => ({
useKeypress: vi.fn(),
}));
@@ -199,6 +214,7 @@ describe('useGeminiStream', () => {
getContentGeneratorConfig: vi
.fn()
.mockReturnValue(contentGeneratorConfig),
getMaxSessionTurns: vi.fn(() => 50),
} as unknown as Config;
mockOnDebugMessage = vi.fn();
mockHandleSlashCommand = vi.fn().mockResolvedValue(false);
@@ -287,6 +303,8 @@ describe('useGeminiStream', () => {
() => {},
() => {},
() => {},
false, // visionModelPreviewEnabled
undefined, // onVisionSwitchRequired (optional)
);
},
{
@@ -448,6 +466,8 @@ describe('useGeminiStream', () => {
() => {},
() => {},
() => {},
false, // visionModelPreviewEnabled
undefined, // onVisionSwitchRequired (optional)
),
);
@@ -527,6 +547,8 @@ describe('useGeminiStream', () => {
() => {},
() => {},
() => {},
false, // visionModelPreviewEnabled
undefined, // onVisionSwitchRequired (optional)
),
);
@@ -635,6 +657,8 @@ describe('useGeminiStream', () => {
() => {},
() => {},
() => {},
false, // visionModelPreviewEnabled
undefined, // onVisionSwitchRequired (optional)
),
);
@@ -744,6 +768,8 @@ describe('useGeminiStream', () => {
() => {},
() => {},
() => {},
false, // visionModelPreviewEnabled
undefined, // onVisionSwitchRequired (optional)
),
);
@@ -873,6 +899,8 @@ describe('useGeminiStream', () => {
() => {},
() => {},
cancelSubmitSpy,
false, // visionModelPreviewEnabled
undefined, // onVisionSwitchRequired (optional)
),
);
@@ -1184,6 +1212,8 @@ describe('useGeminiStream', () => {
() => {},
() => {},
() => {},
false, // visionModelPreviewEnabled
undefined, // onVisionSwitchRequired (optional)
),
);
@@ -1237,6 +1267,8 @@ describe('useGeminiStream', () => {
() => {},
() => {},
() => {},
false, // visionModelPreviewEnabled
undefined, // onVisionSwitchRequired (optional)
),
);
@@ -1287,6 +1319,8 @@ describe('useGeminiStream', () => {
() => {},
() => {},
() => {},
false, // visionModelPreviewEnabled
undefined, // onVisionSwitchRequired (optional)
),
);
@@ -1335,6 +1369,8 @@ describe('useGeminiStream', () => {
() => {},
() => {},
() => {},
false, // visionModelPreviewEnabled
undefined, // onVisionSwitchRequired (optional)
),
);
@@ -1384,6 +1420,8 @@ describe('useGeminiStream', () => {
() => {},
() => {},
() => {},
false, // visionModelPreviewEnabled
undefined, // onVisionSwitchRequired (optional)
),
);
@@ -1473,6 +1511,8 @@ describe('useGeminiStream', () => {
() => {},
() => {},
() => {},
false, // visionModelPreviewEnabled
undefined, // onVisionSwitchRequired (optional)
),
);
@@ -1523,6 +1563,8 @@ describe('useGeminiStream', () => {
vi.fn(), // setModelSwitched
vi.fn(), // onEditorClose
vi.fn(), // onCancelSubmit
false, // visionModelPreviewEnabled
undefined, // onVisionSwitchRequired (optional)
),
);
@@ -1551,6 +1593,7 @@ describe('useGeminiStream', () => {
expect.any(String), // Argument 3: The prompt_id string
);
});
describe('Thought Reset', () => {
it('should reset thought to null when starting a new prompt', async () => {
// First, simulate a response with a thought
@@ -1587,6 +1630,8 @@ describe('useGeminiStream', () => {
() => {},
() => {},
() => {},
false, // visionModelPreviewEnabled
undefined, // onVisionSwitchRequired (optional)
),
);
@@ -1665,6 +1710,8 @@ describe('useGeminiStream', () => {
() => {},
() => {},
() => {},
false, // visionModelPreviewEnabled
undefined, // onVisionSwitchRequired (optional)
),
);
@@ -1719,6 +1766,8 @@ describe('useGeminiStream', () => {
() => {},
() => {},
() => {},
false, // visionModelPreviewEnabled
undefined, // onVisionSwitchRequired (optional)
),
);
@@ -1900,4 +1949,174 @@ describe('useGeminiStream', () => {
);
});
});
// --- New tests focused on recent modifications ---
describe('Vision Auto Switch Integration', () => {
it('should call handleVisionSwitch and proceed to send when allowed', async () => {
mockHandleVisionSwitch.mockResolvedValueOnce({ shouldProceed: true });
mockSendMessageStream.mockReturnValue(
(async function* () {
yield { type: ServerGeminiEventType.Content, value: 'ok' };
yield { type: ServerGeminiEventType.Finished, value: 'STOP' };
})(),
);
const { result } = renderHook(() =>
useGeminiStream(
new MockedGeminiClientClass(mockConfig),
[],
mockAddItem,
mockConfig,
mockOnDebugMessage,
mockHandleSlashCommand,
false,
() => 'vscode' as EditorType,
() => {},
() => Promise.resolve(),
false,
() => {},
() => {},
() => {},
false, // visionModelPreviewEnabled
undefined, // onVisionSwitchRequired (optional)
),
);
await act(async () => {
await result.current.submitQuery('image prompt');
});
await waitFor(() => {
expect(mockHandleVisionSwitch).toHaveBeenCalled();
expect(mockSendMessageStream).toHaveBeenCalled();
});
});
it('should gate submission when handleVisionSwitch returns shouldProceed=false', async () => {
mockHandleVisionSwitch.mockResolvedValueOnce({ shouldProceed: false });
const { result } = renderHook(() =>
useGeminiStream(
new MockedGeminiClientClass(mockConfig),
[],
mockAddItem,
mockConfig,
mockOnDebugMessage,
mockHandleSlashCommand,
false,
() => 'vscode' as EditorType,
() => {},
() => Promise.resolve(),
false,
() => {},
() => {},
() => {},
false, // visionModelPreviewEnabled
undefined, // onVisionSwitchRequired (optional)
),
);
await act(async () => {
await result.current.submitQuery('vision-gated');
});
// No call to API, no restoreOriginalModel needed since no override occurred
expect(mockSendMessageStream).not.toHaveBeenCalled();
expect(mockRestoreOriginalModel).not.toHaveBeenCalled();
// Next call allowed (flag reset path)
mockHandleVisionSwitch.mockResolvedValueOnce({ shouldProceed: true });
mockSendMessageStream.mockReturnValue(
(async function* () {
yield { type: ServerGeminiEventType.Content, value: 'ok' };
yield { type: ServerGeminiEventType.Finished, value: 'STOP' };
})(),
);
await act(async () => {
await result.current.submitQuery('after-gate');
});
await waitFor(() => {
expect(mockSendMessageStream).toHaveBeenCalled();
});
});
});
describe('Model restore on completion and errors', () => {
it('should restore model after successful stream completion', async () => {
mockSendMessageStream.mockReturnValue(
(async function* () {
yield { type: ServerGeminiEventType.Content, value: 'content' };
yield { type: ServerGeminiEventType.Finished, value: 'STOP' };
})(),
);
const { result } = renderHook(() =>
useGeminiStream(
new MockedGeminiClientClass(mockConfig),
[],
mockAddItem,
mockConfig,
mockOnDebugMessage,
mockHandleSlashCommand,
false,
() => 'vscode' as EditorType,
() => {},
() => Promise.resolve(),
false,
() => {},
() => {},
() => {},
false, // visionModelPreviewEnabled
undefined, // onVisionSwitchRequired (optional)
),
);
await act(async () => {
await result.current.submitQuery('restore-success');
});
await waitFor(() => {
expect(mockRestoreOriginalModel).toHaveBeenCalledTimes(1);
});
});
it('should restore model when an error occurs during streaming', async () => {
const testError = new Error('stream failure');
mockSendMessageStream.mockReturnValue(
(async function* () {
yield { type: ServerGeminiEventType.Content, value: 'content' };
throw testError;
})(),
);
const { result } = renderHook(() =>
useGeminiStream(
new MockedGeminiClientClass(mockConfig),
[],
mockAddItem,
mockConfig,
mockOnDebugMessage,
mockHandleSlashCommand,
false,
() => 'vscode' as EditorType,
() => {},
() => Promise.resolve(),
false,
() => {},
() => {},
() => {},
false, // visionModelPreviewEnabled
undefined, // onVisionSwitchRequired (optional)
),
);
await act(async () => {
await result.current.submitQuery('restore-error');
});
await waitFor(() => {
expect(mockRestoreOriginalModel).toHaveBeenCalledTimes(1);
});
});
});
});

View File

@@ -42,6 +42,7 @@ import type {
import { StreamingState, MessageType, ToolCallStatus } from '../types.js';
import { isAtCommand, isSlashCommand } from '../utils/commandUtils.js';
import { useShellCommandProcessor } from './shellCommandProcessor.js';
import { useVisionAutoSwitch } from './useVisionAutoSwitch.js';
import { handleAtCommand } from './atCommandProcessor.js';
import { findLastSafeSplitPoint } from '../utils/markdownUtilities.js';
import { useStateAndRef } from './useStateAndRef.js';
@@ -88,6 +89,12 @@ export const useGeminiStream = (
setModelSwitchedFromQuotaError: React.Dispatch<React.SetStateAction<boolean>>,
onEditorClose: () => void,
onCancelSubmit: () => void,
visionModelPreviewEnabled: boolean,
onVisionSwitchRequired?: (query: PartListUnion) => Promise<{
modelOverride?: string;
persistSessionModel?: string;
showGuidance?: boolean;
}>,
) => {
const [initError, setInitError] = useState<string | null>(null);
const abortControllerRef = useRef<AbortController | null>(null);
@@ -155,6 +162,13 @@ export const useGeminiStream = (
geminiClient,
);
const { handleVisionSwitch, restoreOriginalModel } = useVisionAutoSwitch(
config,
addItem,
visionModelPreviewEnabled,
onVisionSwitchRequired,
);
const streamingState = useMemo(() => {
if (toolCalls.some((tc) => tc.status === 'awaiting_approval')) {
return StreamingState.WaitingForConfirmation;
@@ -715,6 +729,20 @@ export const useGeminiStream = (
return;
}
// Handle vision switch requirement
const visionSwitchResult = await handleVisionSwitch(
queryToSend,
userMessageTimestamp,
options?.isContinuation || false,
);
if (!visionSwitchResult.shouldProceed) {
isSubmittingQueryRef.current = false;
return;
}
const finalQueryToSend = queryToSend;
if (!options?.isContinuation) {
startNewPrompt();
setThought(null); // Reset thought when starting a new prompt
@@ -725,7 +753,7 @@ export const useGeminiStream = (
try {
const stream = geminiClient.sendMessageStream(
queryToSend,
finalQueryToSend,
abortSignal,
prompt_id!,
);
@@ -736,6 +764,10 @@ export const useGeminiStream = (
);
if (processingStatus === StreamProcessingStatus.UserCancelled) {
// Restore original model if it was temporarily overridden
restoreOriginalModel().catch((error) => {
console.error('Failed to restore original model:', error);
});
isSubmittingQueryRef.current = false;
return;
}
@@ -748,7 +780,17 @@ export const useGeminiStream = (
loopDetectedRef.current = false;
handleLoopDetectedEvent();
}
// Restore original model if it was temporarily overridden
restoreOriginalModel().catch((error) => {
console.error('Failed to restore original model:', error);
});
} catch (error: unknown) {
// Restore original model if it was temporarily overridden
restoreOriginalModel().catch((error) => {
console.error('Failed to restore original model:', error);
});
if (error instanceof UnauthorizedError) {
onAuthError();
} else if (!isNodeError(error) || error.name !== 'AbortError') {
@@ -786,6 +828,8 @@ export const useGeminiStream = (
startNewPrompt,
getPromptCount,
handleLoopDetectedEvent,
handleVisionSwitch,
restoreOriginalModel,
],
);
@@ -911,10 +955,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 () => {

View File

@@ -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: '',

View File

@@ -0,0 +1,874 @@
/**
* @license
* Copyright 2025 Qwen
* SPDX-License-Identifier: Apache-2.0
*/
/* eslint-disable @typescript-eslint/no-explicit-any */
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { renderHook, act } from '@testing-library/react';
import type { Part, PartListUnion } from '@google/genai';
import { AuthType, type Config, ApprovalMode } from '@qwen-code/qwen-code-core';
// Mock the image format functions from core package
vi.mock('@qwen-code/qwen-code-core', async (importOriginal) => {
const actual = (await importOriginal()) as Record<string, unknown>;
return {
...actual,
isSupportedImageMimeType: vi.fn((mimeType: string) =>
[
'image/png',
'image/jpeg',
'image/jpg',
'image/gif',
'image/webp',
].includes(mimeType),
),
getUnsupportedImageFormatWarning: vi.fn(
() =>
'Only the following image formats are supported: BMP, JPEG, JPG, PNG, TIFF, WEBP, HEIC. Other formats may not work as expected.',
),
};
});
import {
shouldOfferVisionSwitch,
processVisionSwitchOutcome,
getVisionSwitchGuidanceMessage,
useVisionAutoSwitch,
} from './useVisionAutoSwitch.js';
import { VisionSwitchOutcome } from '../components/ModelSwitchDialog.js';
import { MessageType } from '../types.js';
import { getDefaultVisionModel } from '../models/availableModels.js';
describe('useVisionAutoSwitch helpers', () => {
describe('shouldOfferVisionSwitch', () => {
it('returns false when authType is not QWEN_OAUTH', () => {
const parts: PartListUnion = [
{ inlineData: { mimeType: 'image/png', data: '...' } },
];
const result = shouldOfferVisionSwitch(
parts,
AuthType.USE_GEMINI,
'qwen3-coder-plus',
true,
);
expect(result).toBe(false);
});
it('returns false when current model is already a vision model', () => {
const parts: PartListUnion = [
{ inlineData: { mimeType: 'image/png', data: '...' } },
];
const result = shouldOfferVisionSwitch(
parts,
AuthType.QWEN_OAUTH,
'vision-model',
true,
);
expect(result).toBe(false);
});
it('returns true when image parts exist, QWEN_OAUTH, and model is not vision', () => {
const parts: PartListUnion = [
{ text: 'hello' },
{ inlineData: { mimeType: 'image/jpeg', data: '...' } },
];
const result = shouldOfferVisionSwitch(
parts,
AuthType.QWEN_OAUTH,
'qwen3-coder-plus',
true,
);
expect(result).toBe(true);
});
it('detects image when provided as a single Part object (non-array)', () => {
const singleImagePart: PartListUnion = {
fileData: { mimeType: 'image/gif', fileUri: 'file://image.gif' },
} as Part;
const result = shouldOfferVisionSwitch(
singleImagePart,
AuthType.QWEN_OAUTH,
'qwen3-coder-plus',
true,
);
expect(result).toBe(true);
});
it('returns false when parts contain no images', () => {
const parts: PartListUnion = [{ text: 'just text' }];
const result = shouldOfferVisionSwitch(
parts,
AuthType.QWEN_OAUTH,
'qwen3-coder-plus',
true,
);
expect(result).toBe(false);
});
it('returns false when parts is a plain string', () => {
const parts: PartListUnion = 'plain text';
const result = shouldOfferVisionSwitch(
parts,
AuthType.QWEN_OAUTH,
'qwen3-coder-plus',
true,
);
expect(result).toBe(false);
});
it('returns false when visionModelPreviewEnabled is false', () => {
const parts: PartListUnion = [
{ inlineData: { mimeType: 'image/png', data: '...' } },
];
const result = shouldOfferVisionSwitch(
parts,
AuthType.QWEN_OAUTH,
'qwen3-coder-plus',
false,
);
expect(result).toBe(false);
});
it('returns true when image parts exist in YOLO mode context', () => {
const parts: PartListUnion = [
{ inlineData: { mimeType: 'image/png', data: '...' } },
];
const result = shouldOfferVisionSwitch(
parts,
AuthType.QWEN_OAUTH,
'qwen3-coder-plus',
true,
);
expect(result).toBe(true);
});
it('returns false when no image parts exist in YOLO mode context', () => {
const parts: PartListUnion = [{ text: 'just text' }];
const result = shouldOfferVisionSwitch(
parts,
AuthType.QWEN_OAUTH,
'qwen3-coder-plus',
true,
);
expect(result).toBe(false);
});
it('returns false when already using vision model in YOLO mode context', () => {
const parts: PartListUnion = [
{ inlineData: { mimeType: 'image/png', data: '...' } },
];
const result = shouldOfferVisionSwitch(
parts,
AuthType.QWEN_OAUTH,
'vision-model',
true,
);
expect(result).toBe(false);
});
it('returns false when authType is not QWEN_OAUTH in YOLO mode context', () => {
const parts: PartListUnion = [
{ inlineData: { mimeType: 'image/png', data: '...' } },
];
const result = shouldOfferVisionSwitch(
parts,
AuthType.USE_GEMINI,
'qwen3-coder-plus',
true,
);
expect(result).toBe(false);
});
});
describe('processVisionSwitchOutcome', () => {
it('maps SwitchOnce to a one-time model override', () => {
const vl = getDefaultVisionModel();
const result = processVisionSwitchOutcome(VisionSwitchOutcome.SwitchOnce);
expect(result).toEqual({ modelOverride: vl });
});
it('maps SwitchSessionToVL to a persistent session model', () => {
const vl = getDefaultVisionModel();
const result = processVisionSwitchOutcome(
VisionSwitchOutcome.SwitchSessionToVL,
);
expect(result).toEqual({ persistSessionModel: vl });
});
it('maps ContinueWithCurrentModel to empty result', () => {
const result = processVisionSwitchOutcome(
VisionSwitchOutcome.ContinueWithCurrentModel,
);
expect(result).toEqual({});
});
});
describe('getVisionSwitchGuidanceMessage', () => {
it('returns the expected guidance message', () => {
const vl = getDefaultVisionModel();
const expected =
'To use images with your query, you can:\n' +
`• Use /model set ${vl} to switch to a vision-capable model\n` +
'• Or remove the image and provide a text description instead';
expect(getVisionSwitchGuidanceMessage()).toBe(expected);
});
});
});
describe('useVisionAutoSwitch hook', () => {
type AddItemFn = (
item: { type: MessageType; text: string },
ts: number,
) => any;
const createMockConfig = (
authType: AuthType,
initialModel: string,
approvalMode: ApprovalMode = ApprovalMode.DEFAULT,
vlmSwitchMode?: string,
) => {
let currentModel = initialModel;
const mockConfig: Partial<Config> = {
getModel: vi.fn(() => currentModel),
setModel: vi.fn(async (m: string) => {
currentModel = m;
}),
getApprovalMode: vi.fn(() => approvalMode),
getVlmSwitchMode: vi.fn(() => vlmSwitchMode),
getContentGeneratorConfig: vi.fn(() => ({
authType,
model: currentModel,
apiKey: 'test-key',
vertexai: false,
})),
};
return mockConfig as Config;
};
let addItem: AddItemFn;
beforeEach(() => {
vi.clearAllMocks();
addItem = vi.fn();
});
it('returns shouldProceed=true immediately for continuations', async () => {
const config = createMockConfig(AuthType.QWEN_OAUTH, 'qwen3-coder-plus');
const { result } = renderHook(() =>
useVisionAutoSwitch(config, addItem as any, true, vi.fn()),
);
const parts: PartListUnion = [
{ inlineData: { mimeType: 'image/png', data: '...' } },
];
let res: any;
await act(async () => {
res = await result.current.handleVisionSwitch(parts, Date.now(), true);
});
expect(res).toEqual({ shouldProceed: true });
expect(addItem).not.toHaveBeenCalled();
});
it('does nothing when authType is not QWEN_OAUTH', async () => {
const config = createMockConfig(AuthType.USE_GEMINI, 'qwen3-coder-plus');
const onVisionSwitchRequired = vi.fn();
const { result } = renderHook(() =>
useVisionAutoSwitch(config, addItem as any, true, onVisionSwitchRequired),
);
const parts: PartListUnion = [
{ inlineData: { mimeType: 'image/png', data: '...' } },
];
let res: any;
await act(async () => {
res = await result.current.handleVisionSwitch(parts, 123, false);
});
expect(res).toEqual({ shouldProceed: true });
expect(onVisionSwitchRequired).not.toHaveBeenCalled();
});
it('does nothing when there are no image parts', async () => {
const config = createMockConfig(AuthType.QWEN_OAUTH, 'qwen3-coder-plus');
const onVisionSwitchRequired = vi.fn();
const { result } = renderHook(() =>
useVisionAutoSwitch(config, addItem as any, true, onVisionSwitchRequired),
);
const parts: PartListUnion = [{ text: 'no images here' }];
let res: any;
await act(async () => {
res = await result.current.handleVisionSwitch(parts, 456, false);
});
expect(res).toEqual({ shouldProceed: true });
expect(onVisionSwitchRequired).not.toHaveBeenCalled();
});
it('continues with current model when dialog returns empty result', async () => {
const config = createMockConfig(AuthType.QWEN_OAUTH, 'qwen3-coder-plus');
const onVisionSwitchRequired = vi.fn().mockResolvedValue({}); // Empty result for ContinueWithCurrentModel
const { result } = renderHook(() =>
useVisionAutoSwitch(config, addItem as any, true, onVisionSwitchRequired),
);
const parts: PartListUnion = [
{ inlineData: { mimeType: 'image/png', data: '...' } },
];
const userTs = 1010;
let res: any;
await act(async () => {
res = await result.current.handleVisionSwitch(parts, userTs, false);
});
// Should not add any guidance message
expect(addItem).not.toHaveBeenCalledWith(
{ type: MessageType.INFO, text: getVisionSwitchGuidanceMessage() },
userTs,
);
expect(res).toEqual({ shouldProceed: true });
expect(config.setModel).not.toHaveBeenCalled();
});
it('applies a one-time override and returns originalModel, then restores', async () => {
const initialModel = 'qwen3-coder-plus';
const config = createMockConfig(AuthType.QWEN_OAUTH, initialModel);
const onVisionSwitchRequired = vi
.fn()
.mockResolvedValue({ modelOverride: 'coder-model' });
const { result } = renderHook(() =>
useVisionAutoSwitch(config, addItem as any, true, onVisionSwitchRequired),
);
const parts: PartListUnion = [
{ inlineData: { mimeType: 'image/png', data: '...' } },
];
let res: any;
await act(async () => {
res = await result.current.handleVisionSwitch(parts, 2020, false);
});
expect(res).toEqual({ shouldProceed: true, originalModel: initialModel });
expect(config.setModel).toHaveBeenCalledWith('coder-model', {
reason: 'vision_auto_switch',
context: 'User-prompted vision switch (one-time override)',
});
// Now restore
await act(async () => {
await result.current.restoreOriginalModel();
});
expect(config.setModel).toHaveBeenLastCalledWith(initialModel, {
reason: 'vision_auto_switch',
context: 'Restoring original model after vision switch',
});
});
it('persists session model when dialog requests persistence', async () => {
const config = createMockConfig(AuthType.QWEN_OAUTH, 'qwen3-coder-plus');
const onVisionSwitchRequired = vi
.fn()
.mockResolvedValue({ persistSessionModel: 'coder-model' });
const { result } = renderHook(() =>
useVisionAutoSwitch(config, addItem as any, true, onVisionSwitchRequired),
);
const parts: PartListUnion = [
{ inlineData: { mimeType: 'image/png', data: '...' } },
];
let res: any;
await act(async () => {
res = await result.current.handleVisionSwitch(parts, 3030, false);
});
expect(res).toEqual({ shouldProceed: true });
expect(config.setModel).toHaveBeenCalledWith('coder-model', {
reason: 'vision_auto_switch',
context: 'User-prompted vision switch (session persistent)',
});
// Restore should be a no-op since no one-time override was used
await act(async () => {
await result.current.restoreOriginalModel();
});
// Last call should still be the persisted model set
expect((config.setModel as any).mock.calls.pop()?.[0]).toBe('coder-model');
});
it('returns shouldProceed=true when dialog returns no special flags', async () => {
const config = createMockConfig(AuthType.QWEN_OAUTH, 'qwen3-coder-plus');
const onVisionSwitchRequired = vi.fn().mockResolvedValue({});
const { result } = renderHook(() =>
useVisionAutoSwitch(config, addItem as any, true, onVisionSwitchRequired),
);
const parts: PartListUnion = [
{ inlineData: { mimeType: 'image/png', data: '...' } },
];
let res: any;
await act(async () => {
res = await result.current.handleVisionSwitch(parts, 4040, false);
});
expect(res).toEqual({ shouldProceed: true });
expect(config.setModel).not.toHaveBeenCalled();
});
it('blocks when dialog throws or is cancelled', async () => {
const config = createMockConfig(AuthType.QWEN_OAUTH, 'qwen3-coder-plus');
const onVisionSwitchRequired = vi.fn().mockRejectedValue(new Error('x'));
const { result } = renderHook(() =>
useVisionAutoSwitch(config, addItem as any, true, onVisionSwitchRequired),
);
const parts: PartListUnion = [
{ inlineData: { mimeType: 'image/png', data: '...' } },
];
let res: any;
await act(async () => {
res = await result.current.handleVisionSwitch(parts, 5050, false);
});
expect(res).toEqual({ shouldProceed: false });
expect(config.setModel).not.toHaveBeenCalled();
});
it('does nothing when visionModelPreviewEnabled is false', async () => {
const config = createMockConfig(AuthType.QWEN_OAUTH, 'qwen3-coder-plus');
const onVisionSwitchRequired = vi.fn();
const { result } = renderHook(() =>
useVisionAutoSwitch(
config,
addItem as any,
false,
onVisionSwitchRequired,
),
);
const parts: PartListUnion = [
{ inlineData: { mimeType: 'image/png', data: '...' } },
];
let res: any;
await act(async () => {
res = await result.current.handleVisionSwitch(parts, 6060, false);
});
expect(res).toEqual({ shouldProceed: true });
expect(onVisionSwitchRequired).not.toHaveBeenCalled();
});
describe('YOLO mode behavior', () => {
it('automatically switches to vision model in YOLO mode without showing dialog', async () => {
const initialModel = 'qwen3-coder-plus';
const config = createMockConfig(
AuthType.QWEN_OAUTH,
initialModel,
ApprovalMode.YOLO,
);
const onVisionSwitchRequired = vi.fn(); // Should not be called in YOLO mode
const { result } = renderHook(() =>
useVisionAutoSwitch(
config,
addItem as any,
true,
onVisionSwitchRequired,
),
);
const parts: PartListUnion = [
{ inlineData: { mimeType: 'image/png', data: '...' } },
];
let res: any;
await act(async () => {
res = await result.current.handleVisionSwitch(parts, 7070, false);
});
// Should automatically switch without calling the dialog
expect(onVisionSwitchRequired).not.toHaveBeenCalled();
expect(res).toEqual({
shouldProceed: true,
originalModel: initialModel,
});
expect(config.setModel).toHaveBeenCalledWith(getDefaultVisionModel(), {
reason: 'vision_auto_switch',
context: 'YOLO mode auto-switch for image content',
});
});
it('does not switch in YOLO mode when no images are present', async () => {
const config = createMockConfig(
AuthType.QWEN_OAUTH,
'qwen3-coder-plus',
ApprovalMode.YOLO,
);
const onVisionSwitchRequired = vi.fn();
const { result } = renderHook(() =>
useVisionAutoSwitch(
config,
addItem as any,
true,
onVisionSwitchRequired,
),
);
const parts: PartListUnion = [{ text: 'no images here' }];
let res: any;
await act(async () => {
res = await result.current.handleVisionSwitch(parts, 8080, false);
});
expect(res).toEqual({ shouldProceed: true });
expect(onVisionSwitchRequired).not.toHaveBeenCalled();
expect(config.setModel).not.toHaveBeenCalled();
});
it('does not switch in YOLO mode when already using vision model', async () => {
const config = createMockConfig(
AuthType.QWEN_OAUTH,
'vision-model',
ApprovalMode.YOLO,
);
const onVisionSwitchRequired = vi.fn();
const { result } = renderHook(() =>
useVisionAutoSwitch(
config,
addItem as any,
true,
onVisionSwitchRequired,
),
);
const parts: PartListUnion = [
{ inlineData: { mimeType: 'image/png', data: '...' } },
];
let res: any;
await act(async () => {
res = await result.current.handleVisionSwitch(parts, 9090, false);
});
expect(res).toEqual({ shouldProceed: true });
expect(onVisionSwitchRequired).not.toHaveBeenCalled();
expect(config.setModel).not.toHaveBeenCalled();
});
it('restores original model after YOLO mode auto-switch', async () => {
const initialModel = 'qwen3-coder-plus';
const config = createMockConfig(
AuthType.QWEN_OAUTH,
initialModel,
ApprovalMode.YOLO,
);
const onVisionSwitchRequired = vi.fn();
const { result } = renderHook(() =>
useVisionAutoSwitch(
config,
addItem as any,
true,
onVisionSwitchRequired,
),
);
const parts: PartListUnion = [
{ inlineData: { mimeType: 'image/png', data: '...' } },
];
// First, trigger the auto-switch
await act(async () => {
await result.current.handleVisionSwitch(parts, 10100, false);
});
// Verify model was switched
expect(config.setModel).toHaveBeenCalledWith(getDefaultVisionModel(), {
reason: 'vision_auto_switch',
context: 'YOLO mode auto-switch for image content',
});
// Now restore the original model
await act(async () => {
await result.current.restoreOriginalModel();
});
// Verify model was restored
expect(config.setModel).toHaveBeenLastCalledWith(initialModel, {
reason: 'vision_auto_switch',
context: 'Restoring original model after vision switch',
});
});
it('does not switch in YOLO mode when authType is not QWEN_OAUTH', async () => {
const config = createMockConfig(
AuthType.USE_GEMINI,
'qwen3-coder-plus',
ApprovalMode.YOLO,
);
const onVisionSwitchRequired = vi.fn();
const { result } = renderHook(() =>
useVisionAutoSwitch(
config,
addItem as any,
true,
onVisionSwitchRequired,
),
);
const parts: PartListUnion = [
{ inlineData: { mimeType: 'image/png', data: '...' } },
];
let res: any;
await act(async () => {
res = await result.current.handleVisionSwitch(parts, 11110, false);
});
expect(res).toEqual({ shouldProceed: true });
expect(onVisionSwitchRequired).not.toHaveBeenCalled();
expect(config.setModel).not.toHaveBeenCalled();
});
it('does not switch in YOLO mode when visionModelPreviewEnabled is false', async () => {
const config = createMockConfig(
AuthType.QWEN_OAUTH,
'qwen3-coder-plus',
ApprovalMode.YOLO,
);
const onVisionSwitchRequired = vi.fn();
const { result } = renderHook(() =>
useVisionAutoSwitch(
config,
addItem as any,
false,
onVisionSwitchRequired,
),
);
const parts: PartListUnion = [
{ inlineData: { mimeType: 'image/png', data: '...' } },
];
let res: any;
await act(async () => {
res = await result.current.handleVisionSwitch(parts, 12120, false);
});
expect(res).toEqual({ shouldProceed: true });
expect(onVisionSwitchRequired).not.toHaveBeenCalled();
expect(config.setModel).not.toHaveBeenCalled();
});
it('handles multiple image formats in YOLO mode', async () => {
const initialModel = 'qwen3-coder-plus';
const config = createMockConfig(
AuthType.QWEN_OAUTH,
initialModel,
ApprovalMode.YOLO,
);
const onVisionSwitchRequired = vi.fn();
const { result } = renderHook(() =>
useVisionAutoSwitch(
config,
addItem as any,
true,
onVisionSwitchRequired,
),
);
const parts: PartListUnion = [
{ text: 'Here are some images:' },
{ inlineData: { mimeType: 'image/jpeg', data: '...' } },
{ fileData: { mimeType: 'image/png', fileUri: 'file://image.png' } },
{ text: 'Please analyze them.' },
];
let res: any;
await act(async () => {
res = await result.current.handleVisionSwitch(parts, 13130, false);
});
expect(res).toEqual({
shouldProceed: true,
originalModel: initialModel,
});
expect(config.setModel).toHaveBeenCalledWith(getDefaultVisionModel(), {
reason: 'vision_auto_switch',
context: 'YOLO mode auto-switch for image content',
});
expect(onVisionSwitchRequired).not.toHaveBeenCalled();
});
});
describe('VLM switch mode default behavior', () => {
it('should automatically switch once when vlmSwitchMode is "once"', async () => {
const config = createMockConfig(
AuthType.QWEN_OAUTH,
'qwen3-coder-plus',
ApprovalMode.DEFAULT,
'once',
);
const onVisionSwitchRequired = vi.fn(); // Should not be called
const { result } = renderHook(() =>
useVisionAutoSwitch(
config,
addItem as any,
true,
onVisionSwitchRequired,
),
);
const parts: PartListUnion = [
{ inlineData: { mimeType: 'image/jpeg', data: 'base64data' } },
];
const switchResult = await result.current.handleVisionSwitch(
parts,
Date.now(),
false,
);
expect(switchResult.shouldProceed).toBe(true);
expect(switchResult.originalModel).toBe('qwen3-coder-plus');
expect(config.setModel).toHaveBeenCalledWith('vision-model', {
reason: 'vision_auto_switch',
context: 'Default VLM switch mode: once (one-time override)',
});
expect(onVisionSwitchRequired).not.toHaveBeenCalled();
});
it('should switch session when vlmSwitchMode is "session"', async () => {
const config = createMockConfig(
AuthType.QWEN_OAUTH,
'qwen3-coder-plus',
ApprovalMode.DEFAULT,
'session',
);
const onVisionSwitchRequired = vi.fn(); // Should not be called
const { result } = renderHook(() =>
useVisionAutoSwitch(
config,
addItem as any,
true,
onVisionSwitchRequired,
),
);
const parts: PartListUnion = [
{ inlineData: { mimeType: 'image/jpeg', data: 'base64data' } },
];
const switchResult = await result.current.handleVisionSwitch(
parts,
Date.now(),
false,
);
expect(switchResult.shouldProceed).toBe(true);
expect(switchResult.originalModel).toBeUndefined(); // No original model for session switch
expect(config.setModel).toHaveBeenCalledWith('vision-model', {
reason: 'vision_auto_switch',
context: 'Default VLM switch mode: session (session persistent)',
});
expect(onVisionSwitchRequired).not.toHaveBeenCalled();
});
it('should continue with current model when vlmSwitchMode is "persist"', async () => {
const config = createMockConfig(
AuthType.QWEN_OAUTH,
'qwen3-coder-plus',
ApprovalMode.DEFAULT,
'persist',
);
const onVisionSwitchRequired = vi.fn(); // Should not be called
const { result } = renderHook(() =>
useVisionAutoSwitch(
config,
addItem as any,
true,
onVisionSwitchRequired,
),
);
const parts: PartListUnion = [
{ inlineData: { mimeType: 'image/jpeg', data: 'base64data' } },
];
const switchResult = await result.current.handleVisionSwitch(
parts,
Date.now(),
false,
);
expect(switchResult.shouldProceed).toBe(true);
expect(switchResult.originalModel).toBeUndefined();
expect(config.setModel).not.toHaveBeenCalled();
expect(onVisionSwitchRequired).not.toHaveBeenCalled();
});
it('should fall back to user prompt when vlmSwitchMode is not set', async () => {
const config = createMockConfig(
AuthType.QWEN_OAUTH,
'qwen3-coder-plus',
ApprovalMode.DEFAULT,
undefined, // No default mode
);
const onVisionSwitchRequired = vi
.fn()
.mockResolvedValue({ modelOverride: 'vision-model' });
const { result } = renderHook(() =>
useVisionAutoSwitch(
config,
addItem as any,
true,
onVisionSwitchRequired,
),
);
const parts: PartListUnion = [
{ inlineData: { mimeType: 'image/jpeg', data: 'base64data' } },
];
const switchResult = await result.current.handleVisionSwitch(
parts,
Date.now(),
false,
);
expect(switchResult.shouldProceed).toBe(true);
expect(onVisionSwitchRequired).toHaveBeenCalledWith(parts);
});
it('should fall back to persist behavior when vlmSwitchMode has invalid value', async () => {
const config = createMockConfig(
AuthType.QWEN_OAUTH,
'qwen3-coder-plus',
ApprovalMode.DEFAULT,
'invalid-value',
);
const onVisionSwitchRequired = vi.fn(); // Should not be called
const { result } = renderHook(() =>
useVisionAutoSwitch(
config,
addItem as any,
true,
onVisionSwitchRequired,
),
);
const parts: PartListUnion = [
{ inlineData: { mimeType: 'image/jpeg', data: 'base64data' } },
];
const switchResult = await result.current.handleVisionSwitch(
parts,
Date.now(),
false,
);
expect(switchResult.shouldProceed).toBe(true);
expect(switchResult.originalModel).toBeUndefined();
// For invalid values, it should continue with current model (persist behavior)
expect(config.setModel).not.toHaveBeenCalled();
expect(onVisionSwitchRequired).not.toHaveBeenCalled();
});
});
});

View File

@@ -0,0 +1,363 @@
/**
* @license
* Copyright 2025 Qwen
* SPDX-License-Identifier: Apache-2.0
*/
import { type PartListUnion, type Part } from '@google/genai';
import { AuthType, type Config, ApprovalMode } from '@qwen-code/qwen-code-core';
import { useCallback, useRef } from 'react';
import { VisionSwitchOutcome } from '../components/ModelSwitchDialog.js';
import {
getDefaultVisionModel,
isVisionModel,
} from '../models/availableModels.js';
import { MessageType } from '../types.js';
import type { UseHistoryManagerReturn } from './useHistoryManager.js';
import {
isSupportedImageMimeType,
getUnsupportedImageFormatWarning,
} from '@qwen-code/qwen-code-core';
/**
* Checks if a PartListUnion contains image parts
*/
function hasImageParts(parts: PartListUnion): boolean {
if (typeof parts === 'string') {
return false;
}
if (Array.isArray(parts)) {
return parts.some((part) => {
// Skip string parts
if (typeof part === 'string') return false;
return isImagePart(part);
});
}
// If it's a single Part (not a string), check if it's an image
if (typeof parts === 'object') {
return isImagePart(parts);
}
return false;
}
/**
* Checks if a single Part is an image part
*/
function isImagePart(part: Part): boolean {
// Check for inlineData with image mime type
if ('inlineData' in part && part.inlineData?.mimeType?.startsWith('image/')) {
return true;
}
// Check for fileData with image mime type
if ('fileData' in part && part.fileData?.mimeType?.startsWith('image/')) {
return true;
}
return false;
}
/**
* Checks if image parts have supported formats and returns unsupported ones
*/
function checkImageFormatsSupport(parts: PartListUnion): {
hasImages: boolean;
hasUnsupportedFormats: boolean;
unsupportedMimeTypes: string[];
} {
const unsupportedMimeTypes: string[] = [];
let hasImages = false;
if (typeof parts === 'string') {
return {
hasImages: false,
hasUnsupportedFormats: false,
unsupportedMimeTypes: [],
};
}
const partsArray = Array.isArray(parts) ? parts : [parts];
for (const part of partsArray) {
if (typeof part === 'string') continue;
let mimeType: string | undefined;
// Check inlineData
if (
'inlineData' in part &&
part.inlineData?.mimeType?.startsWith('image/')
) {
hasImages = true;
mimeType = part.inlineData.mimeType;
}
// Check fileData
if ('fileData' in part && part.fileData?.mimeType?.startsWith('image/')) {
hasImages = true;
mimeType = part.fileData.mimeType;
}
// Check if the mime type is supported
if (mimeType && !isSupportedImageMimeType(mimeType)) {
unsupportedMimeTypes.push(mimeType);
}
}
return {
hasImages,
hasUnsupportedFormats: unsupportedMimeTypes.length > 0,
unsupportedMimeTypes,
};
}
/**
* Determines if we should offer vision switch for the given parts, auth type, and current model
*/
export function shouldOfferVisionSwitch(
parts: PartListUnion,
authType: AuthType,
currentModel: string,
visionModelPreviewEnabled: boolean = true,
): boolean {
// Only trigger for qwen-oauth
if (authType !== AuthType.QWEN_OAUTH) {
return false;
}
// If vision model preview is disabled, never offer vision switch
if (!visionModelPreviewEnabled) {
return false;
}
// If current model is already a vision model, no need to switch
if (isVisionModel(currentModel)) {
return false;
}
// Check if the current message contains image parts
return hasImageParts(parts);
}
/**
* Interface for vision switch result
*/
export interface VisionSwitchResult {
modelOverride?: string;
persistSessionModel?: string;
showGuidance?: boolean;
}
/**
* Processes the vision switch outcome and returns the appropriate result
*/
export function processVisionSwitchOutcome(
outcome: VisionSwitchOutcome,
): VisionSwitchResult {
const vlModelId = getDefaultVisionModel();
switch (outcome) {
case VisionSwitchOutcome.SwitchOnce:
return { modelOverride: vlModelId };
case VisionSwitchOutcome.SwitchSessionToVL:
return { persistSessionModel: vlModelId };
case VisionSwitchOutcome.ContinueWithCurrentModel:
return {}; // Continue with current model, no changes needed
default:
return {}; // Default to continuing with current model
}
}
/**
* Gets the guidance message for when vision switch is disallowed
*/
export function getVisionSwitchGuidanceMessage(): string {
const vlModelId = getDefaultVisionModel();
return `To use images with your query, you can:
• Use /model set ${vlModelId} to switch to a vision-capable model
• Or remove the image and provide a text description instead`;
}
/**
* Interface for vision switch handling result
*/
export interface VisionSwitchHandlingResult {
shouldProceed: boolean;
originalModel?: string;
}
/**
* Custom hook for handling vision model auto-switching
*/
export function useVisionAutoSwitch(
config: Config,
addItem: UseHistoryManagerReturn['addItem'],
visionModelPreviewEnabled: boolean = true,
onVisionSwitchRequired?: (query: PartListUnion) => Promise<{
modelOverride?: string;
persistSessionModel?: string;
showGuidance?: boolean;
}>,
) {
const originalModelRef = useRef<string | null>(null);
const handleVisionSwitch = useCallback(
async (
query: PartListUnion,
userMessageTimestamp: number,
isContinuation: boolean,
): Promise<VisionSwitchHandlingResult> => {
// Skip vision switch handling for continuations or if no handler provided
if (isContinuation || !onVisionSwitchRequired) {
return { shouldProceed: true };
}
const contentGeneratorConfig = config.getContentGeneratorConfig();
// Only handle qwen-oauth auth type
if (contentGeneratorConfig?.authType !== AuthType.QWEN_OAUTH) {
return { shouldProceed: true };
}
// Check image format support first
const formatCheck = checkImageFormatsSupport(query);
// If there are unsupported image formats, show warning
if (formatCheck.hasUnsupportedFormats) {
addItem(
{
type: MessageType.INFO,
text: getUnsupportedImageFormatWarning(),
},
userMessageTimestamp,
);
// Continue processing but with warning shown
}
// Check if vision switch is needed
if (
!shouldOfferVisionSwitch(
query,
contentGeneratorConfig.authType,
config.getModel(),
visionModelPreviewEnabled,
)
) {
return { shouldProceed: true };
}
// In YOLO mode, automatically switch to vision model without user interaction
if (config.getApprovalMode() === ApprovalMode.YOLO) {
const vlModelId = getDefaultVisionModel();
originalModelRef.current = config.getModel();
await config.setModel(vlModelId, {
reason: 'vision_auto_switch',
context: 'YOLO mode auto-switch for image content',
});
return {
shouldProceed: true,
originalModel: originalModelRef.current,
};
}
// Check if there's a default VLM switch mode configured
const defaultVlmSwitchMode = config.getVlmSwitchMode();
if (defaultVlmSwitchMode) {
// Convert string value to VisionSwitchOutcome enum
let outcome: VisionSwitchOutcome;
switch (defaultVlmSwitchMode) {
case 'once':
outcome = VisionSwitchOutcome.SwitchOnce;
break;
case 'session':
outcome = VisionSwitchOutcome.SwitchSessionToVL;
break;
case 'persist':
outcome = VisionSwitchOutcome.ContinueWithCurrentModel;
break;
default:
// Invalid value, fall back to prompting user
outcome = VisionSwitchOutcome.ContinueWithCurrentModel;
}
// Process the default outcome
const visionSwitchResult = processVisionSwitchOutcome(outcome);
if (visionSwitchResult.modelOverride) {
// One-time model override
originalModelRef.current = config.getModel();
await config.setModel(visionSwitchResult.modelOverride, {
reason: 'vision_auto_switch',
context: `Default VLM switch mode: ${defaultVlmSwitchMode} (one-time override)`,
});
return {
shouldProceed: true,
originalModel: originalModelRef.current,
};
} else if (visionSwitchResult.persistSessionModel) {
// Persistent session model change
await config.setModel(visionSwitchResult.persistSessionModel, {
reason: 'vision_auto_switch',
context: `Default VLM switch mode: ${defaultVlmSwitchMode} (session persistent)`,
});
return { shouldProceed: true };
}
// For ContinueWithCurrentModel or any other case, proceed with current model
return { shouldProceed: true };
}
try {
const visionSwitchResult = await onVisionSwitchRequired(query);
if (visionSwitchResult.modelOverride) {
// One-time model override
originalModelRef.current = config.getModel();
await config.setModel(visionSwitchResult.modelOverride, {
reason: 'vision_auto_switch',
context: 'User-prompted vision switch (one-time override)',
});
return {
shouldProceed: true,
originalModel: originalModelRef.current,
};
} else if (visionSwitchResult.persistSessionModel) {
// Persistent session model change
await config.setModel(visionSwitchResult.persistSessionModel, {
reason: 'vision_auto_switch',
context: 'User-prompted vision switch (session persistent)',
});
return { shouldProceed: true };
}
// For ContinueWithCurrentModel or any other case, proceed with current model
return { shouldProceed: true };
} catch (_error) {
// If vision switch dialog was cancelled or errored, don't proceed
return { shouldProceed: false };
}
},
[config, addItem, visionModelPreviewEnabled, onVisionSwitchRequired],
);
const restoreOriginalModel = useCallback(async () => {
if (originalModelRef.current) {
await config.setModel(originalModelRef.current, {
reason: 'vision_auto_switch',
context: 'Restoring original model after vision switch',
});
originalModelRef.current = null;
}
}, [config]);
return {
handleVisionSwitch,
restoreOriginalModel,
};
}

View File

@@ -0,0 +1,55 @@
/**
* @license
* Copyright 2025 Qwen
* SPDX-License-Identifier: Apache-2.0
*/
export type AvailableModel = {
id: string;
label: string;
isVision?: boolean;
};
export const MAINLINE_VLM = 'vision-model';
export const MAINLINE_CODER = 'coder-model';
export const AVAILABLE_MODELS_QWEN: AvailableModel[] = [
{ id: MAINLINE_CODER, label: MAINLINE_CODER },
{ id: MAINLINE_VLM, label: MAINLINE_VLM, isVision: true },
];
/**
* Get available Qwen models filtered by vision model preview setting
*/
export function getFilteredQwenModels(
visionModelPreviewEnabled: boolean,
): AvailableModel[] {
if (visionModelPreviewEnabled) {
return AVAILABLE_MODELS_QWEN;
}
return AVAILABLE_MODELS_QWEN.filter((model) => !model.isVision);
}
/**
* Currently we use the single model of `OPENAI_MODEL` in the env.
* In the future, after settings.json is updated, we will allow users to configure this themselves.
*/
export function getOpenAIAvailableModelFromEnv(): AvailableModel | null {
const id = process.env['OPENAI_MODEL']?.trim();
return id ? { id, label: id } : null;
}
/**
/**
* Hard code the default vision model as a string literal,
* until our coding model supports multimodal.
*/
export function getDefaultVisionModel(): string {
return MAINLINE_VLM;
}
export function isVisionModel(modelId: string): boolean {
return AVAILABLE_MODELS_QWEN.some(
(model) => model.id === modelId && model.isVision,
);
}

View File

@@ -9,7 +9,6 @@ import { describe, it, expect, vi, beforeEach } from 'vitest';
import { MarkdownDisplay } from './MarkdownDisplay.js';
import { LoadedSettings } from '../../config/settings.js';
import { SettingsContext } from '../contexts/SettingsContext.js';
import { EOL } from 'node:os';
describe('<MarkdownDisplay />', () => {
const baseProps = {
@@ -57,7 +56,7 @@ describe('<MarkdownDisplay />', () => {
## Header 2
### Header 3
#### Header 4
`.replace(/\n/g, EOL);
`;
const { lastFrame } = render(
<SettingsContext.Provider value={mockSettings}>
<MarkdownDisplay {...baseProps} text={text} />
@@ -67,10 +66,7 @@ describe('<MarkdownDisplay />', () => {
});
it('renders a fenced code block with a language', () => {
const text = '```javascript\nconst x = 1;\nconsole.log(x);\n```'.replace(
/\n/g,
EOL,
);
const text = '```javascript\nconst x = 1;\nconsole.log(x);\n```';
const { lastFrame } = render(
<SettingsContext.Provider value={mockSettings}>
<MarkdownDisplay {...baseProps} text={text} />
@@ -80,7 +76,7 @@ describe('<MarkdownDisplay />', () => {
});
it('renders a fenced code block without a language', () => {
const text = '```\nplain text\n```'.replace(/\n/g, EOL);
const text = '```\nplain text\n```';
const { lastFrame } = render(
<SettingsContext.Provider value={mockSettings}>
<MarkdownDisplay {...baseProps} text={text} />
@@ -90,7 +86,7 @@ describe('<MarkdownDisplay />', () => {
});
it('handles unclosed (pending) code blocks', () => {
const text = '```typescript\nlet y = 2;'.replace(/\n/g, EOL);
const text = '```typescript\nlet y = 2;';
const { lastFrame } = render(
<SettingsContext.Provider value={mockSettings}>
<MarkdownDisplay {...baseProps} text={text} isPending={true} />
@@ -104,7 +100,7 @@ describe('<MarkdownDisplay />', () => {
- item A
* item B
+ item C
`.replace(/\n/g, EOL);
`;
const { lastFrame } = render(
<SettingsContext.Provider value={mockSettings}>
<MarkdownDisplay {...baseProps} text={text} />
@@ -118,7 +114,7 @@ describe('<MarkdownDisplay />', () => {
* Level 1
* Level 2
* Level 3
`.replace(/\n/g, EOL);
`;
const { lastFrame } = render(
<SettingsContext.Provider value={mockSettings}>
<MarkdownDisplay {...baseProps} text={text} />
@@ -131,7 +127,7 @@ describe('<MarkdownDisplay />', () => {
const text = `
1. First item
2. Second item
`.replace(/\n/g, EOL);
`;
const { lastFrame } = render(
<SettingsContext.Provider value={mockSettings}>
<MarkdownDisplay {...baseProps} text={text} />
@@ -147,7 +143,7 @@ Hello
World
***
Test
`.replace(/\n/g, EOL);
`;
const { lastFrame } = render(
<SettingsContext.Provider value={mockSettings}>
<MarkdownDisplay {...baseProps} text={text} />
@@ -162,7 +158,7 @@ Test
|----------|:--------:|
| Cell 1 | Cell 2 |
| Cell 3 | Cell 4 |
`.replace(/\n/g, EOL);
`;
const { lastFrame } = render(
<SettingsContext.Provider value={mockSettings}>
<MarkdownDisplay {...baseProps} text={text} />
@@ -176,7 +172,7 @@ Test
Some text before.
| A | B |
|---|
| 1 | 2 |`.replace(/\n/g, EOL);
| 1 | 2 |`;
const { lastFrame } = render(
<SettingsContext.Provider value={mockSettings}>
<MarkdownDisplay {...baseProps} text={text} />
@@ -188,7 +184,7 @@ Some text before.
it('inserts a single space between paragraphs', () => {
const text = `Paragraph 1.
Paragraph 2.`.replace(/\n/g, EOL);
Paragraph 2.`;
const { lastFrame } = render(
<SettingsContext.Provider value={mockSettings}>
<MarkdownDisplay {...baseProps} text={text} />
@@ -211,7 +207,7 @@ some code
\`\`\`
Another paragraph.
`.replace(/\n/g, EOL);
`;
const { lastFrame } = render(
<SettingsContext.Provider value={mockSettings}>
<MarkdownDisplay {...baseProps} text={text} />
@@ -221,7 +217,7 @@ Another paragraph.
});
it('hides line numbers in code blocks when showLineNumbers is false', () => {
const text = '```javascript\nconst x = 1;\n```'.replace(/\n/g, EOL);
const text = '```javascript\nconst x = 1;\n```';
const settings = new LoadedSettings(
{ path: '', settings: {} },
{ path: '', settings: {} },
@@ -242,7 +238,7 @@ Another paragraph.
});
it('shows line numbers in code blocks by default', () => {
const text = '```javascript\nconst x = 1;\n```'.replace(/\n/g, EOL);
const text = '```javascript\nconst x = 1;\n```';
const { lastFrame } = render(
<SettingsContext.Provider value={mockSettings}>
<MarkdownDisplay {...baseProps} text={text} />
@@ -251,4 +247,21 @@ Another paragraph.
expect(lastFrame()).toMatchSnapshot();
expect(lastFrame()).toContain(' 1 ');
});
it('correctly splits lines using \\n regardless of platform EOL', () => {
// Test that the component uses \n for splitting, not EOL
const textWithUnixLineEndings = 'Line 1\nLine 2\nLine 3';
const { lastFrame } = render(
<SettingsContext.Provider value={mockSettings}>
<MarkdownDisplay {...baseProps} text={textWithUnixLineEndings} />
</SettingsContext.Provider>,
);
const output = lastFrame();
expect(output).toContain('Line 1');
expect(output).toContain('Line 2');
expect(output).toContain('Line 3');
expect(output).toMatchSnapshot();
});
});

View File

@@ -6,7 +6,6 @@
import React from 'react';
import { Text, Box } from 'ink';
import { EOL } from 'node:os';
import { Colors } from '../colors.js';
import { colorizeCode } from './CodeColorizer.js';
import { TableRenderer } from './TableRenderer.js';
@@ -35,7 +34,7 @@ const MarkdownDisplayInternal: React.FC<MarkdownDisplayProps> = ({
}) => {
if (!text) return <></>;
const lines = text.split(EOL);
const lines = text.split(`\n`);
const headerRegex = /^ *(#{1,4}) +(.*)/;
const codeFenceRegex = /^ *(`{3,}|~{3,}) *(\w*?) *$/;
const ulItemRegex = /^([ \t]*)([-*+]) +(.*)/;

View File

@@ -14,6 +14,12 @@ Another paragraph.
"
`;
exports[`<MarkdownDisplay /> > correctly splits lines using \\n regardless of platform EOL 1`] = `
"Line 1
Line 2
Line 3"
`;
exports[`<MarkdownDisplay /> > handles a table at the end of the input 1`] = `
"Some text before.
| A | B |

View File

@@ -126,6 +126,18 @@ describe('validateNonInterActiveAuth', () => {
expect(refreshAuthMock).toHaveBeenCalledWith(AuthType.USE_OPENAI);
});
it('uses configured QWEN_OAUTH if provided', async () => {
const nonInteractiveConfig: NonInteractiveConfig = {
refreshAuth: refreshAuthMock,
};
await validateNonInteractiveAuth(
AuthType.QWEN_OAUTH,
undefined,
nonInteractiveConfig,
);
expect(refreshAuthMock).toHaveBeenCalledWith(AuthType.QWEN_OAUTH);
});
it('uses USE_VERTEX_AI if GOOGLE_GENAI_USE_VERTEXAI is true (with GOOGLE_CLOUD_PROJECT and GOOGLE_CLOUD_LOCATION)', async () => {
process.env['GOOGLE_GENAI_USE_VERTEXAI'] = 'true';
process.env['GOOGLE_CLOUD_PROJECT'] = 'test-project';

View File

@@ -97,6 +97,18 @@ class GeminiAgent {
name: 'Vertex AI',
description: null,
},
{
id: AuthType.USE_OPENAI,
name: 'Use OpenAI API key',
description:
'Requires setting the `OPENAI_API_KEY` environment variable',
},
{
id: AuthType.QWEN_OAUTH,
name: 'Qwen OAuth',
description:
'OAuth authentication for Qwen models with 2000 daily requests',
},
];
return {
@@ -871,6 +883,16 @@ function toToolCallContent(toolResult: ToolResult): acp.ToolCallContent | null {
type: 'content',
content: { type: 'text', text: todoText },
};
} else if (
'type' in toolResult.returnDisplay &&
toolResult.returnDisplay.type === 'plan_summary'
) {
const planDisplay = toolResult.returnDisplay;
const planText = `${planDisplay.message}\n\n${planDisplay.plan}`;
return {
type: 'content',
content: { type: 'text', text: planText },
};
} else if ('fileDiff' in toolResult.returnDisplay) {
// Handle FileDiff
return {
@@ -942,6 +964,15 @@ function toPermissionOptions(
},
...basicPermissionOptions,
];
case 'plan':
return [
{
optionId: ToolConfirmationOutcome.ProceedAlways,
name: `Always Allow Plans`,
kind: 'allow_always',
},
...basicPermissionOptions,
];
default: {
const unreachable: never = confirmation;
throw new Error(`Unexpected: ${unreachable}`);

View File

@@ -19,3 +19,4 @@ export {
} from './src/telemetry/types.js';
export { makeFakeConfig } from './src/test-utils/config.js';
export * from './src/utils/pathReader.js';
export * from './src/utils/request-tokenizer/supportedImageFormats.js';

View File

@@ -1,6 +1,6 @@
{
"name": "@qwen-code/qwen-code-core",
"version": "0.0.11",
"version": "0.0.14-nightly.0",
"description": "Qwen Code Core",
"repository": {
"type": "git",

View File

@@ -710,6 +710,18 @@ describe('setApprovalMode with folder trust', () => {
expect(() => config.setApprovalMode(ApprovalMode.DEFAULT)).not.toThrow();
});
it('should NOT throw an error when setting PLAN mode in an untrusted folder', () => {
const config = new Config({
sessionId: 'test',
targetDir: '.',
debugMode: false,
model: 'test-model',
cwd: '.',
trustedFolder: false, // Untrusted
});
expect(() => config.setApprovalMode(ApprovalMode.PLAN)).not.toThrow();
});
it('should NOT throw an error when setting any mode in a trusted folder', () => {
const config = new Config({
sessionId: 'test',
@@ -722,6 +734,7 @@ describe('setApprovalMode with folder trust', () => {
expect(() => config.setApprovalMode(ApprovalMode.YOLO)).not.toThrow();
expect(() => config.setApprovalMode(ApprovalMode.AUTO_EDIT)).not.toThrow();
expect(() => config.setApprovalMode(ApprovalMode.DEFAULT)).not.toThrow();
expect(() => config.setApprovalMode(ApprovalMode.PLAN)).not.toThrow();
});
it('should NOT throw an error when setting any mode if trustedFolder is undefined', () => {
@@ -736,5 +749,87 @@ describe('setApprovalMode with folder trust', () => {
expect(() => config.setApprovalMode(ApprovalMode.YOLO)).not.toThrow();
expect(() => config.setApprovalMode(ApprovalMode.AUTO_EDIT)).not.toThrow();
expect(() => config.setApprovalMode(ApprovalMode.DEFAULT)).not.toThrow();
expect(() => config.setApprovalMode(ApprovalMode.PLAN)).not.toThrow();
});
describe('Model Switch Logging', () => {
it('should log model switch when setModel is called with different model', async () => {
const config = new Config({
sessionId: 'test-model-switch',
targetDir: '.',
debugMode: false,
model: 'qwen3-coder-plus',
cwd: '.',
});
// Initialize the config to set up content generator
await config.initialize();
// Mock the logger's logModelSwitch method
const logModelSwitchSpy = vi.spyOn(config['logger']!, 'logModelSwitch');
// Change the model
await config.setModel('qwen-vl-max-latest', {
reason: 'vision_auto_switch',
context: 'Test model switch',
});
// Verify that logModelSwitch was called with correct parameters
expect(logModelSwitchSpy).toHaveBeenCalledWith({
fromModel: 'qwen3-coder-plus',
toModel: 'qwen-vl-max-latest',
reason: 'vision_auto_switch',
context: 'Test model switch',
});
});
it('should not log when setModel is called with same model', async () => {
const config = new Config({
sessionId: 'test-same-model',
targetDir: '.',
debugMode: false,
model: 'qwen3-coder-plus',
cwd: '.',
});
// Initialize the config to set up content generator
await config.initialize();
// Mock the logger's logModelSwitch method
const logModelSwitchSpy = vi.spyOn(config['logger']!, 'logModelSwitch');
// Set the same model
await config.setModel('qwen3-coder-plus');
// Verify that logModelSwitch was not called
expect(logModelSwitchSpy).not.toHaveBeenCalled();
});
it('should use default reason when no options provided', async () => {
const config = new Config({
sessionId: 'test-default-reason',
targetDir: '.',
debugMode: false,
model: 'qwen3-coder-plus',
cwd: '.',
});
// Initialize the config to set up content generator
await config.initialize();
// Mock the logger's logModelSwitch method
const logModelSwitchSpy = vi.spyOn(config['logger']!, 'logModelSwitch');
// Change the model without options
await config.setModel('qwen-vl-max-latest');
// Verify that logModelSwitch was called with default reason
expect(logModelSwitchSpy).toHaveBeenCalledWith({
fromModel: 'qwen3-coder-plus',
toModel: 'qwen-vl-max-latest',
reason: 'manual',
context: undefined,
});
});
});
});

View File

@@ -33,6 +33,7 @@ import {
import { logCliConfiguration, logIdeConnection } from '../telemetry/loggers.js';
import { IdeConnectionEvent, IdeConnectionType } from '../telemetry/types.js';
import { EditTool } from '../tools/edit.js';
import { ExitPlanModeTool } from '../tools/exitPlanMode.js';
import { GlobTool } from '../tools/glob.js';
import { GrepTool } from '../tools/grep.js';
import { LSTool } from '../tools/ls.js';
@@ -56,16 +57,20 @@ import {
DEFAULT_GEMINI_FLASH_MODEL,
} from './models.js';
import { Storage } from './storage.js';
import { Logger, type ModelSwitchEvent } from '../core/logger.js';
// Re-export OAuth config type
export type { AnyToolInvocation, MCPOAuthConfig };
export enum ApprovalMode {
PLAN = 'plan',
DEFAULT = 'default',
AUTO_EDIT = 'autoEdit',
AUTO_EDIT = 'auto-edit',
YOLO = 'yolo',
}
export const APPROVAL_MODES = Object.values(ApprovalMode);
export interface AccessibilitySettings {
disableLoadingPhrases?: boolean;
screenReader?: boolean;
@@ -238,6 +243,8 @@ export interface ConfigParameters {
skipNextSpeakerCheck?: boolean;
extensionManagement?: boolean;
enablePromptCompletion?: boolean;
skipLoopDetection?: boolean;
vlmSwitchMode?: string;
}
export class Config {
@@ -328,9 +335,12 @@ export class Config {
private readonly skipNextSpeakerCheck: boolean;
private readonly extensionManagement: boolean;
private readonly enablePromptCompletion: boolean = false;
private readonly skipLoopDetection: boolean;
private readonly vlmSwitchMode: string | undefined;
private initialized: boolean = false;
readonly storage: Storage;
private readonly fileExclusions: FileExclusions;
private logger: Logger | null = null;
constructor(params: ConfigParameters) {
this.sessionId = params.sessionId;
@@ -410,6 +420,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;
@@ -419,8 +432,15 @@ export class Config {
this.extensionManagement = params.extensionManagement ?? false;
this.storage = new Storage(this.targetDir);
this.enablePromptCompletion = params.enablePromptCompletion ?? false;
this.vlmSwitchMode = params.vlmSwitchMode;
this.fileExclusions = new FileExclusions(this);
// Initialize logger asynchronously
this.logger = new Logger(this.sessionId, this.storage);
this.logger.initialize().catch((error) => {
console.debug('Failed to initialize logger:', error);
});
if (params.contextFileName) {
setGeminiMdFilename(params.contextFileName);
}
@@ -512,10 +532,48 @@ export class Config {
return this.contentGeneratorConfig?.model || this.model;
}
setModel(newModel: string): void {
async setModel(
newModel: string,
options?: {
reason?: ModelSwitchEvent['reason'];
context?: string;
},
): Promise<void> {
const oldModel = this.getModel();
if (this.contentGeneratorConfig) {
this.contentGeneratorConfig.model = newModel;
}
// Log the model switch if the model actually changed
if (oldModel !== newModel && this.logger) {
const switchEvent: ModelSwitchEvent = {
fromModel: oldModel,
toModel: newModel,
reason: options?.reason || 'manual',
context: options?.context,
};
// Log asynchronously to avoid blocking
this.logger.logModelSwitch(switchEvent).catch((error) => {
console.debug('Failed to log model switch:', error);
});
}
// Reinitialize chat with updated configuration while preserving history
const geminiClient = this.getGeminiClient();
if (geminiClient && geminiClient.isInitialized()) {
// Now await the reinitialize operation to ensure completion
try {
await geminiClient.reinitialize();
} catch (error) {
console.error(
'Failed to reinitialize chat with updated config:',
error,
);
throw error; // Re-throw to let callers handle the error
}
}
}
isInFallbackMode(): boolean {
@@ -646,7 +704,11 @@ export class Config {
}
setApprovalMode(mode: ApprovalMode): void {
if (this.isTrustedFolder() === false && mode !== ApprovalMode.DEFAULT) {
if (
this.isTrustedFolder() === false &&
mode !== ApprovalMode.DEFAULT &&
mode !== ApprovalMode.PLAN
) {
throw new Error(
'Cannot enable privileged approval modes in an untrusted folder.',
);
@@ -917,6 +979,14 @@ export class Config {
return this.enablePromptCompletion;
}
getSkipLoopDetection(): boolean {
return this.skipLoopDetection;
}
getVlmSwitchMode(): string | undefined {
return this.vlmSwitchMode;
}
async getGitService(): Promise<GitService> {
if (!this.gitService) {
this.gitService = new GitService(this.targetDir, this.storage);
@@ -981,11 +1051,12 @@ export class Config {
registerCoreTool(GlobTool, this);
registerCoreTool(EditTool, this);
registerCoreTool(WriteFileTool, this);
registerCoreTool(WebFetchTool, this);
registerCoreTool(ReadManyFilesTool, this);
registerCoreTool(ShellTool, this);
registerCoreTool(MemoryTool);
registerCoreTool(TodoWriteTool, this);
registerCoreTool(ExitPlanModeTool, this);
registerCoreTool(WebFetchTool, this);
// Conditionally register web search tool only if Tavily API key is set
if (this.getTavilyApiKey()) {
registerCoreTool(WebSearchTool, this);

View File

@@ -41,7 +41,7 @@ describe('Flash Model Fallback Configuration', () => {
// with the fallback mechanism. This will be necessary we introduce more
// intelligent model routing.
describe('setModel', () => {
it('should only mark as switched if contentGeneratorConfig exists', () => {
it('should only mark as switched if contentGeneratorConfig exists', async () => {
// Create config without initializing contentGeneratorConfig
const newConfig = new Config({
sessionId: 'test-session-2',
@@ -52,15 +52,15 @@ describe('Flash Model Fallback Configuration', () => {
});
// Should not crash when contentGeneratorConfig is undefined
newConfig.setModel(DEFAULT_GEMINI_FLASH_MODEL);
await newConfig.setModel(DEFAULT_GEMINI_FLASH_MODEL);
expect(newConfig.isInFallbackMode()).toBe(false);
});
});
describe('getModel', () => {
it('should return contentGeneratorConfig model if available', () => {
it('should return contentGeneratorConfig model if available', async () => {
// Simulate initialized content generator config
config.setModel(DEFAULT_GEMINI_FLASH_MODEL);
await config.setModel(DEFAULT_GEMINI_FLASH_MODEL);
expect(config.getModel()).toBe(DEFAULT_GEMINI_FLASH_MODEL);
});
@@ -88,8 +88,8 @@ describe('Flash Model Fallback Configuration', () => {
expect(config.isInFallbackMode()).toBe(false);
});
it('should persist switched state throughout session', () => {
config.setModel(DEFAULT_GEMINI_FLASH_MODEL);
it('should persist switched state throughout session', async () => {
await config.setModel(DEFAULT_GEMINI_FLASH_MODEL);
// Setting state for fallback mode as is expected of clients
config.setFallbackMode(true);
expect(config.isInFallbackMode()).toBe(true);

View File

@@ -4,11 +4,10 @@
* SPDX-License-Identifier: Apache-2.0
*/
export const DEFAULT_QWEN_MODEL = 'qwen3-coder-plus';
// We do not have a fallback model for now, but note it here anyway.
export const DEFAULT_QWEN_FLASH_MODEL = 'qwen3-coder-flash';
export const DEFAULT_QWEN_MODEL = 'coder-model';
export const DEFAULT_QWEN_FLASH_MODEL = 'coder-model';
export const DEFAULT_GEMINI_MODEL = 'qwen3-coder-plus';
export const DEFAULT_GEMINI_MODEL = 'coder-model';
export const DEFAULT_GEMINI_FLASH_MODEL = 'gemini-2.5-flash';
export const DEFAULT_GEMINI_FLASH_LITE_MODEL = 'gemini-2.5-flash-lite';

View File

@@ -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 {

File diff suppressed because it is too large Load Diff

View File

@@ -5,9 +5,10 @@
*/
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import { OpenAIContentGenerator } from '../openaiContentGenerator.js';
import { OpenAIContentGenerator } from '../openaiContentGenerator/openaiContentGenerator.js';
import type { Config } from '../../config/config.js';
import { AuthType } from '../contentGenerator.js';
import type { OpenAICompatibleProvider } from '../openaiContentGenerator/provider/index.js';
import OpenAI from 'openai';
// Mock OpenAI
@@ -30,6 +31,7 @@ describe('OpenAIContentGenerator Timeout Handling', () => {
let mockConfig: Config;
// eslint-disable-next-line @typescript-eslint/no-explicit-any
let mockOpenAIClient: any;
let mockProvider: OpenAICompatibleProvider;
beforeEach(() => {
// Reset mocks
@@ -42,6 +44,7 @@ describe('OpenAIContentGenerator Timeout Handling', () => {
mockConfig = {
getContentGeneratorConfig: vi.fn().mockReturnValue({
authType: 'openai',
enableOpenAILogging: false,
}),
getCliVersion: vi.fn().mockReturnValue('1.0.0'),
} as unknown as Config;
@@ -53,17 +56,34 @@ describe('OpenAIContentGenerator Timeout Handling', () => {
create: vi.fn(),
},
},
embeddings: {
create: vi.fn(),
},
};
vi.mocked(OpenAI).mockImplementation(() => mockOpenAIClient);
// Create mock provider
mockProvider = {
buildHeaders: vi.fn().mockReturnValue({
'User-Agent': 'QwenCode/1.0.0 (test; test)',
}),
buildClient: vi.fn().mockReturnValue(mockOpenAIClient),
buildRequest: vi.fn().mockImplementation((req) => req),
};
// Create generator instance
const contentGeneratorConfig = {
model: 'gpt-4',
apiKey: 'test-key',
authType: AuthType.USE_OPENAI,
enableOpenAILogging: false,
};
generator = new OpenAIContentGenerator(contentGeneratorConfig, mockConfig);
generator = new OpenAIContentGenerator(
contentGeneratorConfig,
mockConfig,
mockProvider,
);
});
afterEach(() => {
@@ -209,7 +229,7 @@ describe('OpenAIContentGenerator Timeout Handling', () => {
await expect(
generator.generateContentStream(request, 'test-prompt-id'),
).rejects.toThrow(
/Streaming setup timeout after \d+s\. Try reducing input length or increasing timeout in config\./,
/Streaming request timeout after \d+s\. Try reducing input length or increasing timeout in config\./,
);
});
@@ -227,12 +247,8 @@ describe('OpenAIContentGenerator Timeout Handling', () => {
} catch (error: unknown) {
const errorMessage =
error instanceof Error ? error.message : String(error);
expect(errorMessage).toContain(
'Streaming setup timeout troubleshooting:',
);
expect(errorMessage).toContain(
'Check network connectivity and firewall settings',
);
expect(errorMessage).toContain('Streaming timeout troubleshooting:');
expect(errorMessage).toContain('Check network connectivity');
expect(errorMessage).toContain('Consider using non-streaming mode');
}
});
@@ -246,23 +262,21 @@ describe('OpenAIContentGenerator Timeout Handling', () => {
authType: AuthType.USE_OPENAI,
baseUrl: 'http://localhost:8080',
};
new OpenAIContentGenerator(contentGeneratorConfig, mockConfig);
new OpenAIContentGenerator(
contentGeneratorConfig,
mockConfig,
mockProvider,
);
// Verify OpenAI client was created with timeout config
expect(OpenAI).toHaveBeenCalledWith({
apiKey: 'test-key',
baseURL: 'http://localhost:8080',
timeout: 120000,
maxRetries: 3,
defaultHeaders: {
'User-Agent': expect.stringMatching(/^QwenCode/),
},
});
// Verify provider buildClient was called
expect(mockProvider.buildClient).toHaveBeenCalled();
});
it('should use custom timeout from config', () => {
const customConfig = {
getContentGeneratorConfig: vi.fn().mockReturnValue({}),
getContentGeneratorConfig: vi.fn().mockReturnValue({
enableOpenAILogging: false,
}),
getCliVersion: vi.fn().mockReturnValue('1.0.0'),
} as unknown as Config;
@@ -274,22 +288,31 @@ describe('OpenAIContentGenerator Timeout Handling', () => {
timeout: 300000,
maxRetries: 5,
};
new OpenAIContentGenerator(contentGeneratorConfig, customConfig);
expect(OpenAI).toHaveBeenCalledWith({
apiKey: 'test-key',
baseURL: 'http://localhost:8080',
timeout: 300000,
maxRetries: 5,
defaultHeaders: {
'User-Agent': expect.stringMatching(/^QwenCode/),
},
});
// Create a custom mock provider for this test
const customMockProvider: OpenAICompatibleProvider = {
buildHeaders: vi.fn().mockReturnValue({
'User-Agent': 'QwenCode/1.0.0 (test; test)',
}),
buildClient: vi.fn().mockReturnValue(mockOpenAIClient),
buildRequest: vi.fn().mockImplementation((req) => req),
};
new OpenAIContentGenerator(
contentGeneratorConfig,
customConfig,
customMockProvider,
);
// Verify provider buildClient was called
expect(customMockProvider.buildClient).toHaveBeenCalled();
});
it('should handle missing timeout config gracefully', () => {
const noTimeoutConfig = {
getContentGeneratorConfig: vi.fn().mockReturnValue({}),
getContentGeneratorConfig: vi.fn().mockReturnValue({
enableOpenAILogging: false,
}),
getCliVersion: vi.fn().mockReturnValue('1.0.0'),
} as unknown as Config;
@@ -299,17 +322,24 @@ describe('OpenAIContentGenerator Timeout Handling', () => {
authType: AuthType.USE_OPENAI,
baseUrl: 'http://localhost:8080',
};
new OpenAIContentGenerator(contentGeneratorConfig, noTimeoutConfig);
expect(OpenAI).toHaveBeenCalledWith({
apiKey: 'test-key',
baseURL: 'http://localhost:8080',
timeout: 120000, // default
maxRetries: 3, // default
defaultHeaders: {
'User-Agent': expect.stringMatching(/^QwenCode/),
},
});
// Create a custom mock provider for this test
const noTimeoutMockProvider: OpenAICompatibleProvider = {
buildHeaders: vi.fn().mockReturnValue({
'User-Agent': 'QwenCode/1.0.0 (test; test)',
}),
buildClient: vi.fn().mockReturnValue(mockOpenAIClient),
buildRequest: vi.fn().mockImplementation((req) => req),
};
new OpenAIContentGenerator(
contentGeneratorConfig,
noTimeoutConfig,
noTimeoutMockProvider,
);
// Verify provider buildClient was called
expect(noTimeoutMockProvider.buildClient).toHaveBeenCalled();
});
});

View File

@@ -23,6 +23,7 @@ import type {
} from '@google/genai';
import { GoogleGenAI } from '@google/genai';
import { findIndexAfterFraction, GeminiClient } from './client.js';
import { getPlanModeSystemReminder } from './prompts.js';
import {
AuthType,
type ContentGenerator,
@@ -50,6 +51,10 @@ const mockGenerateContentFn = vi.fn();
const mockEmbedContentFn = vi.fn();
const mockTurnRunFn = vi.fn();
let ApprovalModeEnum: typeof import('../config/config.js').ApprovalMode;
// eslint-disable-next-line @typescript-eslint/no-explicit-any
let mockConfigObject: any;
vi.mock('@google/genai');
vi.mock('./turn', async (importOriginal) => {
const actual = await importOriginal<typeof import('./turn.js')>();
@@ -178,6 +183,12 @@ describe('Gemini Client (client.ts)', () => {
beforeEach(async () => {
vi.resetAllMocks();
ApprovalModeEnum = (
await vi.importActual<typeof import('../config/config.js')>(
'../config/config.js',
)
).ApprovalMode;
// Disable 429 simulation for tests
setSimulate429(false);
@@ -226,7 +237,10 @@ describe('Gemini Client (client.ts)', () => {
vertexai: false,
authType: AuthType.USE_GEMINI,
};
const mockConfigObject = {
const mockSubagentManager = {
listSubagents: vi.fn().mockResolvedValue([]),
};
mockConfigObject = {
getContentGeneratorConfig: vi
.fn()
.mockReturnValue(contentGeneratorConfig),
@@ -249,6 +263,7 @@ describe('Gemini Client (client.ts)', () => {
getNoBrowser: vi.fn().mockReturnValue(false),
getSystemPromptMappings: vi.fn().mockReturnValue(undefined),
getUsageStatisticsEnabled: vi.fn().mockReturnValue(true),
getApprovalMode: vi.fn().mockReturnValue(ApprovalModeEnum.DEFAULT),
getIdeModeFeature: vi.fn().mockReturnValue(false),
getIdeMode: vi.fn().mockReturnValue(true),
getDebugMode: vi.fn().mockReturnValue(false),
@@ -260,6 +275,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(
@@ -436,7 +453,8 @@ describe('Gemini Client (client.ts)', () => {
);
});
it('should allow overriding model and config', async () => {
/* We now use model in contentGeneratorConfig in most cases. */
it.skip('should allow overriding model and config', async () => {
const contents: Content[] = [
{ role: 'user', parts: [{ text: 'hello' }] },
];
@@ -942,6 +960,42 @@ describe('Gemini Client (client.ts)', () => {
});
describe('sendMessageStream', () => {
it('injects a plan mode reminder before user queries when approval mode is PLAN', async () => {
const mockStream = (async function* () {})();
mockTurnRunFn.mockReturnValue(mockStream);
mockConfigObject.getApprovalMode.mockReturnValue(ApprovalModeEnum.PLAN);
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;
const stream = client.sendMessageStream(
'Plan mode test',
new AbortController().signal,
'prompt-plan-1',
);
await fromAsync(stream);
expect(mockTurnRunFn).toHaveBeenCalledWith(
[getPlanModeSystemReminder(), 'Plan mode test'],
expect.any(Object),
);
mockConfigObject.getApprovalMode.mockReturnValue(
ApprovalModeEnum.DEFAULT,
);
});
it('emits a compression event when the context was automatically compressed', async () => {
// Arrange
const mockStream = (async function* () {
@@ -1170,10 +1224,7 @@ ${JSON.stringify(
// Assert
expect(ideContext.getIdeContext).toHaveBeenCalled();
expect(mockTurnRunFn).toHaveBeenCalledWith(
initialRequest,
expect.any(Object),
);
expect(mockTurnRunFn).toHaveBeenCalledWith(['Hi'], expect.any(Object));
});
it('should add context if ideMode is enabled and there is one active file', async () => {
@@ -2291,6 +2342,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', () => {
@@ -2450,4 +2595,82 @@ ${JSON.stringify(
expect(mockChat.setHistory).toHaveBeenCalledWith(historyWithThoughts);
});
});
describe('initialize', () => {
it('should accept extraHistory parameter and pass it to startChat', async () => {
const mockStartChat = vi.fn().mockResolvedValue({});
client['startChat'] = mockStartChat;
const extraHistory = [
{ role: 'user', parts: [{ text: 'Previous message' }] },
{ role: 'model', parts: [{ text: 'Previous response' }] },
];
const contentGeneratorConfig = {
model: 'test-model',
apiKey: 'test-key',
vertexai: false,
authType: AuthType.USE_GEMINI,
};
await client.initialize(contentGeneratorConfig, extraHistory);
expect(mockStartChat).toHaveBeenCalledWith(extraHistory, 'test-model');
});
it('should use empty array when no extraHistory is provided', async () => {
const mockStartChat = vi.fn().mockResolvedValue({});
client['startChat'] = mockStartChat;
const contentGeneratorConfig = {
model: 'test-model',
apiKey: 'test-key',
vertexai: false,
authType: AuthType.USE_GEMINI,
};
await client.initialize(contentGeneratorConfig);
expect(mockStartChat).toHaveBeenCalledWith([], 'test-model');
});
});
describe('reinitialize', () => {
it('should reinitialize with preserved user history', async () => {
// Mock the initialize method
const mockInitialize = vi.fn().mockResolvedValue(undefined);
client['initialize'] = mockInitialize;
// Set up initial history with environment context + user messages
const mockHistory = [
{ role: 'user', parts: [{ text: 'Environment context' }] },
{ role: 'model', parts: [{ text: 'Got it. Thanks for the context!' }] },
{ role: 'user', parts: [{ text: 'User message 1' }] },
{ role: 'model', parts: [{ text: 'Model response 1' }] },
];
const mockChat = {
getHistory: vi.fn().mockReturnValue(mockHistory),
};
client['chat'] = mockChat as unknown as GeminiChat;
client['getHistory'] = vi.fn().mockReturnValue(mockHistory);
await client.reinitialize();
// Should call initialize with preserved user history (excluding first 2 env messages)
expect(mockInitialize).toHaveBeenCalledWith(
expect.any(Object), // contentGeneratorConfig
[
{ role: 'user', parts: [{ text: 'User message 1' }] },
{ role: 'model', parts: [{ text: 'Model response 1' }] },
],
);
});
it('should not throw error when chat is not initialized', async () => {
client['chat'] = undefined;
await expect(client.reinitialize()).resolves.not.toThrow();
});
});
});

View File

@@ -17,6 +17,7 @@ import type {
import { ProxyAgent, setGlobalDispatcher } from 'undici';
import type { UserTierId } from '../code_assist/types.js';
import type { Config } from '../config/config.js';
import { ApprovalMode } from '../config/config.js';
import { DEFAULT_GEMINI_FLASH_MODEL } from '../config/models.js';
import type { File, IdeContext } from '../ide/ideContext.js';
import { ideContext } from '../ide/ideContext.js';
@@ -29,6 +30,7 @@ import {
makeChatCompressionEvent,
NextSpeakerCheckEvent,
} from '../telemetry/types.js';
import { TaskTool } from '../tools/task.js';
import {
getDirectoryContextString,
getEnvironmentContext,
@@ -39,6 +41,7 @@ import { getFunctionCalls } from '../utils/generateContentResponseUtilities.js';
import { isFunctionResponse } from '../utils/messageInspectors.js';
import { checkNextSpeaker } from '../utils/nextSpeakerChecker.js';
import { retryWithBackoff } from '../utils/retry.js';
import { flatMapTextParts } from '../utils/partUtils.js';
import type {
ContentGenerator,
ContentGeneratorConfig,
@@ -49,6 +52,8 @@ import {
getCompressionPrompt,
getCoreSystemPrompt,
getCustomSystemPrompt,
getPlanModeSystemReminder,
getSubagentSystemReminder,
} from './prompts.js';
import { tokenLimit } from './tokenLimits.js';
import type { ChatCompressionInfo, ServerGeminiStreamEvent } from './turn.js';
@@ -137,13 +142,24 @@ export class GeminiClient {
this.lastPromptId = this.config.getSessionId();
}
async initialize(contentGeneratorConfig: ContentGeneratorConfig) {
async initialize(
contentGeneratorConfig: ContentGeneratorConfig,
extraHistory?: Content[],
) {
this.contentGenerator = await createContentGenerator(
contentGeneratorConfig,
this.config,
this.config.getSessionId(),
);
this.chat = await this.startChat();
/**
* Always take the model from contentGeneratorConfig to initialize,
* despite the `this.config.contentGeneratorConfig` is not updated yet because in
* `Config` it will not be updated until the initialization is successful.
*/
this.chat = await this.startChat(
extraHistory || [],
contentGeneratorConfig.model,
);
}
getContentGenerator(): ContentGenerator {
@@ -216,6 +232,28 @@ export class GeminiClient {
this.chat = await this.startChat();
}
/**
* Reinitializes the chat with the current contentGeneratorConfig while preserving chat history.
* This creates a new chat object using the existing history and updated configuration.
* Should be called when configuration changes (model, auth, etc.) to ensure consistency.
*/
async reinitialize(): Promise<void> {
if (!this.chat) {
return;
}
// Preserve the current chat history (excluding environment context)
const currentHistory = this.getHistory();
// Remove the initial environment context (first 2 messages: user env + model acknowledgment)
const userHistory = currentHistory.slice(2);
// Get current content generator config and reinitialize with preserved history
const contentGeneratorConfig = this.config.getContentGeneratorConfig();
if (contentGeneratorConfig) {
await this.initialize(contentGeneratorConfig, userHistory);
}
}
async addDirectoryContext(): Promise<void> {
if (!this.chat) {
return;
@@ -227,7 +265,10 @@ export class GeminiClient {
});
}
async startChat(extraHistory?: Content[]): Promise<GeminiChat> {
async startChat(
extraHistory?: Content[],
model?: string,
): Promise<GeminiChat> {
this.forceFullIdeContext = true;
this.hasFailedCompressionAttempt = false;
const envParts = await getEnvironmentContext(this.config);
@@ -247,9 +288,13 @@ export class GeminiClient {
];
try {
const userMemory = this.config.getUserMemory();
const systemInstruction = getCoreSystemPrompt(userMemory);
const systemInstruction = getCoreSystemPrompt(
userMemory,
{},
model || this.config.getModel(),
);
const generateContentConfigWithThinking = isThinkingSupported(
this.config.getModel(),
model || this.config.getModel(),
)
? {
...this.generateContentConfig,
@@ -455,7 +500,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;
}
@@ -488,7 +534,11 @@ export class GeminiClient {
// Get all the content that would be sent in an API call
const currentHistory = this.getChat().getHistory(true);
const userMemory = this.config.getUserMemory();
const systemPrompt = getCoreSystemPrompt(userMemory);
const systemPrompt = getCoreSystemPrompt(
userMemory,
{},
this.config.getModel(),
);
const environment = await getEnvironmentContext(this.config);
// Create a mock request content to count total tokens
@@ -554,18 +604,45 @@ export class GeminiClient {
const turn = new Turn(this.getChat(), prompt_id);
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)) {
if (!this.config.getSkipLoopDetection()) {
const loopDetected = await this.loopDetector.turnStarted(signal);
if (loopDetected) {
yield { type: GeminiEventType.LoopDetected };
return turn;
}
}
// append system reminders to the request
let requestToSent = await flatMapTextParts(request, async (text) => [text]);
if (isNewPrompt) {
const systemReminders = [];
// add subagent system reminder if there are subagents
const hasTaskTool = this.config.getToolRegistry().getTool(TaskTool.Name);
const subagents = (await this.config.getSubagentManager().listSubagents())
.filter((subagent) => subagent.level !== 'builtin')
.map((subagent) => subagent.name);
if (hasTaskTool && subagents.length > 0) {
systemReminders.push(getSubagentSystemReminder(subagents));
}
// add plan mode system reminder if approval mode is plan
if (this.config.getApprovalMode() === ApprovalMode.PLAN) {
systemReminders.push(getPlanModeSystemReminder());
}
requestToSent = [...systemReminders, ...requestToSent];
}
const resultStream = turn.run(requestToSent, signal);
for await (const event of resultStream) {
if (!this.config.getSkipLoopDetection()) {
if (this.loopDetector.addAndCheck(event)) {
yield { type: GeminiEventType.LoopDetected };
return turn;
}
}
yield event;
if (event.type === GeminiEventType.Error) {
return turn;
@@ -620,14 +697,18 @@ export class GeminiClient {
model?: string,
config: GenerateContentConfig = {},
): Promise<Record<string, unknown>> {
// Use current model from config instead of hardcoded Flash model
const modelToUse =
model || this.config.getModel() || DEFAULT_GEMINI_FLASH_MODEL;
/**
* TODO: ensure `model` consistency among GeminiClient, GeminiChat, and ContentGenerator
* `model` passed to generateContent is not respected as we always use contentGenerator
* We should ignore model for now because some calls use `DEFAULT_GEMINI_FLASH_MODEL`
* which is not available as `qwen3-coder-flash`
*/
const modelToUse = this.config.getModel() || DEFAULT_GEMINI_FLASH_MODEL;
try {
const userMemory = this.config.getUserMemory();
const finalSystemInstruction = config.systemInstruction
? getCustomSystemPrompt(config.systemInstruction, userMemory)
: getCoreSystemPrompt(userMemory);
: getCoreSystemPrompt(userMemory, {}, modelToUse);
const requestConfig = {
abortSignal,
@@ -718,7 +799,7 @@ export class GeminiClient {
const userMemory = this.config.getUserMemory();
const finalSystemInstruction = generationConfig.systemInstruction
? getCustomSystemPrompt(generationConfig.systemInstruction, userMemory)
: getCoreSystemPrompt(userMemory);
: getCoreSystemPrompt(userMemory, {}, this.config.getModel());
const requestConfig: GenerateContentConfig = {
abortSignal,
@@ -981,7 +1062,7 @@ export class GeminiClient {
error,
);
if (accepted !== false && accepted !== null) {
this.config.setModel(fallbackModel);
await this.config.setModel(fallbackModel);
this.config.setFallbackMode(true);
return fallbackModel;
}

View File

@@ -10,11 +10,13 @@ import { describe, expect, it, vi } from 'vitest';
import type {
Config,
ToolCallConfirmationDetails,
ToolCallRequestInfo,
ToolConfirmationPayload,
ToolInvocation,
ToolResult,
ToolResultDisplay,
ToolRegistry,
SuccessfulToolCall,
} from '../index.js';
import {
ApprovalMode,
@@ -24,11 +26,16 @@ import {
ToolConfirmationOutcome,
} from '../index.js';
import { MockModifiableTool, MockTool } from '../test-utils/tools.js';
import type { ToolCall, WaitingToolCall } from './coreToolScheduler.js';
import type {
ToolCall,
WaitingToolCall,
ErroredToolCall,
} from './coreToolScheduler.js';
import {
CoreToolScheduler,
convertToFunctionResponse,
} from './coreToolScheduler.js';
import { getPlanModeSystemReminder } from './prompts.js';
class TestApprovalTool extends BaseDeclarativeTool<{ id: string }, ToolResult> {
static readonly Name = 'testApprovalTool';
@@ -101,6 +108,49 @@ class TestApprovalInvocation extends BaseToolInvocation<
}
}
class SimpleToolInvocation extends BaseToolInvocation<
Record<string, unknown>,
ToolResult
> {
constructor(
params: Record<string, unknown>,
private readonly executeImpl: () => Promise<ToolResult> | ToolResult,
) {
super(params);
}
getDescription(): string {
return 'simple tool invocation';
}
async execute(): Promise<ToolResult> {
return await Promise.resolve(this.executeImpl());
}
}
class SimpleTool extends BaseDeclarativeTool<
Record<string, unknown>,
ToolResult
> {
constructor(
name: string,
kind: Kind,
private readonly executeImpl: () => Promise<ToolResult> | ToolResult,
) {
super(name, name, 'Simple test tool', kind, {
type: 'object',
properties: {},
additionalProperties: true,
});
}
protected createInvocation(
params: Record<string, unknown>,
): ToolInvocation<Record<string, unknown>, ToolResult> {
return new SimpleToolInvocation(params, this.executeImpl);
}
}
async function waitForStatus(
onToolCallsUpdate: Mock,
status: 'awaiting_approval' | 'executing' | 'success' | 'error' | 'cancelled',
@@ -197,6 +247,249 @@ describe('CoreToolScheduler', () => {
expect(completedCalls[0].status).toBe('cancelled');
});
describe('plan mode enforcement', () => {
it('returns plan reminder and skips execution for edit tools', async () => {
const executeSpy = vi.fn().mockResolvedValue({
llmContent: 'should not execute',
returnDisplay: 'should not execute',
});
// Use MockTool with shouldConfirm=true to simulate a tool that requires confirmation
const tool = new MockTool('write_file');
tool.shouldConfirm = true;
tool.executeFn = executeSpy;
const mockToolRegistry = {
getTool: vi.fn().mockReturnValue(tool),
getAllToolNames: vi.fn().mockReturnValue([tool.name]),
} as unknown as ToolRegistry;
const onAllToolCallsComplete = vi.fn();
const onToolCallsUpdate = vi.fn();
const mockConfig = {
getSessionId: () => 'plan-session',
getUsageStatisticsEnabled: () => true,
getDebugMode: () => false,
getApprovalMode: vi.fn().mockReturnValue(ApprovalMode.PLAN),
getAllowedTools: () => [],
getContentGeneratorConfig: () => ({
model: 'test-model',
authType: 'oauth-personal',
}),
getToolRegistry: () => mockToolRegistry,
} as unknown as Config;
const scheduler = new CoreToolScheduler({
config: mockConfig,
onAllToolCallsComplete,
onToolCallsUpdate,
getPreferredEditor: () => 'vscode',
onEditorClose: vi.fn(),
});
const request: ToolCallRequestInfo = {
callId: 'plan-1',
name: 'write_file',
args: {},
isClientInitiated: false,
prompt_id: 'prompt-plan',
};
await scheduler.schedule([request], new AbortController().signal);
const errorCall = (await waitForStatus(
onToolCallsUpdate,
'error',
)) as ErroredToolCall;
expect(executeSpy).not.toHaveBeenCalled();
expect(
errorCall.response.responseParts[0]?.functionResponse?.response?.[
'output'
],
).toBe(getPlanModeSystemReminder());
expect(errorCall.response.resultDisplay).toContain('Plan mode');
});
it('allows read tools to execute in plan mode', async () => {
const executeSpy = vi.fn().mockResolvedValue({
llmContent: 'read ok',
returnDisplay: 'read ok',
});
const tool = new SimpleTool('read_file', Kind.Read, executeSpy);
const mockToolRegistry = {
getTool: vi.fn().mockReturnValue(tool),
getAllToolNames: vi.fn().mockReturnValue([tool.name]),
} as unknown as ToolRegistry;
const onAllToolCallsComplete = vi.fn();
const onToolCallsUpdate = vi.fn();
const mockConfig = {
getSessionId: () => 'plan-session',
getUsageStatisticsEnabled: () => true,
getDebugMode: () => false,
getApprovalMode: vi.fn().mockReturnValue(ApprovalMode.PLAN),
getAllowedTools: () => [],
getContentGeneratorConfig: () => ({
model: 'test-model',
authType: 'oauth-personal',
}),
getToolRegistry: () => mockToolRegistry,
} as unknown as Config;
const scheduler = new CoreToolScheduler({
config: mockConfig,
onAllToolCallsComplete,
onToolCallsUpdate,
getPreferredEditor: () => 'vscode',
onEditorClose: vi.fn(),
});
const request: ToolCallRequestInfo = {
callId: 'plan-2',
name: tool.name,
args: {},
isClientInitiated: false,
prompt_id: 'prompt-plan',
};
await scheduler.schedule([request], new AbortController().signal);
const successCall = (await waitForStatus(
onToolCallsUpdate,
'success',
)) as SuccessfulToolCall;
expect(executeSpy).toHaveBeenCalledTimes(1);
expect(
successCall.response.responseParts[0]?.functionResponse?.response?.[
'output'
],
).toBe('read ok');
});
it('enforces shell command restrictions in plan mode', async () => {
const executeSpyAllowed = vi.fn().mockResolvedValue({
llmContent: 'shell ok',
returnDisplay: 'shell ok',
});
const allowedTool = new SimpleTool(
'run_shell_command',
Kind.Execute,
executeSpyAllowed,
);
const allowedToolRegistry = {
getTool: vi.fn().mockReturnValue(allowedTool),
getAllToolNames: vi.fn().mockReturnValue([allowedTool.name]),
} as unknown as ToolRegistry;
const allowedConfig = {
getSessionId: () => 'plan-session',
getUsageStatisticsEnabled: () => true,
getDebugMode: () => false,
getApprovalMode: vi.fn().mockReturnValue(ApprovalMode.PLAN),
getAllowedTools: () => [],
getContentGeneratorConfig: () => ({
model: 'test-model',
authType: 'oauth-personal',
}),
getToolRegistry: () => allowedToolRegistry,
} as unknown as Config;
const allowedUpdates = vi.fn();
const allowedScheduler = new CoreToolScheduler({
config: allowedConfig,
onAllToolCallsComplete: vi.fn(),
onToolCallsUpdate: allowedUpdates,
getPreferredEditor: () => 'vscode',
onEditorClose: vi.fn(),
});
const allowedRequest: ToolCallRequestInfo = {
callId: 'plan-shell-allowed',
name: allowedTool.name,
args: { command: 'ls -la' },
isClientInitiated: false,
prompt_id: 'prompt-plan',
};
await allowedScheduler.schedule(
[allowedRequest],
new AbortController().signal,
);
await waitForStatus(allowedUpdates, 'success');
expect(executeSpyAllowed).toHaveBeenCalledTimes(1);
const executeSpyBlocked = vi.fn().mockResolvedValue({
llmContent: 'blocked',
returnDisplay: 'blocked',
});
// Use MockTool with shouldConfirm=true to simulate a shell tool that requires confirmation
const blockedTool = new MockTool('run_shell_command');
blockedTool.shouldConfirm = true;
blockedTool.executeFn = executeSpyBlocked;
const blockedToolRegistry = {
getTool: vi.fn().mockReturnValue(blockedTool),
getAllToolNames: vi.fn().mockReturnValue([blockedTool.name]),
} as unknown as ToolRegistry;
const blockedConfig = {
getSessionId: () => 'plan-session',
getUsageStatisticsEnabled: () => true,
getDebugMode: () => false,
getApprovalMode: vi.fn().mockReturnValue(ApprovalMode.PLAN),
getAllowedTools: () => [],
getContentGeneratorConfig: () => ({
model: 'test-model',
authType: 'oauth-personal',
}),
getToolRegistry: () => blockedToolRegistry,
} as unknown as Config;
const blockedUpdates = vi.fn();
const blockedScheduler = new CoreToolScheduler({
config: blockedConfig,
onAllToolCallsComplete: vi.fn(),
onToolCallsUpdate: blockedUpdates,
getPreferredEditor: () => 'vscode',
onEditorClose: vi.fn(),
});
const blockedRequest: ToolCallRequestInfo = {
callId: 'plan-shell-blocked',
name: 'run_shell_command',
args: { command: 'rm -rf tmp' },
isClientInitiated: false,
prompt_id: 'prompt-plan',
};
await blockedScheduler.schedule(
[blockedRequest],
new AbortController().signal,
);
const blockedCall = (await waitForStatus(
blockedUpdates,
'error',
)) as ErroredToolCall;
expect(executeSpyBlocked).not.toHaveBeenCalled();
expect(
blockedCall.response.responseParts[0]?.functionResponse?.response?.[
'output'
],
).toBe(getPlanModeSystemReminder());
const observedStatuses = blockedUpdates.mock.calls
.flatMap((call) => call[0] as ToolCall[])
.map((tc) => tc.status);
expect(observedStatuses).not.toContain('awaiting_approval');
});
});
describe('getToolSuggestion', () => {
it('should suggest the top N closest tool names for a typo', () => {
// Create mocked tool registry

View File

@@ -34,6 +34,7 @@ import {
import * as Diff from 'diff';
import { doesToolInvocationMatch } from '../utils/tool-utils.js';
import levenshtein from 'fast-levenshtein';
import { getPlanModeSystemReminder } from './prompts.js';
export type ValidatingToolCall = {
status: 'validating';
@@ -674,7 +675,27 @@ export class CoreToolScheduler {
}
const allowedTools = this.config.getAllowedTools() || [];
if (
const isPlanMode =
this.config.getApprovalMode() === ApprovalMode.PLAN;
const isExitPlanModeTool = reqInfo.name === 'exit_plan_mode';
if (isPlanMode && !isExitPlanModeTool) {
if (confirmationDetails) {
this.setStatusInternal(reqInfo.callId, 'error', {
callId: reqInfo.callId,
responseParts: convertToFunctionResponse(
reqInfo.name,
reqInfo.callId,
getPlanModeSystemReminder(),
),
resultDisplay: 'Plan mode blocked a non-read-only tool call.',
error: undefined,
errorType: undefined,
});
} else {
this.setStatusInternal(reqInfo.callId, 'scheduled');
}
} else if (
this.config.getApprovalMode() === ApprovalMode.YOLO ||
doesToolInvocationMatch(toolCall.tool, invocation, allowedTools)
) {

View File

@@ -224,7 +224,7 @@ export class GeminiChat {
error,
);
if (accepted !== false && accepted !== null) {
this.config.setModel(fallbackModel);
await this.config.setModel(fallbackModel);
this.config.setFallbackMode(true);
return fallbackModel;
}
@@ -500,7 +500,7 @@ export class GeminiChat {
if (error instanceof Error && error.message) {
if (isSchemaDepthError(error.message)) return false;
if (error.message.includes('429')) return true;
if (error.message.match(/5\d{2}/)) return true;
if (error.message.match(/^5\d{2}/)) return true;
}
return false;
},

View File

@@ -755,4 +755,84 @@ describe('Logger', () => {
expect(logger['messageId']).toBe(0);
});
});
describe('Model Switch Logging', () => {
it('should log model switch events correctly', async () => {
const testSessionId = 'test-session-model-switch';
const logger = new Logger(testSessionId, new Storage(process.cwd()));
await logger.initialize();
const modelSwitchEvent = {
fromModel: 'qwen3-coder-plus',
toModel: 'qwen-vl-max-latest',
reason: 'vision_auto_switch' as const,
context: 'YOLO mode auto-switch for image content',
};
await logger.logModelSwitch(modelSwitchEvent);
// Read the log file to verify the entry was written
const logContent = await fs.readFile(TEST_LOG_FILE_PATH, 'utf-8');
const logs: LogEntry[] = JSON.parse(logContent);
const modelSwitchLog = logs.find(
(log) =>
log.sessionId === testSessionId &&
log.type === MessageSenderType.MODEL_SWITCH,
);
expect(modelSwitchLog).toBeDefined();
expect(modelSwitchLog!.type).toBe(MessageSenderType.MODEL_SWITCH);
const loggedEvent = JSON.parse(modelSwitchLog!.message);
expect(loggedEvent.fromModel).toBe('qwen3-coder-plus');
expect(loggedEvent.toModel).toBe('qwen-vl-max-latest');
expect(loggedEvent.reason).toBe('vision_auto_switch');
expect(loggedEvent.context).toBe(
'YOLO mode auto-switch for image content',
);
});
it('should handle multiple model switch events', async () => {
const testSessionId = 'test-session-multiple-switches';
const logger = new Logger(testSessionId, new Storage(process.cwd()));
await logger.initialize();
// Log first switch
await logger.logModelSwitch({
fromModel: 'qwen3-coder-plus',
toModel: 'qwen-vl-max-latest',
reason: 'vision_auto_switch',
context: 'Auto-switch for image',
});
// Log second switch (restore)
await logger.logModelSwitch({
fromModel: 'qwen-vl-max-latest',
toModel: 'qwen3-coder-plus',
reason: 'vision_auto_switch',
context: 'Restoring original model',
});
// Read the log file to verify both entries were written
const logContent = await fs.readFile(TEST_LOG_FILE_PATH, 'utf-8');
const logs: LogEntry[] = JSON.parse(logContent);
const modelSwitchLogs = logs.filter(
(log) =>
log.sessionId === testSessionId &&
log.type === MessageSenderType.MODEL_SWITCH,
);
expect(modelSwitchLogs).toHaveLength(2);
const firstSwitch = JSON.parse(modelSwitchLogs[0].message);
expect(firstSwitch.fromModel).toBe('qwen3-coder-plus');
expect(firstSwitch.toModel).toBe('qwen-vl-max-latest');
const secondSwitch = JSON.parse(modelSwitchLogs[1].message);
expect(secondSwitch.fromModel).toBe('qwen-vl-max-latest');
expect(secondSwitch.toModel).toBe('qwen3-coder-plus');
});
});
});

View File

@@ -13,6 +13,7 @@ const LOG_FILE_NAME = 'logs.json';
export enum MessageSenderType {
USER = 'user',
MODEL_SWITCH = 'model_switch',
}
export interface LogEntry {
@@ -23,6 +24,13 @@ export interface LogEntry {
message: string;
}
export interface ModelSwitchEvent {
fromModel: string;
toModel: string;
reason: 'vision_auto_switch' | 'manual' | 'fallback' | 'other';
context?: string;
}
// This regex matches any character that is NOT a letter (a-z, A-Z),
// a number (0-9), a hyphen (-), an underscore (_), or a dot (.).
@@ -270,6 +278,17 @@ export class Logger {
}
}
async logModelSwitch(event: ModelSwitchEvent): Promise<void> {
const message = JSON.stringify({
fromModel: event.fromModel,
toModel: event.toModel,
reason: event.reason,
context: event.context,
});
await this.logMessage(MessageSenderType.MODEL_SWITCH, message);
}
private _checkpointPath(tag: string): string {
if (!tag.length) {
throw new Error('No checkpoint tag specified.');

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -376,28 +376,22 @@ export class OpenAIContentConverter {
parsedParts: Pick<ParsedParts, 'textParts' | 'mediaParts'>,
): OpenAI.Chat.ChatCompletionMessageParam | null {
const { textParts, mediaParts } = parsedParts;
const combinedText = textParts.join('');
const content = textParts.map((text) => ({ type: 'text' as const, text }));
// If no media parts, return simple text message
if (mediaParts.length === 0) {
return combinedText ? { role, content: combinedText } : null;
return content.length > 0 ? { role, content } : null;
}
// For assistant messages with media, convert to text only
// since OpenAI assistant messages don't support media content arrays
if (role === 'assistant') {
return combinedText
? { role: 'assistant' as const, content: combinedText }
return content.length > 0
? { role: 'assistant' as const, content }
: null;
}
// Create multimodal content array for user messages
const contentArray: OpenAI.Chat.ChatCompletionContentPart[] = [];
// Add text content
if (combinedText) {
contentArray.push({ type: 'text', text: combinedText });
}
const contentArray: OpenAI.Chat.ChatCompletionContentPart[] = [...content];
// Add media content
for (const mediaPart of mediaParts) {
@@ -405,14 +399,14 @@ export class OpenAIContentConverter {
if (mediaPart.fileUri) {
// For file URIs, use the URI directly
contentArray.push({
type: 'image_url',
type: 'image_url' as const,
image_url: { url: mediaPart.fileUri },
});
} else if (mediaPart.data) {
// For inline data, create data URL
const dataUrl = `data:${mediaPart.mimeType};base64,${mediaPart.data}`;
contentArray.push({
type: 'image_url',
type: 'image_url' as const,
image_url: { url: dataUrl },
});
}
@@ -421,7 +415,7 @@ export class OpenAIContentConverter {
const format = this.getAudioFormat(mediaPart.mimeType);
if (format) {
contentArray.push({
type: 'input_audio',
type: 'input_audio' as const,
input_audio: {
data: mediaPart.data,
format: format as 'wav' | 'mp3',

View File

@@ -5,6 +5,37 @@
*/
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
// Mock the request tokenizer module BEFORE importing the class that uses it
const mockTokenizer = {
calculateTokens: vi.fn().mockResolvedValue({
totalTokens: 50,
breakdown: {
textTokens: 50,
imageTokens: 0,
audioTokens: 0,
otherTokens: 0,
},
processingTime: 1,
}),
dispose: vi.fn(),
};
vi.mock('../../../utils/request-tokenizer/index.js', () => ({
getDefaultTokenizer: vi.fn(() => mockTokenizer),
DefaultRequestTokenizer: vi.fn(() => mockTokenizer),
disposeDefaultTokenizer: vi.fn(),
}));
// Mock tiktoken as well for completeness
vi.mock('tiktoken', () => ({
get_encoding: vi.fn(() => ({
encode: vi.fn(() => new Array(50)), // Mock 50 tokens
free: vi.fn(),
})),
}));
// Now import the modules that depend on the mocked modules
import { OpenAIContentGenerator } from './openaiContentGenerator.js';
import type { Config } from '../../config/config.js';
import { AuthType } from '../contentGenerator.js';
@@ -15,14 +46,6 @@ import type {
import type { OpenAICompatibleProvider } from './provider/index.js';
import type OpenAI from 'openai';
// Mock tiktoken
vi.mock('tiktoken', () => ({
get_encoding: vi.fn().mockReturnValue({
encode: vi.fn().mockReturnValue(new Array(50)), // Mock 50 tokens
free: vi.fn(),
}),
}));
describe('OpenAIContentGenerator (Refactored)', () => {
let generator: OpenAIContentGenerator;
let mockConfig: Config;

View File

@@ -13,6 +13,7 @@ import type { PipelineConfig } from './pipeline.js';
import { ContentGenerationPipeline } from './pipeline.js';
import { DefaultTelemetryService } from './telemetryService.js';
import { EnhancedErrorHandler } from './errorHandler.js';
import { getDefaultTokenizer } from '../../utils/request-tokenizer/index.js';
import type { ContentGeneratorConfig } from '../contentGenerator.js';
export class OpenAIContentGenerator implements ContentGenerator {
@@ -71,27 +72,30 @@ export class OpenAIContentGenerator implements ContentGenerator {
async countTokens(
request: CountTokensParameters,
): Promise<CountTokensResponse> {
// Use tiktoken for accurate token counting
const content = JSON.stringify(request.contents);
let totalTokens = 0;
try {
const { get_encoding } = await import('tiktoken');
const encoding = get_encoding('cl100k_base'); // GPT-4 encoding, but estimate for qwen
totalTokens = encoding.encode(content).length;
encoding.free();
// Use the new high-performance request tokenizer
const tokenizer = getDefaultTokenizer();
const result = await tokenizer.calculateTokens(request, {
textEncoding: 'cl100k_base', // Use GPT-4 encoding for consistency
});
return {
totalTokens: result.totalTokens,
};
} catch (error) {
console.warn(
'Failed to load tiktoken, falling back to character approximation:',
'Failed to calculate tokens with new tokenizer, falling back to simple method:',
error,
);
// Fallback: rough approximation using character count
totalTokens = Math.ceil(content.length / 4); // Rough estimate: 1 token ≈ 4 characters
}
return {
totalTokens,
};
// Fallback to original simple method
const content = JSON.stringify(request.contents);
const totalTokens = Math.ceil(content.length / 4); // Rough estimate: 1 token ≈ 4 characters
return {
totalTokens,
};
}
}
async embedContent(

View File

@@ -1105,5 +1105,164 @@ describe('ContentGenerationPipeline', () => {
expect.any(Array),
);
});
it('should collect all OpenAI chunks for logging even when Gemini responses are filtered', async () => {
// Create chunks that would produce empty Gemini responses (partial tool calls)
const partialToolCallChunk1: OpenAI.Chat.ChatCompletionChunk = {
id: 'chunk-1',
object: 'chat.completion.chunk',
created: Date.now(),
model: 'test-model',
choices: [
{
index: 0,
delta: {
tool_calls: [
{
index: 0,
id: 'call_123',
type: 'function',
function: { name: 'test_function', arguments: '{"par' },
},
],
},
finish_reason: null,
},
],
};
const partialToolCallChunk2: OpenAI.Chat.ChatCompletionChunk = {
id: 'chunk-2',
object: 'chat.completion.chunk',
created: Date.now(),
model: 'test-model',
choices: [
{
index: 0,
delta: {
tool_calls: [
{
index: 0,
function: { arguments: 'am": "value"}' },
},
],
},
finish_reason: null,
},
],
};
const finishChunk: OpenAI.Chat.ChatCompletionChunk = {
id: 'chunk-3',
object: 'chat.completion.chunk',
created: Date.now(),
model: 'test-model',
choices: [
{
index: 0,
delta: {},
finish_reason: 'tool_calls',
},
],
};
// Mock empty Gemini responses for partial chunks (they get filtered)
const emptyGeminiResponse1 = new GenerateContentResponse();
emptyGeminiResponse1.candidates = [
{
content: { parts: [], role: 'model' },
index: 0,
safetyRatings: [],
},
];
const emptyGeminiResponse2 = new GenerateContentResponse();
emptyGeminiResponse2.candidates = [
{
content: { parts: [], role: 'model' },
index: 0,
safetyRatings: [],
},
];
// Mock final Gemini response with tool call
const finalGeminiResponse = new GenerateContentResponse();
finalGeminiResponse.candidates = [
{
content: {
parts: [
{
functionCall: {
id: 'call_123',
name: 'test_function',
args: { param: 'value' },
},
},
],
role: 'model',
},
finishReason: FinishReason.STOP,
index: 0,
safetyRatings: [],
},
];
// Setup converter mocks
(mockConverter.convertGeminiRequestToOpenAI as Mock).mockReturnValue([
{ role: 'user', content: 'test' },
]);
(mockConverter.convertOpenAIChunkToGemini as Mock)
.mockReturnValueOnce(emptyGeminiResponse1) // First partial chunk -> empty response
.mockReturnValueOnce(emptyGeminiResponse2) // Second partial chunk -> empty response
.mockReturnValueOnce(finalGeminiResponse); // Finish chunk -> complete response
// Mock stream
const mockStream = {
async *[Symbol.asyncIterator]() {
yield partialToolCallChunk1;
yield partialToolCallChunk2;
yield finishChunk;
},
};
(mockClient.chat.completions.create as Mock).mockResolvedValue(
mockStream,
);
const request: GenerateContentParameters = {
model: 'test-model',
contents: [{ role: 'user', parts: [{ text: 'test' }] }],
};
// Collect responses
const responses: GenerateContentResponse[] = [];
const resultGenerator = await pipeline.executeStream(
request,
'test-prompt-id',
);
for await (const response of resultGenerator) {
responses.push(response);
}
// Should only yield the final response (empty ones are filtered)
expect(responses).toHaveLength(1);
expect(responses[0]).toBe(finalGeminiResponse);
// Verify telemetry was called with ALL OpenAI chunks, including the filtered ones
expect(mockTelemetryService.logStreamingSuccess).toHaveBeenCalledWith(
expect.objectContaining({
model: 'test-model',
duration: expect.any(Number),
userPromptId: 'test-prompt-id',
authType: 'openai',
}),
[finalGeminiResponse], // Only the non-empty Gemini response
expect.objectContaining({
model: 'test-model',
messages: [{ role: 'user', content: 'test' }],
}),
[partialToolCallChunk1, partialToolCallChunk2, finishChunk], // ALL OpenAI chunks
);
});
});
});

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