Compare commits

..

6 Commits

84 changed files with 1497 additions and 3428 deletions

View File

@@ -121,11 +121,6 @@ jobs:
IS_PREVIEW: '${{ steps.vars.outputs.is_preview }}'
MANUAL_VERSION: '${{ inputs.version }}'
- name: 'Build CLI Bundle'
run: |
npm run build
npm run bundle
- name: 'Run Tests'
if: |-
${{ github.event.inputs.force_skip_tests != 'true' }}
@@ -137,6 +132,13 @@ jobs:
OPENAI_BASE_URL: '${{ secrets.OPENAI_BASE_URL }}'
OPENAI_MODEL: '${{ secrets.OPENAI_MODEL }}'
- name: 'Build CLI for Integration Tests'
if: |-
${{ github.event.inputs.force_skip_tests != 'true' }}
run: |
npm run build
npm run bundle
- name: 'Run SDK Integration Tests'
if: |-
${{ github.event.inputs.force_skip_tests != 'true' }}

View File

@@ -133,8 +133,8 @@ jobs:
${{ github.event.inputs.force_skip_tests != 'true' }}
run: |
npm run preflight
npm run test:integration:cli:sandbox:none
npm run test:integration:cli:sandbox:docker
npm run test:integration:sandbox:none
npm run test:integration:sandbox:docker
env:
OPENAI_API_KEY: '${{ secrets.OPENAI_API_KEY }}'
OPENAI_BASE_URL: '${{ secrets.OPENAI_BASE_URL }}'

359
README.md
View File

@@ -2,7 +2,7 @@
<div align="center">
![Qwen Code Screenshot](./docs/assets/qwen-screenshot.png)
![](https://gw.alicdn.com/imgextra/i1/O1CN01D2DviS1wwtEtMwIzJ_!!6000000006373-2-tps-1600-900.png)
[![npm version](https://img.shields.io/npm/v/@qwen-code/qwen-code.svg)](https://www.npmjs.com/package/@qwen-code/qwen-code)
[![License](https://img.shields.io/github/license/QwenLM/qwen-code.svg)](./LICENSE)
@@ -11,52 +11,36 @@
**AI-powered command-line workflow tool for developers**
[Installation](#installation) • [Quick Start](#quick-start) • [Features](#key-features) • [Documentation](./docs/) • [Contributing](./CONTRIBUTING.md)
</div>
<div align="center">
<a href="https://qwenlm.github.io/qwen-code-docs/zh/">中文</a> |
<a href="https://qwenlm.github.io/qwen-code-docs/de/">Deutsch</a> |
<a href="https://qwenlm.github.io/qwen-code-docs/fr">français</a> |
<a href="https://qwenlm.github.io/qwen-code-docs/ja/">日本語</a> |
<a href="https://qwenlm.github.io/qwen-code-docs/ru">Русский</a> |
<a href="https://qwenlm.github.io/qwen-code-docs/zh/">中文</a>
<a href="https://qwenlm.github.io/qwen-code-docs/ru">Русский</a>
[Installation](#install-from-npm) • [Quick Start](#-quick-start) • [Features](#-why-qwen-code) • [Documentation](https://qwenlm.github.io/qwen-code-docs/en/users/overview/) • [Contributing](https://qwenlm.github.io/qwen-code-docs/en/developers/contributing/)
</div>
Qwen Code is a powerful command-line AI workflow tool adapted from [**Gemini CLI**](https://github.com/google-gemini/gemini-cli), specifically optimized for [Qwen3-Coder](https://github.com/QwenLM/Qwen3-Coder) models. It enhances your development workflow with advanced code understanding, automated tasks, and intelligent assistance.
## 💡 Free Options Available
Get started with Qwen Code at no cost using any of these free options:
### 🔥 Qwen OAuth (Recommended)
## 📌 Why Qwen Code
- **2,000 requests per day** with no token limits
- **60 requests per minute** rate limit
- Simply run `qwen` and authenticate with your qwen.ai account
- Automatic credential management and refresh
- Use `/auth` command to switch to Qwen OAuth if you have initialized with OpenAI compatible mode
- 🎯 Free Access Available: Get started with 2,000 free requests per day via Qwen OAuth.
- 🧠 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
- 💻 Terminal-first: Designed for developers who live in the command line.
- 🧰 VS Code: Install the VS Code extension to seamlessly integrate into your existing workflow.
- 📦 Simple Setup: Easy installation with npm, Homebrew, or source for quick deployment.
### 🌏 Regional Free Tiers
>👉 Know more [workflows](https://qwenlm.github.io/qwen-code-docs/en/users/common-workflow/)
>
> 📦 The extension is currently in development. For installation, features, and development guide, see the [VS Code Extension README](./packages/vscode-ide-companion/README.md).
- **Mainland China**: ModelScope offers **2,000 free API calls per day**
- **International**: OpenRouter provides **up to 1,000 free API calls per day** worldwide
For detailed setup instructions, see [Authorization](#authorization).
> [!WARNING]
> **Token Usage Notice**: Qwen Code may issue multiple API calls per cycle, resulting in higher token usage (similar to Claude Code). We're actively optimizing API efficiency.
## Key Features
- **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
## ❓ How to use Qwen Code?
### Prerequisites
@@ -70,313 +54,57 @@ curl -qL https://www.npmjs.com/install.sh | sh
```bash
npm install -g @qwen-code/qwen-code@latest
qwen --version
```
### Install from source
```bash
git clone https://github.com/QwenLM/qwen-code.git
cd qwen-code
npm install
npm install -g .
```
### Install globally with Homebrew (macOS/Linux)
```bash
brew install qwen-code
```
## VS Code Extension
In addition to the CLI tool, Qwen Code also provides a **VS Code extension** that brings AI-powered coding assistance directly into your editor with features like file system operations, native diffing, interactive chat, and more.
> 📦 The extension is currently in development. For installation, features, and development guide, see the [VS Code Extension README](./packages/vscode-ide-companion/README.md).
## Quick Start
## 🚀 Quick Start
```bash
# Start Qwen Code
qwen
# Example commands
> What does this project do?
> Explain this codebase structure
> Help me refactor this function
> Generate unit tests for this module
```
### Session Management
👇 Click to play video
Control your token usage with configurable session limits to optimize costs and performance.
[![qwencode-start](https://img.alicdn.com/imgextra/i3/6000000004682/O1CN01cSWkqj1kSPUZPj68h_!!6000000004682-0-tbvideo.jpg)](https://cloud.video.taobao.com/vod/HLfyppnCHplRV9Qhz2xSqeazHeRzYtG-EYJnHAqtzkQ.mp4)
#### Configure Session Token Limit
Create or edit `.qwen/settings.json` in your home directory:
```json
{
"sessionTokenLimit": 32000
}
```
#### Session Commands
- **`/compress`** - Compress conversation history to continue within token limits
- **`/clear`** - Clear all conversation history and start fresh
- **`/stats`** - Check current token usage and limits
> 📝 **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:
#### 1. Qwen OAuth (🚀 Recommended - Start in 30 seconds)
The easiest way to get started - completely free with generous quotas:
```bash
# Just run this command and follow the browser authentication
qwen
```
**What happens:**
1. **Instant Setup**: CLI opens your browser automatically
2. **One-Click Login**: Authenticate with your qwen.ai account
3. **Automatic Management**: Credentials cached locally for future use
4. **No Configuration**: Zero setup required - just start coding!
**Free Tier Benefits:**
-**2,000 requests/day** (no token counting needed)
-**60 requests/minute** rate limit
-**Automatic credential refresh**
-**Zero cost** for individual users
- **Note**: Model fallback may occur to maintain service quality
#### 2. OpenAI-Compatible API
Use API keys for OpenAI or other compatible providers:
**Configuration Methods:**
1. **Environment Variables**
```bash
export OPENAI_API_KEY="your_api_key_here"
export OPENAI_BASE_URL="your_api_endpoint"
export OPENAI_MODEL="your_model_choice"
```
2. **Project `.env` File**
Create a `.env` file in your project root:
```env
OPENAI_API_KEY=your_api_key_here
OPENAI_BASE_URL=your_api_endpoint
OPENAI_MODEL=your_model_choice
```
**API Provider Options**
> ⚠️ **Regional Notice:**
>
> - **Mainland China**: Use Alibaba Cloud Bailian or ModelScope
> - **International**: Use Alibaba Cloud ModelStudio or OpenRouter
<details>
<summary><b>🇨🇳 For Users in Mainland China</b></summary>
**Option 1: Alibaba Cloud Bailian** ([Apply for API Key](https://bailian.console.aliyun.com/))
```bash
export OPENAI_API_KEY="your_api_key_here"
export OPENAI_BASE_URL="https://dashscope.aliyuncs.com/compatible-mode/v1"
export OPENAI_MODEL="qwen3-coder-plus"
```
**Option 2: ModelScope (Free Tier)** ([Apply for API Key](https://modelscope.cn/docs/model-service/API-Inference/intro))
- ✅ **2,000 free API calls per day**
- ⚠️ Connect your Aliyun account to avoid authentication errors
```bash
export OPENAI_API_KEY="your_api_key_here"
export OPENAI_BASE_URL="https://api-inference.modelscope.cn/v1"
export OPENAI_MODEL="Qwen/Qwen3-Coder-480B-A35B-Instruct"
```
</details>
<details>
<summary><b>🌍 For International Users</b></summary>
**Option 1: Alibaba Cloud ModelStudio** ([Apply for API Key](https://modelstudio.console.alibabacloud.com/))
```bash
export OPENAI_API_KEY="your_api_key_here"
export OPENAI_BASE_URL="https://dashscope-intl.aliyuncs.com/compatible-mode/v1"
export OPENAI_MODEL="qwen3-coder-plus"
```
**Option 2: OpenRouter (Free Tier Available)** ([Apply for API Key](https://openrouter.ai/))
```bash
export OPENAI_API_KEY="your_api_key_here"
export OPENAI_BASE_URL="https://openrouter.ai/api/v1"
export OPENAI_MODEL="qwen/qwen3-coder:free"
```
</details>
## Usage Examples
### 🔍 Explore Codebases
### 1⃣ Interactive Mode
```bash
cd your-project/
qwen
# Architecture analysis
> Describe the main pieces of this system's architecture
> What are the key dependencies and how do they interact?
> Find all API endpoints and their authentication methods
```
### 💻 Code Development
Navigate to your project folder and type `qwen` to launch Qwen Code. Start a conversation and use `@` to reference files within the folder.
If you want to learn more about common workflows, click [Common Workflows](https://qwenlm.github.io/qwen-code-docs/en/users/common-workflow/) to view.
### 2⃣ Headless Mode
```bash
# Refactoring
> Refactor this function to improve readability and performance
> Convert this class to use dependency injection
> Split this large module into smaller, focused components
# Code generation
> Create a REST API endpoint for user management
> Generate unit tests for the authentication module
> Add error handling to all database operations
cd your-project/
qwen -p "your question"
```
[Headless mode](https://qwenlm.github.io/qwen-code-docs/en/users/features/headless) allows you to run Qwen Code programmatically from command line scripts and automation tools without any interactive UI. This is ideal for scripting, automation, CI/CD pipelines, and building AI-powered tools.
### 🔄 Automate Workflows
### 3⃣ Use in IDE
If you prefer to integrate Qwen Code into your current editor, we now support VS Code and Zed. For details, please refer to:
```bash
# Git automation
> Analyze git commits from the last 7 days, grouped by feature
> Create a changelog from recent commits
> Find all TODO comments and create GitHub issues
- [Use in VS Code](https://qwenlm.github.io/qwen-code-docs/en/users/integration-vscode/)
- [Use in Zed](https://qwenlm.github.io/qwen-code-docs/en/users/integration-zed/)
# File operations
> Convert all images in this directory to PNG format
> Rename all test files to follow the *.test.ts pattern
> Find and remove all console.log statements
```
### 4⃣ SDK
Qwen Code now supports an SDK designed to simplify integration with the Qwen Code platform. It provides a set of easy-to-use APIs and tools enabling developers to efficiently build, test, and deploy applications. For details, please refer to:
### 🐛 Debugging & Analysis
```bash
# Performance analysis
> Identify performance bottlenecks in this React component
> Find all N+1 query problems in the codebase
# Security audit
> Check for potential SQL injection vulnerabilities
> Find all hardcoded credentials or API keys
```
## Popular Tasks
### 📚 Understand New Codebases
```text
> What are the core business logic components?
> What security mechanisms are in place?
> How does the data flow through the system?
> What are the main design patterns used?
> Generate a dependency graph for this module
```
### 🔨 Code Refactoring & Optimization
```text
> What parts of this module can be optimized?
> Help me refactor this class to follow SOLID principles
> Add proper error handling and logging
> Convert callbacks to async/await pattern
> Implement caching for expensive operations
```
### 📝 Documentation & Testing
```text
> Generate comprehensive JSDoc comments for all public APIs
> Write unit tests with edge cases for this component
> Create API documentation in OpenAPI format
> Add inline comments explaining complex algorithms
> Generate a README for this module
```
### 🚀 Development Acceleration
```text
> Set up a new Express server with authentication
> Create a React component with TypeScript and tests
> Implement a rate limiter middleware
> Add database migrations for new schema
> Configure CI/CD pipeline for this project
```
- [Use the Qwen Code SDK](./packages/sdk-typescript/README.md)
## Commands & Shortcuts
@@ -394,6 +122,11 @@ qwen
- `Ctrl+D` - Exit (on empty line)
- `Up/Down` - Navigate command history
> 👉 Know more about [Commands](https://qwenlm.github.io/qwen-code-docs/en/users/features/commands/)
>
> 💡 **Tip**: In YOLO mode (`--yolo`), vision switching happens automatically without prompts when images are detected. Know more about [Approval Mode](https://qwenlm.github.io/qwen-code-docs/en/users/features/approval-mode/)
## Benchmark Results
### Terminal-Bench Performance
@@ -405,13 +138,13 @@ qwen
## Development & Contributing
See [CONTRIBUTING.md](./CONTRIBUTING.md) to learn how to contribute to the project.
See [CONTRIBUTING.md](https://qwenlm.github.io/qwen-code-docs/en/developers/contributing/) to learn how to contribute to the project.
For detailed authentication setup, see the [authentication guide](./docs/cli/authentication.md).
For detailed authentication setup, see the [authentication guide](https://qwenlm.github.io/qwen-code-docs/en/users/configuration/auth/).
## Troubleshooting
If you encounter issues, check the [troubleshooting guide](docs/troubleshooting.md).
If you encounter issues, check the [troubleshooting guide](https://qwenlm.github.io/qwen-code-docs/en/users/support/troubleshooting/).
## Acknowledgments

View File

@@ -627,12 +627,7 @@ The MCP integration tracks several states:
### Schema Compatibility
- **Schema compliance mode:** By default (`schemaCompliance: "auto"`), tool schemas are passed through as-is. Set `"model": { "generationConfig": { "schemaCompliance": "openapi_30" } }` in your `settings.json` to convert models to Strict OpenAPI 3.0 format.
- **OpenAPI 3.0 transformations:** When `openapi_30` mode is enabled, the system handles:
- Nullable types: `["string", "null"]` -> `type: "string", nullable: true`
- Const values: `const: "foo"` -> `enum: ["foo"]`
- Exclusive limits: numeric `exclusiveMinimum` -> boolean form with `minimum`
- Keyword removal: `$schema`, `$id`, `dependencies`, `patternProperties`
- **Property stripping:** The system automatically removes certain schema properties (`$schema`, `additionalProperties`) for Qwen API compatibility
- **Name sanitization:** Tool names are automatically sanitized to meet API requirements
- **Conflict resolution:** Tool name conflicts between servers are resolved through automatic prefixing

View File

@@ -5,7 +5,6 @@ Welcome to the Qwen Code documentation. Qwen Code is an agentic coding tool that
## Documentation Sections
### [User Guide](./users/overview)
Learn how to use Qwen Code as an end user. This section covers:
- Basic installation and setup

View File

@@ -20,9 +20,9 @@ You can update your `.qwenignore` file at any time. To apply the changes, you mu
## How to use `.qwenignore`
| Step | Description |
| ---------------------- | -------------------------------------------------------------------------------------- |
| **Enable .qwenignore** | Create a file named `.qwenignore` in your project root directory |
| Step | Description |
| ---------------------- | ------------------------------------------------------------ |
| **Enable .qwenignore** | Create a file named `.qwenignore` in your project root directory |
| **Add ignore rules** | Open `.qwenignore` file and add paths to ignore, example: `/archive/` or `apikeys.txt` |
### `.qwenignore` examples

View File

@@ -50,14 +50,13 @@ Settings are organized into categories. All settings should be placed within the
#### general
| Setting | Type | Description | Default |
| ------------------------------- | ------- | ---------------------------------------------------------------------------------------------------------- | ----------- |
| `general.preferredEditor` | string | The preferred editor to open files in. | `undefined` |
| `general.vimMode` | boolean | Enable Vim keybindings. | `false` |
| `general.disableAutoUpdate` | boolean | Disable automatic updates. | `false` |
| `general.disableUpdateNag` | boolean | Disable update notification prompts. | `false` |
| `general.gitCoAuthor` | boolean | Automatically add a Co-authored-by trailer to git commit messages when commits are made through Qwen Code. | `true` |
| `general.checkpointing.enabled` | boolean | Enable session checkpointing for recovery. | `false` |
| Setting | Type | Description | Default |
| ------------------------------- | ------- | ------------------------------------------ | ----------- |
| `general.preferredEditor` | string | The preferred editor to open files in. | `undefined` |
| `general.vimMode` | boolean | Enable Vim keybindings. | `false` |
| `general.disableAutoUpdate` | boolean | Disable automatic updates. | `false` |
| `general.disableUpdateNag` | boolean | Disable update notification prompts. | `false` |
| `general.checkpointing.enabled` | boolean | Enable session checkpointing for recovery. | `false` |
#### output
@@ -69,7 +68,7 @@ Settings are organized into categories. All settings should be placed within the
| Setting | Type | Description | Default |
| ---------------------------------------- | ---------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ----------- |
| `ui.theme` | string | The color theme for the UI. See [Themes](../configuration/themes) for available options. | `undefined` |
| `ui.theme` | string | The color theme for the UI. See [Themes](../configuration/themes) for available options. | `undefined` |
| `ui.customThemes` | object | Custom theme definitions. | `{}` |
| `ui.hideWindowTitle` | boolean | Hide the window title bar. | `false` |
| `ui.hideTips` | boolean | Hide helpful tips in the UI. | `false` |
@@ -357,38 +356,38 @@ Arguments passed directly when running the CLI can override other configurations
### Command-Line Arguments Table
| Argument | Alias | Description | Possible Values | Notes |
| ---------------------------- | ----- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `--model` | `-m` | Specifies the Qwen model to use for this session. | Model name | Example: `npm start -- --model qwen3-coder-plus` |
| `--prompt` | `-p` | Used to pass a prompt directly to the command. This invokes Qwen Code in a non-interactive mode. | Your prompt text | For scripting examples, use the `--output-format json` flag to get structured output. |
| `--prompt-interactive` | `-i` | Starts an interactive session with the provided prompt as the initial input. | Your prompt text | The prompt is processed within the interactive session, not before it. Cannot be used when piping input from stdin. Example: `qwen -i "explain this code"` |
| Argument | Alias | Description | Possible Values | Notes |
| ---------------------------- | ----- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `--model` | `-m` | Specifies the Qwen model to use for this session. | Model name | Example: `npm start -- --model qwen3-coder-plus` |
| `--prompt` | `-p` | Used to pass a prompt directly to the command. This invokes Qwen Code in a non-interactive mode. | Your prompt text | For scripting examples, use the `--output-format json` flag to get structured output. |
| `--prompt-interactive` | `-i` | Starts an interactive session with the provided prompt as the initial input. | Your prompt text | The prompt is processed within the interactive session, not before it. Cannot be used when piping input from stdin. Example: `qwen -i "explain this code"` |
| `--output-format` | `-o` | Specifies the format of the CLI output for non-interactive mode. | `text`, `json`, `stream-json` | `text`: (Default) The standard human-readable output. `json`: A machine-readable JSON output emitted at the end of execution. `stream-json`: Streaming JSON messages emitted as they occur during execution. For structured output and scripting, use the `--output-format json` or `--output-format stream-json` flag. See [Headless Mode](../features/headless) for detailed information. |
| `--input-format` | | Specifies the format consumed from standard input. | `text`, `stream-json` | `text`: (Default) Standard text input from stdin or command-line arguments. `stream-json`: JSON message protocol via stdin for bidirectional communication. Requirement: `--input-format stream-json` requires `--output-format stream-json` to be set. When using `stream-json`, stdin is reserved for protocol messages. See [Headless Mode](../features/headless) for detailed information. |
| `--include-partial-messages` | | Include partial assistant messages when using `stream-json` output format. When enabled, emits stream events (message_start, content_block_delta, etc.) as they occur during streaming. | | Default: `false`. Requirement: Requires `--output-format stream-json` to be set. See [Headless Mode](../features/headless) for detailed information about stream events. |
| `--sandbox` | `-s` | Enables sandbox mode for this session. | | |
| `--sandbox-image` | | Sets the sandbox image URI. | | |
| `--debug` | `-d` | Enables debug mode for this session, providing more verbose output. | | |
| `--all-files` | `-a` | If set, recursively includes all files within the current directory as context for the prompt. | | |
| `--help` | `-h` | Displays help information about command-line arguments. | | |
| `--show-memory-usage` | | Displays the current memory usage. | | |
| `--yolo` | | Enables YOLO mode, which automatically approves all tool calls. | | |
| `--sandbox` | `-s` | Enables sandbox mode for this session. | | |
| `--sandbox-image` | | Sets the sandbox image URI. | | |
| `--debug` | `-d` | Enables debug mode for this session, providing more verbose output. | | |
| `--all-files` | `-a` | If set, recursively includes all files within the current directory as context for the prompt. | | |
| `--help` | `-h` | Displays help information about command-line arguments. | | |
| `--show-memory-usage` | | Displays the current memory usage. | | |
| `--yolo` | | Enables YOLO mode, which automatically approves all tool calls. | | |
| `--approval-mode` | | Sets the approval mode for tool calls. | `plan`, `default`, `auto-edit`, `yolo` | 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`<br>See more about [Approval Mode](../features/approval-mode). |
| `--allowed-tools` | | A comma-separated list of tool names that will bypass the confirmation dialog. | Tool names | Example: `qwen --allowed-tools "Shell(git status)"` |
| `--telemetry` | | Enables [telemetry](/developers/development/telemetry). | | |
| `--telemetry-target` | | Sets the telemetry target. | | See [telemetry](/developers/development/telemetry) for more information. |
| `--telemetry-otlp-endpoint` | | Sets the OTLP endpoint for telemetry. | | See [telemetry](../../developers/development/telemetry) for more information. |
| `--telemetry-otlp-protocol` | | Sets the OTLP protocol for telemetry (`grpc` or `http`). | | Defaults to `grpc`. See [telemetry](../../developers/development/telemetry) for more information. |
| `--telemetry-log-prompts` | | Enables logging of prompts for telemetry. | | See [telemetry](../../developers/development/telemetry) for more information. |
| `--checkpointing` | | Enables [checkpointing](../features/checkpointing). | | |
| `--extensions` | `-e` | Specifies a list of extensions to use for the session. | Extension names | If not provided, all available extensions are used. Use the special term `qwen -e none` to disable all extensions. Example: `qwen -e my-extension -e my-other-extension` |
| `--list-extensions` | `-l` | Lists all available extensions and exits. | | |
| `--proxy` | | Sets the proxy for the CLI. | Proxy URL | Example: `--proxy http://localhost:7890`. |
| `--include-directories` | | Includes additional directories in the workspace for multi-directory support. | Directory paths | Can be specified multiple times or as comma-separated values. 5 directories can be added at maximum. Example: `--include-directories /path/to/project1,/path/to/project2` or `--include-directories /path/to/project1 --include-directories /path/to/project2` |
| `--screen-reader` | | Enables screen reader mode, which adjusts the TUI for better compatibility with screen readers. | | |
| `--version` | | Displays the version of the CLI. | | |
| `--openai-logging` | | Enables logging of OpenAI API calls for debugging and analysis. | | This flag overrides the `enableOpenAILogging` setting in `settings.json`. |
| `--openai-logging-dir` | | Sets a custom directory path for OpenAI API logs. | Directory path | This flag overrides the `openAILoggingDir` setting in `settings.json`. Supports absolute paths, relative paths, and `~` expansion. Example: `qwen --openai-logging-dir "~/qwen-logs" --openai-logging` |
| `--tavily-api-key` | | Sets the Tavily API key for web search functionality for this session. | API key | Example: `qwen --tavily-api-key tvly-your-api-key-here` |
| `--allowed-tools` | | A comma-separated list of tool names that will bypass the confirmation dialog. | Tool names | Example: `qwen --allowed-tools "Shell(git status)"` |
| `--telemetry` | | Enables [telemetry](/developers/development/telemetry). | | |
| `--telemetry-target` | | Sets the telemetry target. | | See [telemetry](/developers/development/telemetry) for more information. |
| `--telemetry-otlp-endpoint` | | Sets the OTLP endpoint for telemetry. | | See [telemetry](../../developers/development/telemetry) for more information. |
| `--telemetry-otlp-protocol` | | Sets the OTLP protocol for telemetry (`grpc` or `http`). | | Defaults to `grpc`. See [telemetry](../../developers/development/telemetry) for more information. |
| `--telemetry-log-prompts` | | Enables logging of prompts for telemetry. | | See [telemetry](../../developers/development/telemetry) for more information. |
| `--checkpointing` | | Enables [checkpointing](../features/checkpointing). | | |
| `--extensions` | `-e` | Specifies a list of extensions to use for the session. | Extension names | If not provided, all available extensions are used. Use the special term `qwen -e none` to disable all extensions. Example: `qwen -e my-extension -e my-other-extension` |
| `--list-extensions` | `-l` | Lists all available extensions and exits. | | |
| `--proxy` | | Sets the proxy for the CLI. | Proxy URL | Example: `--proxy http://localhost:7890`. |
| `--include-directories` | | Includes additional directories in the workspace for multi-directory support. | Directory paths | Can be specified multiple times or as comma-separated values. 5 directories can be added at maximum. Example: `--include-directories /path/to/project1,/path/to/project2` or `--include-directories /path/to/project1 --include-directories /path/to/project2` |
| `--screen-reader` | | Enables screen reader mode, which adjusts the TUI for better compatibility with screen readers. | | |
| `--version` | | Displays the version of the CLI. | | |
| `--openai-logging` | | Enables logging of OpenAI API calls for debugging and analysis. | | This flag overrides the `enableOpenAILogging` setting in `settings.json`. |
| `--openai-logging-dir` | | Sets a custom directory path for OpenAI API logs. | Directory path | This flag overrides the `openAILoggingDir` setting in `settings.json`. Supports absolute paths, relative paths, and `~` expansion. Example: `qwen --openai-logging-dir "~/qwen-logs" --openai-logging` |
| `--tavily-api-key` | | Sets the Tavily API key for web search functionality for this session. | API key | Example: `qwen --tavily-api-key tvly-your-api-key-here` |
## Context Files (Hierarchical Instructional Context)

View File

@@ -140,6 +140,8 @@ The theme file must be a valid JSON file that follows the same structure as a cu
### Example Custom Theme
<img src="https://gw.alicdn.com/imgextra/i1/O1CN01Em30Hc1jYXAdIgls3_!!6000000004560-2-tps-1009-629.png" alt=" " style="zoom:100%;text-align:center;margin: 0 auto;" />
### Using Your Custom Theme
@@ -148,13 +150,15 @@ The theme file must be a valid JSON file that follows the same structure as a cu
- Or, set it as the default by adding `"theme": "MyCustomTheme"` to the `ui` object in your `settings.json`.
- Custom themes can be set at the user, project, or system level, and follow the same [configuration precedence](../configuration/settings) as other settings.
## Themes Preview
| Dark Theme | Preview | Light Theme | Preview |
| :----------: | :-------------------------------------------------------------------------------------------------------------------------------------------------------------------: | :-----------: | :-------------------------------------------------------------------------------------------------------------------------------------------------------------------: |
| ANSI | <img src="https://gw.alicdn.com/imgextra/i2/O1CN01ZInJiq1GdSZc9gHsI_!!6000000000645-2-tps-1140-934.png" style="zoom:30%;text-align:center;margin: 0 auto;" /> | ANSI Light | <img src="https://gw.alicdn.com/imgextra/i2/O1CN01IiJQFC1h9E3MXQj6W_!!6000000004234-2-tps-1140-934.png" style="zoom:30%;text-align:center;margin: 0 auto;" /> |
| Atom OneDark | <img src="https://gw.alicdn.com/imgextra/i2/O1CN01Zlx1SO1Sw21SkTKV3_!!6000000002310-2-tps-1140-934.png" style="zoom:30%;text-align:center;margin: 0 auto;" /> | Ayu Light | <img src="https://gw.alicdn.com/imgextra/i3/O1CN01zEUc1V1jeUJsnCgQb_!!6000000004573-2-tps-1140-934.png" alt=" " style="zoom:30%;text-align:center;margin: 0 auto;" /> |
| Ayu | <img src="https://gw.alicdn.com/imgextra/i3/O1CN019upo6v1SmPhmRjzfN_!!6000000002289-2-tps-1140-934.png" alt=" " style="zoom:30%;text-align:center;margin: 0 auto;" /> | Default Light | <img src="https://gw.alicdn.com/imgextra/i4/O1CN01RHjrEs1u7TXq3M6l3_!!6000000005990-2-tps-1140-934.png" alt=" " style="zoom:30%;text-align:center;margin: 0 auto;" /> |
| Default | <img src="https://gw.alicdn.com/imgextra/i4/O1CN016pIeXz1pFC8owmR4Q_!!6000000005330-2-tps-1140-934.png" style="zoom:30%;text-align:center;margin: 0 auto;" /> | GitHub Light | <img src="https://gw.alicdn.com/imgextra/i4/O1CN01US2b0g1VETCPAVWLA_!!6000000002621-2-tps-1140-934.png" alt=" " style="zoom:30%;text-align:center;margin: 0 auto;" /> |
| Dracula | <img src="https://gw.alicdn.com/imgextra/i4/O1CN016htnWH20c3gd2LpUR_!!6000000006869-2-tps-1140-934.png" style="zoom:30%;text-align:center;margin: 0 auto;" /> | Google Code | <img src="https://gw.alicdn.com/imgextra/i1/O1CN01Ng29ab23iQ2BuYKz8_!!6000000007289-2-tps-1140-934.png" alt=" " style="zoom:30%;text-align:center;margin: 0 auto;" /> |
| GitHub | <img src="https://gw.alicdn.com/imgextra/i4/O1CN01fFCRda1IQIQ9qDNqv_!!6000000000887-2-tps-1140-934.png" alt=" " style="zoom:30%;text-align:center;margin: 0 auto;" /> | Xcode | <img src="https://gw.alicdn.com/imgextra/i1/O1CN010E3QAi1Huh5o1E9LN_!!6000000000818-2-tps-1140-934.png" alt=" " style="zoom:30%;text-align:center;margin: 0 auto;" /> |
| Dark Theme | Preview | Light Theme | Preview |
| :-: | :-: | :-: | :-: |
| ANSI | <img src="https://gw.alicdn.com/imgextra/i2/O1CN01ZInJiq1GdSZc9gHsI_!!6000000000645-2-tps-1140-934.png" style="zoom:30%;text-align:center;margin: 0 auto;" /> | ANSI Light | <img src="https://gw.alicdn.com/imgextra/i2/O1CN01IiJQFC1h9E3MXQj6W_!!6000000004234-2-tps-1140-934.png" style="zoom:30%;text-align:center;margin: 0 auto;" /> |
| Atom OneDark | <img src="https://gw.alicdn.com/imgextra/i2/O1CN01Zlx1SO1Sw21SkTKV3_!!6000000002310-2-tps-1140-934.png" style="zoom:30%;text-align:center;margin: 0 auto;" /> | Ayu Light | <img src="https://gw.alicdn.com/imgextra/i3/O1CN01zEUc1V1jeUJsnCgQb_!!6000000004573-2-tps-1140-934.png" alt=" " style="zoom:30%;text-align:center;margin: 0 auto;" /> |
| Ayu | <img src="https://gw.alicdn.com/imgextra/i3/O1CN019upo6v1SmPhmRjzfN_!!6000000002289-2-tps-1140-934.png" alt=" " style="zoom:30%;text-align:center;margin: 0 auto;" /> | Default Light | <img src="https://gw.alicdn.com/imgextra/i4/O1CN01RHjrEs1u7TXq3M6l3_!!6000000005990-2-tps-1140-934.png" alt=" " style="zoom:30%;text-align:center;margin: 0 auto;" /> |
| Default | <img src="https://gw.alicdn.com/imgextra/i4/O1CN016pIeXz1pFC8owmR4Q_!!6000000005330-2-tps-1140-934.png" style="zoom:30%;text-align:center;margin: 0 auto;" /> | GitHub Light | <img src="https://gw.alicdn.com/imgextra/i4/O1CN01US2b0g1VETCPAVWLA_!!6000000002621-2-tps-1140-934.png" alt=" " style="zoom:30%;text-align:center;margin: 0 auto;" /> |
| Dracula | <img src="https://gw.alicdn.com/imgextra/i4/O1CN016htnWH20c3gd2LpUR_!!6000000006869-2-tps-1140-934.png" style="zoom:30%;text-align:center;margin: 0 auto;" /> | Google Code | <img src="https://gw.alicdn.com/imgextra/i1/O1CN01Ng29ab23iQ2BuYKz8_!!6000000007289-2-tps-1140-934.png" alt=" " style="zoom:30%;text-align:center;margin: 0 auto;" /> |
| GitHub | <img src="https://gw.alicdn.com/imgextra/i4/O1CN01fFCRda1IQIQ9qDNqv_!!6000000000887-2-tps-1140-934.png" alt=" " style="zoom:30%;text-align:center;margin: 0 auto;" /> | Xcode | <img src="https://gw.alicdn.com/imgextra/i1/O1CN010E3QAi1Huh5o1E9LN_!!6000000000818-2-tps-1140-934.png" alt=" " style="zoom:30%;text-align:center;margin: 0 auto;" /> |

View File

@@ -20,11 +20,10 @@ These commands help you save, restore, and summarize work progress.
| Command | Description | Usage Examples |
| ----------- | --------------------------------------------------------- | ------------------------------------ |
| `/init` | Analyze current directory and create initial context file | `/init` |
| `/summary` | Generate project summary based on conversation history | `/summary` |
| `/compress` | Replace chat history with summary to save Tokens | `/compress` |
| `/resume` | Resume a previous conversation session | `/resume` |
| `/restore` | Restore files to state before tool execution | `/restore` (list) or `/restore <ID>` |
| `/init` | Analyze current directory and create initial context file | `/init` |
### 1.2 Interface and Workspace Control

View File

@@ -16,15 +16,16 @@ The plugin **MUST** run a local HTTP server that implements the **Model Context
- **Endpoint:** The server should expose a single endpoint (e.g., `/mcp`) for all MCP communication.
- **Port:** The server **MUST** listen on a dynamically assigned port (i.e., listen on port `0`).
### 2. Discovery Mechanism: The Lock File
### 2. Discovery Mechanism: The Port File
For Qwen Code to connect, it needs to discover what port your server is using. The plugin **MUST** facilitate this by creating a "lock file" and setting the port environment variable.
For Qwen Code to connect, it needs to discover which IDE instance it's running in and what port your server is using. The plugin **MUST** facilitate this by creating a "discovery file."
- **How the CLI Finds the File:** The CLI reads the port from `QWEN_CODE_IDE_SERVER_PORT`, then reads `~/.qwen/ide/<PORT>.lock`. (Legacy fallbacks exist for older extensions; see note below.)
- **File Location:** The file must be created in a specific directory: `~/.qwen/ide/`. Your plugin must create this directory if it doesn't exist.
- **How the CLI Finds the File:** The CLI determines the Process ID (PID) of the IDE it's running in by traversing the process tree. It then looks for a discovery file that contains this PID in its name.
- **File Location:** The file must be created in a specific directory: `os.tmpdir()/qwen/ide/`. Your plugin must create this directory if it doesn't exist.
- **File Naming Convention:** The filename is critical and **MUST** follow the pattern:
`<PORT>.lock`
- `<PORT>`: The port your MCP server is listening on.
`qwen-code-ide-server-${PID}-${PORT}.json`
- `${PID}`: The process ID of the parent IDE process. Your plugin must determine this PID and include it in the filename.
- `${PORT}`: The port your MCP server is listening on.
- **File Content & Workspace Validation:** The file **MUST** contain a JSON object with the following structure:
```json
@@ -32,20 +33,21 @@ For Qwen Code to connect, it needs to discover what port your server is using. T
"port": 12345,
"workspacePath": "/path/to/project1:/path/to/project2",
"authToken": "a-very-secret-token",
"ppid": 1234,
"ideName": "VS Code"
"ideInfo": {
"name": "vscode",
"displayName": "VS Code"
}
}
```
- `port` (number, required): The port of the MCP server.
- `workspacePath` (string, required): A list of all open workspace root paths, delimited by the OS-specific path separator (`:` for Linux/macOS, `;` for Windows). The CLI uses this path to ensure it's running in the same project folder that's open in the IDE. If the CLI's current working directory is not a sub-directory of `workspacePath`, the connection will be rejected. Your plugin **MUST** provide the correct, absolute path(s) to the root of the open workspace(s).
- `authToken` (string, required): A secret token for securing the connection. The CLI will include this token in an `Authorization: Bearer <token>` header on all requests.
- `ppid` (number, required): The parent process ID of the IDE process.
- `ideName` (string, required): A user-friendly name for the IDE (e.g., `VS Code`, `JetBrains IDE`).
- `ideInfo` (object, required): Information about the IDE.
- `name` (string, required): A short, lowercase identifier for the IDE (e.g., `vscode`, `jetbrains`).
- `displayName` (string, required): A user-friendly name for the IDE (e.g., `VS Code`, `JetBrains IDE`).
- **Authentication:** To secure the connection, the plugin **MUST** generate a unique, secret token and include it in the discovery file. The CLI will then include this token in the `Authorization` header for all requests to the MCP server (e.g., `Authorization: Bearer a-very-secret-token`). Your server **MUST** validate this token on every request and reject any that are unauthorized.
- **Environment Variables (Required):** Your plugin **MUST** set `QWEN_CODE_IDE_SERVER_PORT` in the integrated terminal so the CLI can locate the correct `<PORT>.lock` file.
**Legacy note:** For extensions older than v0.5.1, Qwen Code may fall back to reading JSON files in the system temp directory named `qwen-code-ide-server-<PID>.json` or `qwen-code-ide-server-<PORT>.json`. New integrations should not rely on these legacy files.
- **Tie-Breaking with Environment Variables (Recommended):** For the most reliable experience, your plugin **SHOULD** both create the discovery file and set the `QWEN_CODE_IDE_SERVER_PORT` environment variable in the integrated terminal. The file serves as the primary discovery mechanism, but the environment variable is crucial for tie-breaking. If a user has multiple IDE windows open for the same workspace, the CLI uses the `QWEN_CODE_IDE_SERVER_PORT` variable to identify and connect to the correct window's server.
## II. The Context Interface

View File

@@ -84,12 +84,12 @@ This guide provides solutions to common issues and debugging tips, including top
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 |
| --------- | -------------------------- | --------------------------------------------------------------------------------------------------- |
| 41 | `FatalAuthenticationError` | An error occurred during the authentication process. |
| 42 | `FatalInputError` | Invalid or missing input was provided to the CLI. (non-interactive mode only) |
| 44 | `FatalSandboxError` | An error occurred with the sandboxing environment (e.g. Docker, Podman, or Seatbelt). |
| 52 | `FatalConfigError` | A configuration file (`settings.json`) is invalid or contains errors. |
| Exit Code | Error Type | Description |
| --------- | -------------------------- | ------------------------------------------------------------ |
| 41 | `FatalAuthenticationError` | An error occurred during the authentication process. |
| 42 | `FatalInputError` | Invalid or missing input was provided to the CLI. (non-interactive mode only) |
| 44 | `FatalSandboxError` | An error occurred with the sandboxing environment (e.g. Docker, Podman, or Seatbelt). |
| 52 | `FatalConfigError` | A configuration file (`settings.json`) is invalid or contains errors. |
| 53 | `FatalTurnLimitedError` | The maximum number of conversational turns for the session was reached. (non-interactive mode only) |
## Debugging Tips

View File

@@ -5,8 +5,8 @@
*/
import { describe, it, expect, beforeAll, afterAll } from 'vitest';
import { writeFileSync } from 'node:fs';
import { join } from 'node:path';
import { writeFileSync, readFileSync } from 'node:fs';
import { join, resolve } from 'node:path';
import { TestRig } from './test-helper.js';
// Windows skip (Option A: avoid infra scope)
@@ -121,4 +121,21 @@ d('BOM end-to-end integration', () => {
'BOM_OK UTF-32BE',
);
});
it('Can describe a PNG file', async () => {
const imagePath = resolve(
process.cwd(),
'docs/assets/gemini-screenshot.png',
);
const imageContent = readFileSync(imagePath);
const filename = 'gemini-screenshot.png';
writeFileSync(join(dir, filename), imageContent);
const prompt = `What is shown in the image ${filename}?`;
const output = await rig.run(prompt);
await rig.waitForToolCall('read_file');
const lower = output.toLowerCase();
// The response is non-deterministic, so we just check for some
// keywords that are very likely to be in the response.
expect(lower.includes('gemini')).toBeTruthy();
});
});

73
package-lock.json generated
View File

@@ -568,6 +568,7 @@
}
],
"license": "MIT",
"peer": true,
"engines": {
"node": ">=18"
},
@@ -591,6 +592,7 @@
}
],
"license": "MIT",
"peer": true,
"engines": {
"node": ">=18"
}
@@ -2155,6 +2157,7 @@
"resolved": "https://registry.npmjs.org/@opentelemetry/api/-/api-1.9.0.tgz",
"integrity": "sha512-3giAOQvZiH5F9bMlMiv8+GSPMeqg0dbaeo58/0SlA9sxSqZhnUtxzX9/2FzyhS9sWQf5S0GJE0AKBrFqjpeYcg==",
"license": "Apache-2.0",
"peer": true,
"engines": {
"node": ">=8.0.0"
}
@@ -3668,6 +3671,7 @@
"resolved": "https://registry.npmjs.org/@testing-library/dom/-/dom-10.4.1.tgz",
"integrity": "sha512-o4PXJQidqJl82ckFaXUeoAW+XysPLauYI43Abki5hABd853iMhitooc6znOnczgbTYmEP6U6/y1ZyKAIsvMKGg==",
"license": "MIT",
"peer": true,
"dependencies": {
"@babel/code-frame": "^7.10.4",
"@babel/runtime": "^7.12.5",
@@ -4138,6 +4142,7 @@
"integrity": "sha512-AwAfQ2Wa5bCx9WP8nZL2uMZWod7J7/JSplxbTmBQ5ms6QpqNYm672H0Vu9ZVKVngQ+ii4R/byguVEUZQyeg44g==",
"devOptional": true,
"license": "MIT",
"peer": true,
"dependencies": {
"csstype": "^3.0.2"
}
@@ -4148,6 +4153,7 @@
"integrity": "sha512-4hOiT/dwO8Ko0gV1m/TJZYk3y0KBnY9vzDh7W+DH17b2HFSOGgdj33dhihPeuy3l0q23+4e+hoXHV6hCC4dCXw==",
"dev": true,
"license": "MIT",
"peer": true,
"peerDependencies": {
"@types/react": "^19.0.0"
}
@@ -4353,6 +4359,7 @@
"integrity": "sha512-6sMvZePQrnZH2/cJkwRpkT7DxoAWh+g6+GFRK6bV3YQo7ogi3SX5rgF6099r5Q53Ma5qeT7LGmOmuIutF4t3lA==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@typescript-eslint/scope-manager": "8.35.0",
"@typescript-eslint/types": "8.35.0",
@@ -5128,6 +5135,7 @@
"resolved": "https://registry.npmjs.org/acorn/-/acorn-8.15.0.tgz",
"integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==",
"license": "MIT",
"peer": true,
"bin": {
"acorn": "bin/acorn"
},
@@ -5522,8 +5530,7 @@
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/array-flatten/-/array-flatten-1.1.1.tgz",
"integrity": "sha512-PCVAQswWemu6UdxsDFFX/+gVeYqKAod3D3UVm91jHwynguOwAvYPhx8nNlM++NqRcK6CxxpUafjmhIdKiHibqg==",
"license": "MIT",
"peer": true
"license": "MIT"
},
"node_modules/array-includes": {
"version": "3.1.9",
@@ -6858,7 +6865,6 @@
"resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-0.5.4.tgz",
"integrity": "sha512-FveZTNuGw04cxlAiWbzi6zTAL/lhehaWbTtgluJh4/E95DqMwTmha3KZN1aAWA8cFIhHzMZUvLevkw5Rqk+tSQ==",
"license": "MIT",
"peer": true,
"dependencies": {
"safe-buffer": "5.2.1"
},
@@ -7976,6 +7982,7 @@
"integrity": "sha512-GsGizj2Y1rCWDu6XoEekL3RLilp0voSePurjZIkxL3wlm5o5EC9VpgaP7lrCvjnkuLvzFBQWB3vWB3K5KQTveQ==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@eslint-community/eslint-utils": "^4.2.0",
"@eslint-community/regexpp": "^4.12.1",
@@ -8511,7 +8518,6 @@
"resolved": "https://registry.npmjs.org/express/-/express-4.21.2.tgz",
"integrity": "sha512-28HqgMZAmih1Czt9ny7qr6ek2qddF4FclbMzwhCREB6OFfH+rXAnuNCwo1/wFvrtbgsQDb4kSbX9de9lFbrXnA==",
"license": "MIT",
"peer": true,
"dependencies": {
"accepts": "~1.3.8",
"array-flatten": "1.1.1",
@@ -8573,7 +8579,6 @@
"resolved": "https://registry.npmjs.org/cookie/-/cookie-0.7.1.tgz",
"integrity": "sha512-6DnInpx7SJ2AK3+CTUE/ZM0vWTUboZCegxhC2xiIydHR9jNuTAASBrfEpHhiGOZw/nX51bHt6YQl8jsGo4y/0w==",
"license": "MIT",
"peer": true,
"engines": {
"node": ">= 0.6"
}
@@ -8583,7 +8588,6 @@
"resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz",
"integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==",
"license": "MIT",
"peer": true,
"dependencies": {
"ms": "2.0.0"
}
@@ -8593,7 +8597,6 @@
"resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.1.tgz",
"integrity": "sha512-RwNA9Z/7PrK06rYLIzFMlaF+l73iwpzsqRIFgbMLbTcLD6cOao82TaWefPXQvB2fOC4AjuYSEndS7N/mTCbkdQ==",
"license": "MIT",
"peer": true,
"engines": {
"node": ">= 0.8"
}
@@ -8760,7 +8763,6 @@
"resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-1.3.1.tgz",
"integrity": "sha512-6BN9trH7bp3qvnrRyzsBz+g3lZxTNZTbVO2EV1CS0WIcDbawYVdYvGflME/9QP0h0pYlCDBCTjYa9nZzMDpyxQ==",
"license": "MIT",
"peer": true,
"dependencies": {
"debug": "2.6.9",
"encodeurl": "~2.0.0",
@@ -8779,7 +8781,6 @@
"resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz",
"integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==",
"license": "MIT",
"peer": true,
"dependencies": {
"ms": "2.0.0"
}
@@ -8788,15 +8789,13 @@
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz",
"integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==",
"license": "MIT",
"peer": true
"license": "MIT"
},
"node_modules/finalhandler/node_modules/statuses": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.1.tgz",
"integrity": "sha512-RwNA9Z/7PrK06rYLIzFMlaF+l73iwpzsqRIFgbMLbTcLD6cOao82TaWefPXQvB2fOC4AjuYSEndS7N/mTCbkdQ==",
"license": "MIT",
"peer": true,
"engines": {
"node": ">= 0.8"
}
@@ -9910,6 +9909,7 @@
"resolved": "https://registry.npmjs.org/ink/-/ink-6.2.3.tgz",
"integrity": "sha512-fQkfEJjKbLXIcVWEE3MvpYSnwtbbmRsmeNDNz1pIuOFlwE+UF2gsy228J36OXKZGWJWZJKUigphBSqCNMcARtg==",
"license": "MIT",
"peer": true,
"dependencies": {
"@alcalzone/ansi-tokenize": "^0.2.0",
"ansi-escapes": "^7.0.0",
@@ -11864,7 +11864,6 @@
"resolved": "https://registry.npmjs.org/methods/-/methods-1.1.2.tgz",
"integrity": "sha512-iclAHeNqNm68zFtnZ0e+1L2yUIdvzNoauKU4WBA3VvH/vPFieF7qfRlwUZU+DA9P9bPXIS90ulxoUoCH23sV2w==",
"license": "MIT",
"peer": true,
"engines": {
"node": ">= 0.6"
}
@@ -13163,8 +13162,7 @@
"version": "0.1.12",
"resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-0.1.12.tgz",
"integrity": "sha512-RA1GjUVMnvYFxuqovrEqZoxxW5NUZqbwKtYz/Tt7nXerk0LbLblQmrsgdeOxV5SFHf0UDggjS/bSeOZwt1pmEQ==",
"license": "MIT",
"peer": true
"license": "MIT"
},
"node_modules/path-type": {
"version": "3.0.0",
@@ -13823,6 +13821,7 @@
"resolved": "https://registry.npmjs.org/react/-/react-19.1.0.tgz",
"integrity": "sha512-FS+XFBNvn3GTAWq26joslQgWNoFu08F4kl0J4CgdNKADkdSGXQyTCnKteIAJy96Br6YbpEU1LSzV5dYtjMkMDg==",
"license": "MIT",
"peer": true,
"engines": {
"node": ">=0.10.0"
}
@@ -13833,6 +13832,7 @@
"integrity": "sha512-cq/o30z9W2Wb4rzBefjv5fBalHU0rJGZCHAkf/RHSBWSSYwh8PlQTqqOJmgIIbBtpj27T6FIPXeomIjZtCNVqA==",
"devOptional": true,
"license": "MIT",
"peer": true,
"dependencies": {
"shell-quote": "^1.6.1",
"ws": "^7"
@@ -13866,6 +13866,7 @@
"integrity": "sha512-Xs1hdnE+DyKgeHJeJznQmYMIBG3TKIHJJT95Q58nHLSrElKlGQqDTR2HQ9fx5CN/Gk6Vh/kupBTDLU11/nDk/g==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"scheduler": "^0.26.0"
},
@@ -15931,6 +15932,7 @@
"integrity": "sha512-M7BAV6Rlcy5u+m6oPhAPFgJTzAioX/6B0DxyvDlo9l8+T3nLKbrczg2WLUyzd45L8RqfUMyGPzekbMvX2Ldkwg==",
"dev": true,
"license": "MIT",
"peer": true,
"engines": {
"node": ">=12"
},
@@ -16110,7 +16112,8 @@
"version": "2.8.1",
"resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz",
"integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==",
"license": "0BSD"
"license": "0BSD",
"peer": true
},
"node_modules/tsx": {
"version": "4.20.3",
@@ -16118,6 +16121,7 @@
"integrity": "sha512-qjbnuR9Tr+FJOMBqJCW5ehvIo/buZq7vH7qD7JziU98h6l3qGy0a/yPFjwO+y0/T7GFpNgNAvEcPPVfyT8rrPQ==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"esbuild": "~0.25.0",
"get-tsconfig": "^4.7.5"
@@ -16312,6 +16316,7 @@
"integrity": "sha512-p1diW6TqL9L07nNxvRMM7hMMw4c5XOo/1ibL4aAIGmSAt9slTE1Xgw5KWuof2uTOvCg9BY7ZRi+GaF+7sfgPeQ==",
"dev": true,
"license": "Apache-2.0",
"peer": true,
"bin": {
"tsc": "bin/tsc",
"tsserver": "bin/tsserver"
@@ -16386,7 +16391,6 @@
"version": "7.15.0",
"resolved": "https://registry.npmjs.org/undici/-/undici-7.15.0.tgz",
"integrity": "sha512-7oZJCPvvMvTd0OlqWsIxTuItTpJBpU1tcbVl24FMn3xt3+VSunwUasmfPJRE57oNO1KsZ4PgA1xTdAX4hq8NyQ==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=20.18.1"
@@ -16619,7 +16623,6 @@
"resolved": "https://registry.npmjs.org/utils-merge/-/utils-merge-1.0.1.tgz",
"integrity": "sha512-pMZTvIkT1d+TFGvDOqodOclx0QWkkgi6Tdoa8gC8ffGAAqz9pzPTZWAybbsHHoED/ztMtkv/VoYTYyShUn81hA==",
"license": "MIT",
"peer": true,
"engines": {
"node": ">= 0.4.0"
}
@@ -16675,6 +16678,7 @@
"integrity": "sha512-ixXJB1YRgDIw2OszKQS9WxGHKwLdCsbQNkpJN171udl6szi/rIySHL6/Os3s2+oE4P/FLD4dxg4mD7Wust+u5g==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"esbuild": "^0.25.0",
"fdir": "^6.4.6",
@@ -16788,6 +16792,7 @@
"integrity": "sha512-M7BAV6Rlcy5u+m6oPhAPFgJTzAioX/6B0DxyvDlo9l8+T3nLKbrczg2WLUyzd45L8RqfUMyGPzekbMvX2Ldkwg==",
"dev": true,
"license": "MIT",
"peer": true,
"engines": {
"node": ">=12"
},
@@ -16801,6 +16806,7 @@
"integrity": "sha512-LUCP5ev3GURDysTWiP47wRRUpLKMOfPh+yKTx3kVIEiu5KOMeqzpnYNsKyOoVrULivR8tLcks4+lga33Whn90A==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@types/chai": "^5.2.2",
"@vitest/expect": "3.2.4",
@@ -17479,6 +17485,7 @@
"resolved": "https://registry.npmjs.org/zod/-/zod-3.25.76.tgz",
"integrity": "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==",
"license": "MIT",
"peer": true,
"funding": {
"url": "https://github.com/sponsors/colinhacks"
}
@@ -17525,7 +17532,7 @@
"strip-ansi": "^7.1.0",
"strip-json-comments": "^3.1.1",
"tar": "^7.5.2",
"undici": "^6.22.0",
"undici": "^7.10.0",
"update-notifier": "^7.3.1",
"wrap-ansi": "9.0.2",
"yargs": "^17.7.2",
@@ -17607,15 +17614,6 @@
"url": "https://github.com/sponsors/sindresorhus"
}
},
"packages/cli/node_modules/undici": {
"version": "6.22.0",
"resolved": "https://registry.npmjs.org/undici/-/undici-6.22.0.tgz",
"integrity": "sha512-hU/10obOIu62MGYjdskASR3CUAiYaFTtC9Pa6vHyf//mAipSvSQg6od2CnJswq7fvzNS3zJhxoRkgNVaHurWKw==",
"license": "MIT",
"engines": {
"node": ">=18.17"
}
},
"packages/core": {
"name": "@qwen-code/qwen-code-core",
"version": "0.5.1",
@@ -17661,7 +17659,7 @@
"simple-git": "^3.28.0",
"strip-ansi": "^7.1.0",
"tiktoken": "^1.0.21",
"undici": "^6.22.0",
"undici": "^7.10.0",
"uuid": "^9.0.1",
"ws": "^8.18.0"
},
@@ -17749,6 +17747,7 @@
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz",
"integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
"license": "MIT",
"peer": true,
"engines": {
"node": ">=12"
},
@@ -17756,22 +17755,12 @@
"url": "https://github.com/sponsors/jonschlinkert"
}
},
"packages/core/node_modules/undici": {
"version": "6.22.0",
"resolved": "https://registry.npmjs.org/undici/-/undici-6.22.0.tgz",
"integrity": "sha512-hU/10obOIu62MGYjdskASR3CUAiYaFTtC9Pa6vHyf//mAipSvSQg6od2CnJswq7fvzNS3zJhxoRkgNVaHurWKw==",
"license": "MIT",
"engines": {
"node": ">=18.17"
}
},
"packages/sdk-typescript": {
"name": "@qwen-code/sdk",
"version": "0.5.1",
"license": "Apache-2.0",
"dependencies": {
"@modelcontextprotocol/sdk": "^1.0.4",
"tiktoken": "^1.0.21"
"@modelcontextprotocol/sdk": "^1.0.4"
},
"devDependencies": {
"@types/node": "^20.14.0",
@@ -20229,7 +20218,7 @@
"@types/react": "^19.1.8",
"@types/react-dom": "^19.1.6",
"@types/semver": "^7.7.1",
"@types/vscode": "^1.85.0",
"@types/vscode": "^1.99.0",
"@typescript-eslint/eslint-plugin": "^8.31.1",
"@typescript-eslint/parser": "^8.31.1",
"@vscode/vsce": "^3.6.0",
@@ -20244,7 +20233,7 @@
"vitest": "^3.2.4"
},
"engines": {
"vscode": "^1.85.0"
"vscode": "^1.99.0"
}
},
"packages/vscode-ide-companion/node_modules/@types/react": {

View File

@@ -64,7 +64,7 @@
"strip-ansi": "^7.1.0",
"strip-json-comments": "^3.1.1",
"tar": "^7.5.2",
"undici": "^6.22.0",
"undici": "^7.10.0",
"extract-zip": "^2.0.1",
"update-notifier": "^7.3.1",
"wrap-ansi": "9.0.2",

View File

@@ -206,18 +206,6 @@ describe('parseArguments', () => {
expect(argv.prompt).toBeUndefined();
});
it('should allow -r flag as alias for --resume', async () => {
process.argv = ['node', 'script.js', '-r', 'session-123'];
const argv = await parseArguments({} as Settings);
expect(argv.resume).toBe('session-123');
});
it('should allow -c flag as alias for --continue', async () => {
process.argv = ['node', 'script.js', '-c'];
const argv = await parseArguments({} as Settings);
expect(argv.continue).toBe(true);
});
it('should convert positional query argument to prompt by default', async () => {
process.argv = ['node', 'script.js', 'Hi Gemini'];
const argv = await parseArguments({} as Settings);

View File

@@ -299,6 +299,7 @@ export async function parseArguments(settings: Settings): Promise<CliArgs> {
'Set the approval mode: plan (plan only), default (prompt for approval), auto-edit (auto-approve edit tools), yolo (auto-approve all tools)',
})
.option('checkpointing', {
alias: 'c',
type: 'boolean',
description: 'Enables checkpointing of file edits',
default: false,
@@ -421,14 +422,12 @@ export async function parseArguments(settings: Settings): Promise<CliArgs> {
default: false,
})
.option('continue', {
alias: 'c',
type: 'boolean',
description:
'Resume the most recent session for the current project.',
default: false,
})
.option('resume', {
alias: 'r',
type: 'string',
description:
'Resume a specific session by its ID. Use without an ID to show session picker.',
@@ -1003,7 +1002,6 @@ export async function loadCliConfig(
enableToolOutputTruncation: settings.tools?.enableToolOutputTruncation,
eventEmitter: appEvents,
useSmartEdit: argv.useSmartEdit ?? settings.useSmartEdit,
gitCoAuthor: settings.general?.gitCoAuthor,
output: {
format: outputSettingsFormat,
},

View File

@@ -581,7 +581,7 @@ function extensionConsentString(extensionConfig: ExtensionConfig): string {
}
if (extensionConfig.contextFileName) {
output.push(
`This extension will append info to your QWEN.md context using ${extensionConfig.contextFileName}`,
`This extension will append info to your gemini.md context using ${extensionConfig.contextFileName}`,
);
}
if (extensionConfig.excludeTools) {

View File

@@ -147,16 +147,6 @@ const SETTINGS_SCHEMA = {
description: 'Disable update notification prompts.',
showInDialog: false,
},
gitCoAuthor: {
type: 'boolean',
label: 'Git Co-Author',
category: 'General',
requiresRestart: false,
default: true,
description:
'Automatically add a Co-authored-by trailer to git commit messages when commits are made through Qwen Code.',
showInDialog: false,
},
checkpointing: {
type: 'object',
label: 'Checkpointing',
@@ -294,7 +284,7 @@ const SETTINGS_SCHEMA = {
requiresRestart: false,
default: false,
description:
'Show Qwen Code status and thoughts in the terminal window title',
'Show Gemini CLI status and thoughts in the terminal window title',
showInDialog: true,
},
hideTips: {
@@ -322,7 +312,7 @@ const SETTINGS_SCHEMA = {
requiresRestart: false,
default: false,
description:
'Hide the context summary (QWEN.md, MCP servers) above the input.',
'Hide the context summary (GEMINI.md, MCP servers) above the input.',
showInDialog: true,
},
footer: {
@@ -528,7 +518,7 @@ const SETTINGS_SCHEMA = {
category: 'Model',
requiresRestart: false,
default: undefined as string | undefined,
description: 'The model to use for conversations.',
description: 'The Gemini model to use for conversations.',
showInDialog: false,
},
maxSessionTurns: {
@@ -659,22 +649,6 @@ const SETTINGS_SCHEMA = {
childKey: 'disableCacheControl',
showInDialog: true,
},
schemaCompliance: {
type: 'enum',
label: 'Tool Schema Compliance',
category: 'Generation Configuration',
requiresRestart: false,
default: 'auto',
description:
'The compliance mode for tool schemas sent to the model. Use "openapi_30" for strict OpenAPI 3.0 compatibility (e.g., for Gemini).',
parentKey: 'generationConfig',
childKey: 'schemaCompliance',
showInDialog: true,
options: [
{ value: 'auto', label: 'Auto (Default)' },
{ value: 'openapi_30', label: 'OpenAPI 3.0 Strict' },
],
},
},
},
},

View File

@@ -379,8 +379,8 @@ describe('gemini.tsx main function kitty protocol', () => {
beforeEach(() => {
// Set no relaunch in tests since process spawning causing issues in tests
originalEnvNoRelaunch = process.env['QWEN_CODE_NO_RELAUNCH'];
process.env['QWEN_CODE_NO_RELAUNCH'] = 'true';
originalEnvNoRelaunch = process.env['GEMINI_CLI_NO_RELAUNCH'];
process.env['GEMINI_CLI_NO_RELAUNCH'] = 'true';
// eslint-disable-next-line @typescript-eslint/no-explicit-any
if (!(process.stdin as any).setRawMode) {
@@ -402,9 +402,9 @@ describe('gemini.tsx main function kitty protocol', () => {
afterEach(() => {
// Restore original env variables
if (originalEnvNoRelaunch !== undefined) {
process.env['QWEN_CODE_NO_RELAUNCH'] = originalEnvNoRelaunch;
process.env['GEMINI_CLI_NO_RELAUNCH'] = originalEnvNoRelaunch;
} else {
delete process.env['QWEN_CODE_NO_RELAUNCH'];
delete process.env['GEMINI_CLI_NO_RELAUNCH'];
}
});

View File

@@ -58,7 +58,7 @@ import { getUserStartupWarnings } from './utils/userStartupWarnings.js';
import { getCliVersion } from './utils/version.js';
import { computeWindowTitle } from './utils/windowTitle.js';
import { validateNonInteractiveAuth } from './validateNonInterActiveAuth.js';
import { showResumeSessionPicker } from './ui/components/StandaloneSessionPicker.js';
import { showResumeSessionPicker } from './ui/components/ResumeSessionPicker.js';
export function validateDnsResolutionOrder(
order: string | undefined,
@@ -92,7 +92,7 @@ function getNodeMemoryArgs(isDebugMode: boolean): string[] {
);
}
if (process.env['QWEN_CODE_NO_RELAUNCH']) {
if (process.env['GEMINI_CLI_NO_RELAUNCH']) {
return [];
}

View File

@@ -310,7 +310,6 @@ export default {
'Tool Output Truncation Lines': 'Tool Output Truncation Lines',
'Folder Trust': 'Folder Trust',
'Vision Model Preview': 'Vision Model Preview',
'Tool Schema Compliance': 'Tool Schema Compliance',
// Settings enum options
'Auto (detect from system)': 'Auto (detect from system)',
Text: 'Text',
@@ -636,8 +635,8 @@ export default {
'The /directory add command is not supported in restrictive sandbox profiles. Please use --include-directories when starting the session instead.':
'The /directory add command is not supported in restrictive sandbox profiles. Please use --include-directories when starting the session instead.',
"Error adding '{{path}}': {{error}}": "Error adding '{{path}}': {{error}}",
'Successfully added QWEN.md files from the following directories if there are:\n- {{directories}}':
'Successfully added QWEN.md files from the following directories if there are:\n- {{directories}}',
'Successfully added GEMINI.md files from the following directories if there are:\n- {{directories}}':
'Successfully added GEMINI.md files from the following directories if there are:\n- {{directories}}',
'Error refreshing memory: {{error}}': 'Error refreshing memory: {{error}}',
'Successfully added directories:\n- {{directories}}':
'Successfully added directories:\n- {{directories}}',

View File

@@ -300,7 +300,6 @@ export default {
'Tool Output Truncation Lines': '工具输出截断行数',
'Folder Trust': '文件夹信任',
'Vision Model Preview': '视觉模型预览',
'Tool Schema Compliance': '工具 Schema 兼容性',
// Settings enum options
'Auto (detect from system)': '自动(从系统检测)',
Text: '文本',
@@ -602,8 +601,8 @@ export default {
'The /directory add command is not supported in restrictive sandbox profiles. Please use --include-directories when starting the session instead.':
'/directory add 命令在限制性沙箱配置文件中不受支持。请改为在启动会话时使用 --include-directories。',
"Error adding '{{path}}': {{error}}": "添加 '{{path}}' 时出错:{{error}}",
'Successfully added QWEN.md files from the following directories if there are:\n- {{directories}}':
'如果存在,已成功从以下目录添加 QWEN.md 文件:\n- {{directories}}',
'Successfully added GEMINI.md files from the following directories if there are:\n- {{directories}}':
'如果存在,已成功从以下目录添加 GEMINI.md 文件:\n- {{directories}}',
'Error refreshing memory: {{error}}': '刷新内存时出错:{{error}}',
'Successfully added directories:\n- {{directories}}':
'成功添加目录:\n- {{directories}}',

View File

@@ -29,7 +29,6 @@ import { modelCommand } from '../ui/commands/modelCommand.js';
import { permissionsCommand } from '../ui/commands/permissionsCommand.js';
import { quitCommand } from '../ui/commands/quitCommand.js';
import { restoreCommand } from '../ui/commands/restoreCommand.js';
import { resumeCommand } from '../ui/commands/resumeCommand.js';
import { settingsCommand } from '../ui/commands/settingsCommand.js';
import { statsCommand } from '../ui/commands/statsCommand.js';
import { summaryCommand } from '../ui/commands/summaryCommand.js';
@@ -77,7 +76,6 @@ export class BuiltinCommandLoader implements ICommandLoader {
...(this.config?.getFolderTrust() ? [permissionsCommand] : []),
quitCommand,
restoreCommand(this.config),
resumeCommand,
statsCommand,
summaryCommand,
themeCommand,

View File

@@ -53,7 +53,6 @@ import { useEditorSettings } from './hooks/useEditorSettings.js';
import { useSettingsCommand } from './hooks/useSettingsCommand.js';
import { useModelCommand } from './hooks/useModelCommand.js';
import { useApprovalModeCommand } from './hooks/useApprovalModeCommand.js';
import { useResumeCommand } from './hooks/useResumeCommand.js';
import { useSlashCommandProcessor } from './hooks/slashCommandProcessor.js';
import { useVimMode } from './contexts/VimModeContext.js';
import { useConsoleMessages } from './hooks/useConsoleMessages.js';
@@ -204,7 +203,7 @@ export const AppContainer = (props: AppContainerProps) => {
const { stdout } = useStdout();
// Additional hooks moved from App.tsx
const { stats: sessionStats, startNewSession } = useSessionStats();
const { stats: sessionStats } = useSessionStats();
const logger = useLogger(config.storage, sessionStats.sessionId);
const branchName = useGitBranchName(config.getTargetDir());
@@ -436,18 +435,6 @@ export const AppContainer = (props: AppContainerProps) => {
const { isModelDialogOpen, openModelDialog, closeModelDialog } =
useModelCommand();
const {
isResumeDialogOpen,
openResumeDialog,
closeResumeDialog,
handleResume,
} = useResumeCommand({
config,
historyManager,
startNewSession,
remount: refreshStatic,
});
const {
showWorkspaceMigrationDialog,
workspaceExtensions,
@@ -501,7 +488,6 @@ export const AppContainer = (props: AppContainerProps) => {
addConfirmUpdateExtensionRequest,
openSubagentCreateDialog,
openAgentsManagerDialog,
openResumeDialog,
}),
[
openAuthDialog,
@@ -516,7 +502,6 @@ export const AppContainer = (props: AppContainerProps) => {
addConfirmUpdateExtensionRequest,
openSubagentCreateDialog,
openAgentsManagerDialog,
openResumeDialog,
],
);
@@ -1209,8 +1194,7 @@ export const AppContainer = (props: AppContainerProps) => {
!!proQuotaRequest ||
isSubagentCreateDialogOpen ||
isAgentsManagerDialogOpen ||
isApprovalModeDialogOpen ||
isResumeDialogOpen;
isApprovalModeDialogOpen;
const pendingHistoryItems = useMemo(
() => [...pendingSlashCommandHistoryItems, ...pendingGeminiHistoryItems],
@@ -1238,7 +1222,6 @@ export const AppContainer = (props: AppContainerProps) => {
isModelDialogOpen,
isPermissionsDialogOpen,
isApprovalModeDialogOpen,
isResumeDialogOpen,
slashCommands,
pendingSlashCommandHistoryItems,
commandContext,
@@ -1329,7 +1312,6 @@ export const AppContainer = (props: AppContainerProps) => {
isModelDialogOpen,
isPermissionsDialogOpen,
isApprovalModeDialogOpen,
isResumeDialogOpen,
slashCommands,
pendingSlashCommandHistoryItems,
commandContext,
@@ -1439,10 +1421,6 @@ export const AppContainer = (props: AppContainerProps) => {
// Subagent dialogs
closeSubagentCreateDialog,
closeAgentsManagerDialog,
// Resume session dialog
openResumeDialog,
closeResumeDialog,
handleResume,
}),
[
handleThemeSelect,
@@ -1475,10 +1453,6 @@ export const AppContainer = (props: AppContainerProps) => {
// Subagent dialogs
closeSubagentCreateDialog,
closeAgentsManagerDialog,
// Resume session dialog
openResumeDialog,
closeResumeDialog,
handleResume,
],
);

View File

@@ -130,7 +130,7 @@ export const directoryCommand: SlashCommand = {
{
type: MessageType.INFO,
text: t(
'Successfully added QWEN.md files from the following directories if there are:\n- {{directories}}',
'Successfully added GEMINI.md files from the following directories if there are:\n- {{directories}}',
{
directories: added.join('\n- '),
},

View File

@@ -89,7 +89,7 @@ describe('restoreCommand', () => {
).toEqual({
type: 'message',
messageType: 'error',
content: 'Could not determine the .qwen directory path.',
content: 'Could not determine the .gemini directory path.',
});
});

View File

@@ -28,7 +28,7 @@ async function restoreAction(
return {
type: 'message',
messageType: 'error',
content: 'Could not determine the .qwen directory path.',
content: 'Could not determine the .gemini directory path.',
};
}

View File

@@ -1,38 +0,0 @@
/**
* @license
* Copyright 2025 Qwen Code
* SPDX-License-Identifier: Apache-2.0
*/
import { describe, it, expect, beforeEach } from 'vitest';
import { resumeCommand } from './resumeCommand.js';
import { type CommandContext } from './types.js';
import { createMockCommandContext } from '../../test-utils/mockCommandContext.js';
describe('resumeCommand', () => {
let mockContext: CommandContext;
beforeEach(() => {
mockContext = createMockCommandContext();
});
it('should return a dialog action to open the resume dialog', async () => {
// Ensure the command has an action to test.
if (!resumeCommand.action) {
throw new Error('The resume command must have an action.');
}
const result = await resumeCommand.action(mockContext, '');
// Assert that the action returns the correct object to trigger the resume dialog.
expect(result).toEqual({
type: 'dialog',
dialog: 'resume',
});
});
it('should have the correct name and description', () => {
expect(resumeCommand.name).toBe('resume');
expect(resumeCommand.description).toBe('Resume a previous session');
});
});

View File

@@ -1,21 +0,0 @@
/**
* @license
* Copyright 2025 Qwen Code
* SPDX-License-Identifier: Apache-2.0
*/
import type { SlashCommand, SlashCommandActionReturn } from './types.js';
import { CommandKind } from './types.js';
import { t } from '../../i18n/index.js';
export const resumeCommand: SlashCommand = {
name: 'resume',
kind: CommandKind.BUILT_IN,
get description() {
return t('Resume a previous session');
},
action: async (): Promise<SlashCommandActionReturn> => ({
type: 'dialog',
dialog: 'resume',
}),
};

View File

@@ -124,8 +124,7 @@ export interface OpenDialogActionReturn {
| 'subagent_create'
| 'subagent_list'
| 'permissions'
| 'approval-mode'
| 'resume';
| 'approval-mode';
}
/**

View File

@@ -36,7 +36,6 @@ import { WelcomeBackDialog } from './WelcomeBackDialog.js';
import { ModelSwitchDialog } from './ModelSwitchDialog.js';
import { AgentCreationWizard } from './subagents/create/AgentCreationWizard.js';
import { AgentsManagerDialog } from './subagents/manage/AgentsManagerDialog.js';
import { SessionPicker } from './SessionPicker.js';
interface DialogManagerProps {
addItem: UseHistoryManagerReturn['addItem'];
@@ -291,16 +290,5 @@ export const DialogManager = ({
);
}
if (uiState.isResumeDialogOpen) {
return (
<SessionPicker
sessionService={config.getSessionService()}
currentBranch={uiState.branchName}
onSelect={uiActions.handleResume}
onCancel={uiActions.closeResumeDialog}
/>
);
}
return null;
};

View File

@@ -115,7 +115,7 @@ export function PermissionsModifyTrustDialog({
{needsRestart && (
<Box marginLeft={1} marginTop={1}>
<Text color={theme.status.warning}>
To apply the trust changes, Qwen Code must be restarted. Press
To apply the trust changes, Gemini CLI must be restarted. Press
&apos;r&apos; to restart CLI now.
</Text>
</Box>

View File

@@ -0,0 +1,436 @@
/**
* @license
* Copyright 2025 Qwen Code
* SPDX-License-Identifier: Apache-2.0
*/
import { useState, useEffect, useCallback, useRef } from 'react';
import { render, Box, Text, useInput, useApp } from 'ink';
import {
SessionService,
type SessionListItem,
type ListSessionsResult,
getGitBranch,
} from '@qwen-code/qwen-code-core';
import { theme } from '../semantic-colors.js';
import { formatRelativeTime } from '../utils/formatters.js';
const PAGE_SIZE = 20;
interface SessionPickerProps {
sessionService: SessionService;
currentBranch?: string;
onSelect: (sessionId: string) => void;
onCancel: () => void;
}
/**
* Truncates text to fit within a given width, adding ellipsis if needed.
*/
function truncateText(text: string, maxWidth: number): string {
if (text.length <= maxWidth) return text;
if (maxWidth <= 3) return text.slice(0, maxWidth);
return text.slice(0, maxWidth - 3) + '...';
}
function SessionPicker({
sessionService,
currentBranch,
onSelect,
onCancel,
}: SessionPickerProps): React.JSX.Element {
const { exit } = useApp();
const [selectedIndex, setSelectedIndex] = useState(0);
const [sessionState, setSessionState] = useState<{
sessions: SessionListItem[];
hasMore: boolean;
nextCursor?: number;
}>({
sessions: [],
hasMore: true,
nextCursor: undefined,
});
const isLoadingMoreRef = useRef(false);
const [filterByBranch, setFilterByBranch] = useState(false);
const [isExiting, setIsExiting] = useState(false);
const [terminalSize, setTerminalSize] = useState({
width: process.stdout.columns || 80,
height: process.stdout.rows || 24,
});
// Update terminal size on resize
useEffect(() => {
const handleResize = () => {
setTerminalSize({
width: process.stdout.columns || 80,
height: process.stdout.rows || 24,
});
};
process.stdout.on('resize', handleResize);
return () => {
process.stdout.off('resize', handleResize);
};
}, []);
// Filter sessions by current branch if filter is enabled
const filteredSessions =
filterByBranch && currentBranch
? sessionState.sessions.filter(
(session) => session.gitBranch === currentBranch,
)
: sessionState.sessions;
const hasSentinel = sessionState.hasMore;
// Reset selection when filter changes
useEffect(() => {
setSelectedIndex(0);
}, [filterByBranch]);
const loadMoreSessions = useCallback(async () => {
if (!sessionState.hasMore || isLoadingMoreRef.current) return;
isLoadingMoreRef.current = true;
try {
const result: ListSessionsResult = await sessionService.listSessions({
size: PAGE_SIZE,
cursor: sessionState.nextCursor,
});
setSessionState((prev) => ({
sessions: [...prev.sessions, ...result.items],
hasMore: result.hasMore && result.nextCursor !== undefined,
nextCursor: result.nextCursor,
}));
} finally {
isLoadingMoreRef.current = false;
}
}, [sessionService, sessionState.hasMore, sessionState.nextCursor]);
// Calculate visible items
// Reserved space: header (1), footer (1), separators (2), borders (2)
const reservedLines = 6;
// Each item takes 2 lines (prompt + metadata) + 1 line margin between items
// On average, this is ~3 lines per item, but the last item has no margin
const itemHeight = 3;
const maxVisibleItems = Math.max(
1,
Math.floor((terminalSize.height - reservedLines) / itemHeight),
);
// Calculate scroll offset
const scrollOffset = (() => {
if (filteredSessions.length <= maxVisibleItems) return 0;
const halfVisible = Math.floor(maxVisibleItems / 2);
let offset = selectedIndex - halfVisible;
offset = Math.max(0, offset);
offset = Math.min(filteredSessions.length - maxVisibleItems, offset);
return offset;
})();
const visibleSessions = filteredSessions.slice(
scrollOffset,
scrollOffset + maxVisibleItems,
);
const showScrollUp = scrollOffset > 0;
const showScrollDown =
scrollOffset + maxVisibleItems < filteredSessions.length;
// Sentinel (invisible) sits after the last session item; consider it visible
// once the viewport reaches the final real item.
const sentinelVisible =
hasSentinel && scrollOffset + maxVisibleItems >= filteredSessions.length;
// Load more when sentinel enters view or when filtered list is empty.
useEffect(() => {
if (!sessionState.hasMore || isLoadingMoreRef.current) return;
const shouldLoadMore =
filteredSessions.length === 0 ||
sentinelVisible ||
isLoadingMoreRef.current;
if (shouldLoadMore) {
void loadMoreSessions();
}
}, [
filteredSessions.length,
loadMoreSessions,
sessionState.hasMore,
sentinelVisible,
]);
// Handle keyboard input
useInput((input, key) => {
// Ignore input if already exiting
if (isExiting) {
return;
}
// Escape or Ctrl+C to cancel
if (key.escape || (key.ctrl && input === 'c')) {
setIsExiting(true);
onCancel();
exit();
return;
}
if (key.return) {
const session = filteredSessions[selectedIndex];
if (session) {
setIsExiting(true);
onSelect(session.sessionId);
exit();
}
return;
}
if (key.upArrow || input === 'k') {
setSelectedIndex((prev) => Math.max(0, prev - 1));
return;
}
if (key.downArrow || input === 'j') {
if (filteredSessions.length === 0) {
return;
}
setSelectedIndex((prev) =>
Math.min(filteredSessions.length - 1, prev + 1),
);
return;
}
if (input === 'b' || input === 'B') {
if (currentBranch) {
setFilterByBranch((prev) => !prev);
}
return;
}
});
// Filtered sessions may have changed, ensure selectedIndex is valid
useEffect(() => {
if (
selectedIndex >= filteredSessions.length &&
filteredSessions.length > 0
) {
setSelectedIndex(filteredSessions.length - 1);
}
}, [filteredSessions.length, selectedIndex]);
// Calculate content width (terminal width minus border padding)
const contentWidth = terminalSize.width - 4;
const promptMaxWidth = contentWidth - 4; // Account for " " prefix
// Return empty while exiting to prevent visual glitches
if (isExiting) {
return <Box />;
}
return (
<Box
flexDirection="column"
width={terminalSize.width}
height={terminalSize.height - 1}
overflow="hidden"
>
{/* Main container with single border */}
<Box
flexDirection="column"
borderStyle="round"
borderColor={theme.border.default}
width={terminalSize.width}
height={terminalSize.height - 1}
overflow="hidden"
>
{/* Header row */}
<Box paddingX={1}>
<Text bold color={theme.text.primary}>
Resume Session
</Text>
</Box>
{/* Separator line */}
<Box>
<Text color={theme.border.default}>
{'─'.repeat(terminalSize.width - 2)}
</Text>
</Box>
{/* Session list with auto-scrolling */}
<Box flexDirection="column" flexGrow={1} paddingX={1} overflow="hidden">
{filteredSessions.length === 0 ? (
<Box paddingY={1} justifyContent="center">
<Text color={theme.text.secondary}>
{filterByBranch
? `No sessions found for branch "${currentBranch}"`
: 'No sessions found'}
</Text>
</Box>
) : (
visibleSessions.map((session, visibleIndex) => {
const actualIndex = scrollOffset + visibleIndex;
const isSelected = actualIndex === selectedIndex;
const isFirst = visibleIndex === 0;
const isLast = visibleIndex === visibleSessions.length - 1;
const timeAgo = formatRelativeTime(session.mtime);
const messageText =
session.messageCount === 1
? '1 message'
: `${session.messageCount} messages`;
// Show scroll indicator on first/last visible items
const showUpIndicator = isFirst && showScrollUp;
const showDownIndicator = isLast && showScrollDown;
// Determine the prefix: selector takes priority over scroll indicator
const prefix = isSelected
? ' '
: showUpIndicator
? '↑ '
: showDownIndicator
? '↓ '
: ' ';
return (
<Box
key={session.sessionId}
flexDirection="column"
marginBottom={isLast ? 0 : 1}
>
{/* First line: prefix (selector or scroll indicator) + prompt text */}
<Box>
<Text
color={
isSelected
? theme.text.accent
: showUpIndicator || showDownIndicator
? theme.text.secondary
: undefined
}
>
{prefix}
</Text>
<Text
bold={isSelected}
color={
isSelected ? theme.text.accent : theme.text.primary
}
>
{truncateText(
session.prompt || '(empty prompt)',
promptMaxWidth,
)}
</Text>
</Box>
{/* Second line: metadata (aligned with prompt text) */}
<Box>
<Text>{' '}</Text>
<Text color={theme.text.secondary}>
{timeAgo} · {messageText}
{session.gitBranch && ` · ${session.gitBranch}`}
</Text>
</Box>
</Box>
);
})
)}
</Box>
{/* Separator line */}
<Box>
<Text color={theme.border.default}>
{'─'.repeat(terminalSize.width - 2)}
</Text>
</Box>
{/* Footer with keyboard shortcuts */}
<Box paddingX={1}>
<Text color={theme.text.secondary}>
{currentBranch && (
<>
<Text
bold={filterByBranch}
color={filterByBranch ? theme.text.accent : undefined}
>
B
</Text>
{' to toggle branch · '}
</>
)}
{'↑↓ to navigate · Esc to cancel'}
</Text>
</Box>
</Box>
</Box>
);
}
/**
* Clears the terminal screen.
*/
function clearScreen(): void {
// Move cursor to home position and clear screen
process.stdout.write('\x1b[2J\x1b[H');
}
/**
* Shows an interactive session picker and returns the selected session ID.
* Returns undefined if the user cancels or no sessions are available.
*/
export async function showResumeSessionPicker(
cwd: string = process.cwd(),
): Promise<string | undefined> {
const sessionService = new SessionService(cwd);
const hasSession = await sessionService.loadLastSession();
if (!hasSession) {
console.log('No sessions found. Start a new session with `qwen`.');
return undefined;
}
const currentBranch = getGitBranch(cwd);
// Clear the screen before showing the picker for a clean fullscreen experience
clearScreen();
// Enable raw mode for keyboard input if not already enabled
const wasRaw = process.stdin.isRaw;
if (process.stdin.isTTY && !wasRaw) {
process.stdin.setRawMode(true);
}
return new Promise<string | undefined>((resolve) => {
let selectedId: string | undefined;
const { unmount, waitUntilExit } = render(
<SessionPicker
sessionService={sessionService}
currentBranch={currentBranch}
onSelect={(id) => {
selectedId = id;
}}
onCancel={() => {
selectedId = undefined;
}}
/>,
{
exitOnCtrlC: false,
},
);
waitUntilExit().then(() => {
unmount();
// Clear the screen after the picker closes for a clean fullscreen experience
clearScreen();
// Restore raw mode state only if we changed it and user cancelled
// (if user selected a session, main app will handle raw mode)
if (process.stdin.isTTY && !wasRaw && !selectedId) {
process.stdin.setRawMode(false);
}
resolve(selectedId);
});
});
}

View File

@@ -1,251 +0,0 @@
/**
* @license
* Copyright 2025 Qwen Code
* SPDX-License-Identifier: Apache-2.0
*/
import { Box, Text } from 'ink';
import type {
SessionListItem as SessionData,
SessionService,
} from '@qwen-code/qwen-code-core';
import { theme } from '../semantic-colors.js';
import { useSessionPicker } from '../hooks/useSessionPicker.js';
import { formatRelativeTime } from '../utils/formatters.js';
import {
formatMessageCount,
truncateText,
} from '../utils/sessionPickerUtils.js';
import { useTerminalSize } from '../hooks/useTerminalSize.js';
import { t } from '../../i18n/index.js';
export interface SessionPickerProps {
sessionService: SessionService | null;
onSelect: (sessionId: string) => void;
onCancel: () => void;
currentBranch?: string;
/**
* Scroll mode. When true, keep selection centered (fullscreen-style).
* Defaults to true so dialog + standalone behave identically.
*/
centerSelection?: boolean;
}
const PREFIX_CHARS = {
selected: ' ',
scrollUp: '↑ ',
scrollDown: '↓ ',
normal: ' ',
};
interface SessionListItemViewProps {
session: SessionData;
isSelected: boolean;
isFirst: boolean;
isLast: boolean;
showScrollUp: boolean;
showScrollDown: boolean;
maxPromptWidth: number;
prefixChars?: {
selected: string;
scrollUp: string;
scrollDown: string;
normal: string;
};
boldSelectedPrefix?: boolean;
}
function SessionListItemView({
session,
isSelected,
isFirst,
isLast,
showScrollUp,
showScrollDown,
maxPromptWidth,
prefixChars = PREFIX_CHARS,
boldSelectedPrefix = true,
}: SessionListItemViewProps): React.JSX.Element {
const timeAgo = formatRelativeTime(session.mtime);
const messageText = formatMessageCount(session.messageCount);
const showUpIndicator = isFirst && showScrollUp;
const showDownIndicator = isLast && showScrollDown;
const prefix = isSelected
? prefixChars.selected
: showUpIndicator
? prefixChars.scrollUp
: showDownIndicator
? prefixChars.scrollDown
: prefixChars.normal;
const promptText = session.prompt || '(empty prompt)';
const truncatedPrompt = truncateText(promptText, maxPromptWidth);
return (
<Box flexDirection="column" marginBottom={isLast ? 0 : 1}>
<Box>
<Text
color={
isSelected
? theme.text.accent
: showUpIndicator || showDownIndicator
? theme.text.secondary
: undefined
}
bold={isSelected && boldSelectedPrefix}
>
{prefix}
</Text>
<Text
color={isSelected ? theme.text.accent : theme.text.primary}
bold={isSelected}
>
{truncatedPrompt}
</Text>
</Box>
<Box paddingLeft={2}>
<Text color={theme.text.secondary}>
{timeAgo} · {messageText}
{session.gitBranch && ` · ${session.gitBranch}`}
</Text>
</Box>
</Box>
);
}
export function SessionPicker(props: SessionPickerProps) {
const {
sessionService,
onSelect,
onCancel,
currentBranch,
centerSelection = true,
} = props;
const { columns: width, rows: height } = useTerminalSize();
// Calculate box width (width + 6 for border padding)
const boxWidth = width + 6;
// Calculate visible items (same heuristic as before)
// Reserved space: header (1), footer (1), separators (2), borders (2)
const reservedLines = 6;
// Each item takes 2 lines (prompt + metadata) + 1 line margin between items
const itemHeight = 3;
const maxVisibleItems = Math.max(
1,
Math.floor((height - reservedLines) / itemHeight),
);
const picker = useSessionPicker({
sessionService,
currentBranch,
onSelect,
onCancel,
maxVisibleItems,
centerSelection,
isActive: true,
});
return (
<Box
flexDirection="column"
width={boxWidth}
height={height - 1}
overflow="hidden"
>
<Box
flexDirection="column"
borderStyle="round"
borderColor={theme.border.default}
width={boxWidth}
height={height - 1}
overflow="hidden"
>
{/* Header row */}
<Box paddingX={1}>
<Text bold color={theme.text.primary}>
{t('Resume Session')}
</Text>
{picker.filterByBranch && currentBranch && (
<Text color={theme.text.secondary}>
{' '}
{t('(branch: {{branch}})', { branch: currentBranch })}
</Text>
)}
</Box>
{/* Separator */}
<Box>
<Text color={theme.border.default}>{'─'.repeat(width - 2)}</Text>
</Box>
{/* Session list */}
<Box flexDirection="column" flexGrow={1} paddingX={1} overflow="hidden">
{!sessionService || picker.isLoading ? (
<Box paddingY={1} justifyContent="center">
<Text color={theme.text.secondary}>
{t('Loading sessions...')}
</Text>
</Box>
) : picker.filteredSessions.length === 0 ? (
<Box paddingY={1} justifyContent="center">
<Text color={theme.text.secondary}>
{picker.filterByBranch
? t('No sessions found for branch "{{branch}}"', {
branch: currentBranch ?? '',
})
: t('No sessions found')}
</Text>
</Box>
) : (
picker.visibleSessions.map((session, visibleIndex) => {
const actualIndex = picker.scrollOffset + visibleIndex;
return (
<SessionListItemView
key={session.sessionId}
session={session}
isSelected={actualIndex === picker.selectedIndex}
isFirst={visibleIndex === 0}
isLast={visibleIndex === picker.visibleSessions.length - 1}
showScrollUp={picker.showScrollUp}
showScrollDown={picker.showScrollDown}
maxPromptWidth={width}
prefixChars={PREFIX_CHARS}
boldSelectedPrefix={false}
/>
);
})
)}
</Box>
{/* Separator */}
<Box>
<Text color={theme.border.default}>{'─'.repeat(width - 2)}</Text>
</Box>
{/* Footer */}
<Box paddingX={1}>
<Box flexDirection="row">
{currentBranch && (
<Text color={theme.text.secondary}>
<Text
bold={picker.filterByBranch}
color={picker.filterByBranch ? theme.text.accent : undefined}
>
B
</Text>
{t(' to toggle branch')} ·
</Text>
)}
<Text color={theme.text.secondary}>
{t('↑↓ to navigate · Esc to cancel')}
</Text>
</Box>
</Box>
</Box>
</Box>
);
}

View File

@@ -1,624 +0,0 @@
/**
* @license
* Copyright 2025 Qwen Code
* SPDX-License-Identifier: Apache-2.0
*/
import { render } from 'ink-testing-library';
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import { KeypressProvider } from '../contexts/KeypressContext.js';
import { SessionPicker } from './SessionPicker.js';
import type {
SessionListItem,
ListSessionsResult,
} from '@qwen-code/qwen-code-core';
vi.mock('@qwen-code/qwen-code-core', async () => {
const actual = await vi.importActual('@qwen-code/qwen-code-core');
return {
...actual,
getGitBranch: vi.fn().mockReturnValue('main'),
};
});
// Mock terminal size
const mockTerminalSize = { columns: 80, rows: 24 };
beforeEach(() => {
Object.defineProperty(process.stdout, 'columns', {
value: mockTerminalSize.columns,
configurable: true,
});
Object.defineProperty(process.stdout, 'rows', {
value: mockTerminalSize.rows,
configurable: true,
});
});
// Helper to create mock sessions
function createMockSession(
overrides: Partial<SessionListItem> = {},
): SessionListItem {
return {
sessionId: 'test-session-id',
cwd: '/test/path',
startTime: '2025-01-01T00:00:00.000Z',
mtime: Date.now(),
prompt: 'Test prompt',
gitBranch: 'main',
filePath: '/test/path/sessions/test-session-id.jsonl',
messageCount: 5,
...overrides,
};
}
// Helper to create mock session service
function createMockSessionService(
sessions: SessionListItem[] = [],
hasMore = false,
) {
return {
listSessions: vi.fn().mockResolvedValue({
items: sessions,
hasMore,
nextCursor: hasMore ? Date.now() : undefined,
} as ListSessionsResult),
loadSession: vi.fn(),
loadLastSession: vi
.fn()
.mockResolvedValue(sessions.length > 0 ? {} : undefined),
};
}
describe('SessionPicker', () => {
const wait = (ms = 50) => new Promise((resolve) => setTimeout(resolve, ms));
afterEach(() => {
vi.clearAllMocks();
});
describe('Empty Sessions', () => {
it('should show sessions with 0 messages', async () => {
const sessions = [
createMockSession({
sessionId: 'empty-1',
messageCount: 0,
prompt: '',
}),
createMockSession({
sessionId: 'with-messages',
messageCount: 5,
prompt: 'Hello',
}),
createMockSession({
sessionId: 'empty-2',
messageCount: 0,
prompt: '(empty prompt)',
}),
];
const mockService = createMockSessionService(sessions);
const onSelect = vi.fn();
const onCancel = vi.fn();
const { lastFrame } = render(
<KeypressProvider kittyProtocolEnabled={false}>
<SessionPicker
sessionService={mockService as never}
onSelect={onSelect}
onCancel={onCancel}
/>
</KeypressProvider>,
);
await wait(100);
const output = lastFrame();
expect(output).toContain('Hello');
// Should show empty sessions too (rendered as "(empty prompt)" + "0 messages")
expect(output).toContain('0 messages');
});
it('should show sessions even when all sessions are empty', async () => {
const sessions = [
createMockSession({ sessionId: 'empty-1', messageCount: 0 }),
createMockSession({ sessionId: 'empty-2', messageCount: 0 }),
];
const mockService = createMockSessionService(sessions);
const onSelect = vi.fn();
const onCancel = vi.fn();
const { lastFrame } = render(
<KeypressProvider kittyProtocolEnabled={false}>
<SessionPicker
sessionService={mockService as never}
onSelect={onSelect}
onCancel={onCancel}
/>
</KeypressProvider>,
);
await wait(100);
const output = lastFrame();
expect(output).toContain('0 messages');
});
it('should show sessions with 1 or more messages', async () => {
const sessions = [
createMockSession({
sessionId: 'one-msg',
messageCount: 1,
prompt: 'Single message',
}),
createMockSession({
sessionId: 'many-msg',
messageCount: 10,
prompt: 'Many messages',
}),
];
const mockService = createMockSessionService(sessions);
const onSelect = vi.fn();
const onCancel = vi.fn();
const { lastFrame } = render(
<KeypressProvider kittyProtocolEnabled={false}>
<SessionPicker
sessionService={mockService as never}
onSelect={onSelect}
onCancel={onCancel}
/>
</KeypressProvider>,
);
await wait(100);
const output = lastFrame();
expect(output).toContain('Single message');
expect(output).toContain('Many messages');
expect(output).toContain('1 message');
expect(output).toContain('10 messages');
});
});
describe('Branch Filtering', () => {
it('should filter by branch when B is pressed', async () => {
const sessions = [
createMockSession({
sessionId: 's1',
gitBranch: 'main',
prompt: 'Main branch',
messageCount: 1,
}),
createMockSession({
sessionId: 's2',
gitBranch: 'feature',
prompt: 'Feature branch',
messageCount: 1,
}),
createMockSession({
sessionId: 's3',
gitBranch: 'main',
prompt: 'Also main',
messageCount: 1,
}),
];
const mockService = createMockSessionService(sessions);
const onSelect = vi.fn();
const onCancel = vi.fn();
const { lastFrame, stdin } = render(
<KeypressProvider kittyProtocolEnabled={false}>
<SessionPicker
sessionService={mockService as never}
onSelect={onSelect}
onCancel={onCancel}
currentBranch="main"
/>
</KeypressProvider>,
);
await wait(100);
// All sessions should be visible initially
let output = lastFrame();
expect(output).toContain('Main branch');
expect(output).toContain('Feature branch');
// Press B to filter by branch
stdin.write('B');
await wait(50);
output = lastFrame();
// Only main branch sessions should be visible
expect(output).toContain('Main branch');
expect(output).toContain('Also main');
expect(output).not.toContain('Feature branch');
});
it('should combine empty session filter with branch filter', async () => {
const sessions = [
createMockSession({
sessionId: 's1',
gitBranch: 'main',
messageCount: 0,
prompt: 'Empty main',
}),
createMockSession({
sessionId: 's2',
gitBranch: 'main',
messageCount: 5,
prompt: 'Valid main',
}),
createMockSession({
sessionId: 's3',
gitBranch: 'feature',
messageCount: 5,
prompt: 'Valid feature',
}),
];
const mockService = createMockSessionService(sessions);
const onSelect = vi.fn();
const onCancel = vi.fn();
const { lastFrame, stdin } = render(
<KeypressProvider kittyProtocolEnabled={false}>
<SessionPicker
sessionService={mockService as never}
onSelect={onSelect}
onCancel={onCancel}
currentBranch="main"
/>
</KeypressProvider>,
);
await wait(100);
// Press B to filter by branch
stdin.write('B');
await wait(50);
const output = lastFrame();
// Should only show sessions from main branch (including 0-message sessions)
expect(output).toContain('Valid main');
expect(output).toContain('Empty main');
expect(output).not.toContain('Valid feature');
});
});
describe('Keyboard Navigation', () => {
it('should navigate with arrow keys', async () => {
const sessions = [
createMockSession({
sessionId: 's1',
prompt: 'First session',
messageCount: 1,
}),
createMockSession({
sessionId: 's2',
prompt: 'Second session',
messageCount: 1,
}),
createMockSession({
sessionId: 's3',
prompt: 'Third session',
messageCount: 1,
}),
];
const mockService = createMockSessionService(sessions);
const onSelect = vi.fn();
const onCancel = vi.fn();
const { lastFrame, stdin } = render(
<KeypressProvider kittyProtocolEnabled={false}>
<SessionPicker
sessionService={mockService as never}
onSelect={onSelect}
onCancel={onCancel}
/>
</KeypressProvider>,
);
await wait(100);
// First session should be selected initially (indicated by >)
let output = lastFrame();
expect(output).toContain('First session');
// Navigate down
stdin.write('\u001B[B'); // Down arrow
await wait(50);
output = lastFrame();
// Selection indicator should move
expect(output).toBeDefined();
});
it('should navigate with vim keys (j/k)', async () => {
const sessions = [
createMockSession({
sessionId: 's1',
prompt: 'First',
messageCount: 1,
}),
createMockSession({
sessionId: 's2',
prompt: 'Second',
messageCount: 1,
}),
];
const mockService = createMockSessionService(sessions);
const onSelect = vi.fn();
const onCancel = vi.fn();
const { stdin, unmount } = render(
<KeypressProvider kittyProtocolEnabled={false}>
<SessionPicker
sessionService={mockService as never}
onSelect={onSelect}
onCancel={onCancel}
/>
</KeypressProvider>,
);
await wait(100);
// Navigate with j (down)
stdin.write('j');
await wait(50);
// Navigate with k (up)
stdin.write('k');
await wait(50);
unmount();
});
it('should select session on Enter', async () => {
const sessions = [
createMockSession({
sessionId: 'selected-session',
prompt: 'Select me',
messageCount: 1,
}),
];
const mockService = createMockSessionService(sessions);
const onSelect = vi.fn();
const onCancel = vi.fn();
const { stdin } = render(
<KeypressProvider kittyProtocolEnabled={false}>
<SessionPicker
sessionService={mockService as never}
onSelect={onSelect}
onCancel={onCancel}
/>
</KeypressProvider>,
);
await wait(100);
// Press Enter to select
stdin.write('\r');
await wait(50);
expect(onSelect).toHaveBeenCalledWith('selected-session');
});
it('should cancel on Escape', async () => {
const sessions = [
createMockSession({ sessionId: 's1', messageCount: 1 }),
];
const mockService = createMockSessionService(sessions);
const onSelect = vi.fn();
const onCancel = vi.fn();
const { stdin } = render(
<KeypressProvider kittyProtocolEnabled={false}>
<SessionPicker
sessionService={mockService as never}
onSelect={onSelect}
onCancel={onCancel}
/>
</KeypressProvider>,
);
await wait(100);
// Press Escape to cancel
stdin.write('\u001B');
await wait(50);
expect(onCancel).toHaveBeenCalled();
expect(onSelect).not.toHaveBeenCalled();
});
});
describe('Display', () => {
it('should show session metadata', async () => {
const sessions = [
createMockSession({
sessionId: 's1',
prompt: 'Test prompt text',
messageCount: 5,
gitBranch: 'feature-branch',
}),
];
const mockService = createMockSessionService(sessions);
const onSelect = vi.fn();
const onCancel = vi.fn();
const { lastFrame } = render(
<KeypressProvider kittyProtocolEnabled={false}>
<SessionPicker
sessionService={mockService as never}
onSelect={onSelect}
onCancel={onCancel}
/>
</KeypressProvider>,
);
await wait(100);
const output = lastFrame();
expect(output).toContain('Test prompt text');
expect(output).toContain('5 messages');
expect(output).toContain('feature-branch');
});
it('should show header and footer', async () => {
const sessions = [createMockSession({ messageCount: 1 })];
const mockService = createMockSessionService(sessions);
const onSelect = vi.fn();
const onCancel = vi.fn();
const { lastFrame } = render(
<KeypressProvider kittyProtocolEnabled={false}>
<SessionPicker
sessionService={mockService as never}
onSelect={onSelect}
onCancel={onCancel}
/>
</KeypressProvider>,
);
await wait(100);
const output = lastFrame();
expect(output).toContain('Resume Session');
expect(output).toContain('↑↓ to navigate');
expect(output).toContain('Esc to cancel');
});
it('should show branch toggle hint when currentBranch is provided', async () => {
const sessions = [createMockSession({ messageCount: 1 })];
const mockService = createMockSessionService(sessions);
const onSelect = vi.fn();
const onCancel = vi.fn();
const { lastFrame } = render(
<KeypressProvider kittyProtocolEnabled={false}>
<SessionPicker
sessionService={mockService as never}
onSelect={onSelect}
onCancel={onCancel}
currentBranch="main"
/>
</KeypressProvider>,
);
await wait(100);
const output = lastFrame();
expect(output).toContain('B');
expect(output).toContain('toggle branch');
});
it('should truncate long prompts', async () => {
const longPrompt = 'A'.repeat(300);
const sessions = [
createMockSession({ prompt: longPrompt, messageCount: 1 }),
];
const mockService = createMockSessionService(sessions);
const onSelect = vi.fn();
const onCancel = vi.fn();
const { lastFrame } = render(
<KeypressProvider kittyProtocolEnabled={false}>
<SessionPicker
sessionService={mockService as never}
onSelect={onSelect}
onCancel={onCancel}
/>
</KeypressProvider>,
);
await wait(100);
const output = lastFrame();
// Should contain ellipsis for truncated text
expect(output).toContain('...');
// Should NOT contain the full untruncated prompt (300 A's in a row)
expect(output).not.toContain(longPrompt);
});
it('should show "(empty prompt)" for sessions without prompt text', async () => {
const sessions = [createMockSession({ prompt: '', messageCount: 1 })];
const mockService = createMockSessionService(sessions);
const onSelect = vi.fn();
const onCancel = vi.fn();
const { lastFrame } = render(
<KeypressProvider kittyProtocolEnabled={false}>
<SessionPicker
sessionService={mockService as never}
onSelect={onSelect}
onCancel={onCancel}
/>
</KeypressProvider>,
);
await wait(100);
const output = lastFrame();
expect(output).toContain('(empty prompt)');
});
});
describe('Pagination', () => {
it('should load more sessions when scrolling to bottom', async () => {
const firstPage = Array.from({ length: 5 }, (_, i) =>
createMockSession({
sessionId: `session-${i}`,
prompt: `Session ${i}`,
messageCount: 1,
mtime: Date.now() - i * 1000,
}),
);
const secondPage = Array.from({ length: 3 }, (_, i) =>
createMockSession({
sessionId: `session-${i + 5}`,
prompt: `Session ${i + 5}`,
messageCount: 1,
mtime: Date.now() - (i + 5) * 1000,
}),
);
const mockService = {
listSessions: vi
.fn()
.mockResolvedValueOnce({
items: firstPage,
hasMore: true,
nextCursor: Date.now() - 5000,
})
.mockResolvedValueOnce({
items: secondPage,
hasMore: false,
nextCursor: undefined,
}),
loadSession: vi.fn(),
loadLastSession: vi.fn().mockResolvedValue({}),
};
const onSelect = vi.fn();
const onCancel = vi.fn();
const { unmount } = render(
<KeypressProvider kittyProtocolEnabled={false}>
<SessionPicker
sessionService={mockService as never}
onSelect={onSelect}
onCancel={onCancel}
/>
</KeypressProvider>,
);
await wait(200);
// First page should be loaded
expect(mockService.listSessions).toHaveBeenCalled();
unmount();
});
});
});

View File

@@ -1,122 +0,0 @@
/**
* @license
* Copyright 2025 Qwen Code
* SPDX-License-Identifier: Apache-2.0
*/
import { useState } from 'react';
import { render, Box, useApp } from 'ink';
import { getGitBranch, SessionService } from '@qwen-code/qwen-code-core';
import { KeypressProvider } from '../contexts/KeypressContext.js';
import { SessionPicker } from './SessionPicker.js';
interface StandalonePickerScreenProps {
sessionService: SessionService;
onSelect: (sessionId: string) => void;
onCancel: () => void;
currentBranch?: string;
}
function StandalonePickerScreen({
sessionService,
onSelect,
onCancel,
currentBranch,
}: StandalonePickerScreenProps): React.JSX.Element {
const { exit } = useApp();
const [isExiting, setIsExiting] = useState(false);
const handleExit = () => {
setIsExiting(true);
exit();
};
// Return empty while exiting to prevent visual glitches
if (isExiting) {
return <Box />;
}
return (
<SessionPicker
sessionService={sessionService}
onSelect={(id) => {
onSelect(id);
handleExit();
}}
onCancel={() => {
onCancel();
handleExit();
}}
currentBranch={currentBranch}
centerSelection={true}
/>
);
}
/**
* Clears the terminal screen.
*/
function clearScreen(): void {
// Move cursor to home position and clear screen
process.stdout.write('\x1b[2J\x1b[H');
}
/**
* Shows an interactive session picker and returns the selected session ID.
* Returns undefined if the user cancels or no sessions are available.
*/
export async function showResumeSessionPicker(
cwd: string = process.cwd(),
): Promise<string | undefined> {
const sessionService = new SessionService(cwd);
const hasSession = await sessionService.loadLastSession();
if (!hasSession) {
console.log('No sessions found. Start a new session with `qwen`.');
return undefined;
}
// Clear the screen before showing the picker for a clean fullscreen experience
clearScreen();
// Enable raw mode for keyboard input if not already enabled
const wasRaw = process.stdin.isRaw;
if (process.stdin.isTTY && !wasRaw) {
process.stdin.setRawMode(true);
}
return new Promise<string | undefined>((resolve) => {
let selectedId: string | undefined;
const { unmount, waitUntilExit } = render(
<KeypressProvider kittyProtocolEnabled={false}>
<StandalonePickerScreen
sessionService={sessionService}
onSelect={(id) => {
selectedId = id;
}}
onCancel={() => {
selectedId = undefined;
}}
currentBranch={getGitBranch(cwd)}
/>
</KeypressProvider>,
{
exitOnCtrlC: false,
},
);
waitUntilExit().then(() => {
unmount();
// Clear the screen after the picker closes for a clean fullscreen experience
clearScreen();
// Restore raw mode state only if we changed it and user cancelled
// (if user selected a session, main app will handle raw mode)
if (process.stdin.isTTY && !wasRaw && !selectedId) {
process.stdin.setRawMode(false);
}
resolve(selectedId);
});
});
}

View File

@@ -64,10 +64,6 @@ export interface UIActions {
// Subagent dialogs
closeSubagentCreateDialog: () => void;
closeAgentsManagerDialog: () => void;
// Resume session dialog
openResumeDialog: () => void;
closeResumeDialog: () => void;
handleResume: (sessionId: string) => void;
}
export const UIActionsContext = createContext<UIActions | null>(null);

View File

@@ -60,7 +60,6 @@ export interface UIState {
isModelDialogOpen: boolean;
isPermissionsDialogOpen: boolean;
isApprovalModeDialogOpen: boolean;
isResumeDialogOpen: boolean;
slashCommands: readonly SlashCommand[];
pendingSlashCommandHistoryItems: HistoryItemWithoutId[];
commandContext: CommandContext;

View File

@@ -56,7 +56,6 @@ const SLASH_COMMANDS_SKIP_RECORDING = new Set([
'clear',
'reset',
'new',
'resume',
]);
interface SlashCommandProcessorActions {
@@ -67,7 +66,6 @@ interface SlashCommandProcessorActions {
openModelDialog: () => void;
openPermissionsDialog: () => void;
openApprovalModeDialog: () => void;
openResumeDialog: () => void;
quit: (messages: HistoryItem[]) => void;
setDebugMessage: (message: string) => void;
dispatchExtensionStateUpdate: (action: ExtensionUpdateAction) => void;
@@ -419,9 +417,6 @@ export const useSlashCommandProcessor = (
case 'approval-mode':
actions.openApprovalModeDialog();
return { type: 'handled' };
case 'resume':
actions.openResumeDialog();
return { type: 'handled' };
case 'help':
return { type: 'handled' };
default: {

View File

@@ -1,190 +0,0 @@
/**
* @license
* Copyright 2025 Qwen Code
* SPDX-License-Identifier: Apache-2.0
*/
import { act, renderHook } from '@testing-library/react';
import { describe, it, expect, vi } from 'vitest';
import { useResumeCommand } from './useResumeCommand.js';
const resumeMocks = vi.hoisted(() => {
let resolveLoadSession:
| ((value: { conversation: unknown } | undefined) => void)
| undefined;
let pendingLoadSession:
| Promise<{ conversation: unknown } | undefined>
| undefined;
return {
createPendingLoadSession() {
pendingLoadSession = new Promise((resolve) => {
resolveLoadSession = resolve;
});
return pendingLoadSession;
},
resolvePendingLoadSession(value: { conversation: unknown } | undefined) {
resolveLoadSession?.(value);
},
getPendingLoadSession() {
return pendingLoadSession;
},
reset() {
resolveLoadSession = undefined;
pendingLoadSession = undefined;
},
};
});
vi.mock('../utils/resumeHistoryUtils.js', () => ({
buildResumedHistoryItems: vi.fn(() => [{ id: 1, type: 'user', text: 'hi' }]),
}));
vi.mock('@qwen-code/qwen-code-core', () => {
class SessionService {
constructor(_cwd: string) {}
async loadSession(_sessionId: string) {
return (
resumeMocks.getPendingLoadSession() ??
Promise.resolve({
conversation: [{ role: 'user', parts: [{ text: 'hello' }] }],
})
);
}
}
return {
SessionService,
};
});
describe('useResumeCommand', () => {
it('should initialize with dialog closed', () => {
const { result } = renderHook(() => useResumeCommand());
expect(result.current.isResumeDialogOpen).toBe(false);
});
it('should open the dialog when openResumeDialog is called', () => {
const { result } = renderHook(() => useResumeCommand());
act(() => {
result.current.openResumeDialog();
});
expect(result.current.isResumeDialogOpen).toBe(true);
});
it('should close the dialog when closeResumeDialog is called', () => {
const { result } = renderHook(() => useResumeCommand());
// Open the dialog first
act(() => {
result.current.openResumeDialog();
});
expect(result.current.isResumeDialogOpen).toBe(true);
// Close the dialog
act(() => {
result.current.closeResumeDialog();
});
expect(result.current.isResumeDialogOpen).toBe(false);
});
it('should maintain stable function references across renders', () => {
const { result, rerender } = renderHook(() => useResumeCommand());
const initialOpenFn = result.current.openResumeDialog;
const initialCloseFn = result.current.closeResumeDialog;
const initialHandleResume = result.current.handleResume;
rerender();
expect(result.current.openResumeDialog).toBe(initialOpenFn);
expect(result.current.closeResumeDialog).toBe(initialCloseFn);
expect(result.current.handleResume).toBe(initialHandleResume);
});
it('handleResume no-ops when config is null', async () => {
const historyManager = { clearItems: vi.fn(), loadHistory: vi.fn() };
const startNewSession = vi.fn();
const { result } = renderHook(() =>
useResumeCommand({
config: null,
historyManager,
startNewSession,
}),
);
await act(async () => {
await result.current.handleResume('session-1');
});
expect(startNewSession).not.toHaveBeenCalled();
expect(historyManager.clearItems).not.toHaveBeenCalled();
expect(historyManager.loadHistory).not.toHaveBeenCalled();
});
it('handleResume closes the dialog immediately and restores session state', async () => {
resumeMocks.reset();
resumeMocks.createPendingLoadSession();
const historyManager = { clearItems: vi.fn(), loadHistory: vi.fn() };
const startNewSession = vi.fn();
const geminiClient = {
initialize: vi.fn(),
};
const config = {
getTargetDir: () => '/tmp',
getGeminiClient: () => geminiClient,
startNewSession: vi.fn(),
} as unknown as import('@qwen-code/qwen-code-core').Config;
const { result } = renderHook(() =>
useResumeCommand({
config,
historyManager,
startNewSession,
}),
);
// Open first so we can verify the dialog closes immediately.
act(() => {
result.current.openResumeDialog();
});
expect(result.current.isResumeDialogOpen).toBe(true);
let resumePromise: Promise<void> | undefined;
act(() => {
// Start resume but do not await it yet — we want to assert the dialog
// closes immediately before the async session load completes.
resumePromise = result.current.handleResume('session-2') as unknown as
| Promise<void>
| undefined;
});
expect(result.current.isResumeDialogOpen).toBe(false);
// Now finish the async load and let the handler complete.
resumeMocks.resolvePendingLoadSession({
conversation: [{ role: 'user', parts: [{ text: 'hello' }] }],
});
await act(async () => {
await resumePromise;
});
expect(config.startNewSession).toHaveBeenCalledWith(
'session-2',
expect.objectContaining({
conversation: expect.anything(),
}),
);
expect(startNewSession).toHaveBeenCalledWith('session-2');
expect(geminiClient.initialize).toHaveBeenCalledTimes(1);
expect(historyManager.clearItems).toHaveBeenCalledTimes(1);
expect(historyManager.loadHistory).toHaveBeenCalledTimes(1);
});
});

View File

@@ -1,82 +0,0 @@
/**
* @license
* Copyright 2025 Qwen Code
* SPDX-License-Identifier: Apache-2.0
*/
import { useState, useCallback } from 'react';
import { SessionService, type Config } from '@qwen-code/qwen-code-core';
import { buildResumedHistoryItems } from '../utils/resumeHistoryUtils.js';
import type { UseHistoryManagerReturn } from './useHistoryManager.js';
export interface UseResumeCommandOptions {
config: Config | null;
historyManager: Pick<UseHistoryManagerReturn, 'clearItems' | 'loadHistory'>;
startNewSession: (sessionId: string) => void;
remount?: () => void;
}
export interface UseResumeCommandResult {
isResumeDialogOpen: boolean;
openResumeDialog: () => void;
closeResumeDialog: () => void;
handleResume: (sessionId: string) => void;
}
export function useResumeCommand(
options?: UseResumeCommandOptions,
): UseResumeCommandResult {
const [isResumeDialogOpen, setIsResumeDialogOpen] = useState(false);
const openResumeDialog = useCallback(() => {
setIsResumeDialogOpen(true);
}, []);
const closeResumeDialog = useCallback(() => {
setIsResumeDialogOpen(false);
}, []);
const { config, historyManager, startNewSession, remount } = options ?? {};
const handleResume = useCallback(
async (sessionId: string) => {
if (!config || !historyManager || !startNewSession) {
return;
}
// Close dialog immediately to prevent input capture during async operations.
closeResumeDialog();
const cwd = config.getTargetDir();
const sessionService = new SessionService(cwd);
const sessionData = await sessionService.loadSession(sessionId);
if (!sessionData) {
return;
}
// Start new session in UI context.
startNewSession(sessionId);
// Reset UI history.
const uiHistoryItems = buildResumedHistoryItems(sessionData, config);
historyManager.clearItems();
historyManager.loadHistory(uiHistoryItems);
// Update session history core.
config.startNewSession(sessionId, sessionData);
await config.getGeminiClient()?.initialize?.();
// Refresh terminal UI.
remount?.();
},
[closeResumeDialog, config, historyManager, startNewSession, remount],
);
return {
isResumeDialogOpen,
openResumeDialog,
closeResumeDialog,
handleResume,
};
}

View File

@@ -1,279 +0,0 @@
/**
* @license
* Copyright 2025 Qwen Code
* SPDX-License-Identifier: Apache-2.0
*/
/**
* Unified session picker hook for both dialog and standalone modes.
*
* IMPORTANT:
* - Uses KeypressContext (`useKeypress`) so it behaves correctly inside the main app.
* - Standalone mode should wrap the picker in `<KeypressProvider>` when rendered
* outside the main app.
*/
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import type {
ListSessionsResult,
SessionListItem,
SessionService,
} from '@qwen-code/qwen-code-core';
import {
filterSessions,
SESSION_PAGE_SIZE,
type SessionState,
} from '../utils/sessionPickerUtils.js';
import { useKeypress } from './useKeypress.js';
export interface UseSessionPickerOptions {
sessionService: SessionService | null;
currentBranch?: string;
onSelect: (sessionId: string) => void;
onCancel: () => void;
maxVisibleItems: number;
/**
* If true, computes centered scroll offset (keeps selection near middle).
* If false, uses follow mode (scrolls when selection reaches edge).
*/
centerSelection?: boolean;
/**
* Enable/disable input handling.
*/
isActive?: boolean;
}
export interface UseSessionPickerResult {
selectedIndex: number;
sessionState: SessionState;
filteredSessions: SessionListItem[];
filterByBranch: boolean;
isLoading: boolean;
scrollOffset: number;
visibleSessions: SessionListItem[];
showScrollUp: boolean;
showScrollDown: boolean;
loadMoreSessions: () => Promise<void>;
}
export function useSessionPicker({
sessionService,
currentBranch,
onSelect,
onCancel,
maxVisibleItems,
centerSelection = false,
isActive = true,
}: UseSessionPickerOptions): UseSessionPickerResult {
const [selectedIndex, setSelectedIndex] = useState(0);
const [sessionState, setSessionState] = useState<SessionState>({
sessions: [],
hasMore: true,
nextCursor: undefined,
});
const [filterByBranch, setFilterByBranch] = useState(false);
const [isLoading, setIsLoading] = useState(true);
// For follow mode (non-centered)
const [followScrollOffset, setFollowScrollOffset] = useState(0);
const isLoadingMoreRef = useRef(false);
const filteredSessions = useMemo(
() => filterSessions(sessionState.sessions, filterByBranch, currentBranch),
[sessionState.sessions, filterByBranch, currentBranch],
);
const scrollOffset = useMemo(() => {
if (centerSelection) {
if (filteredSessions.length <= maxVisibleItems) {
return 0;
}
const halfVisible = Math.floor(maxVisibleItems / 2);
let offset = selectedIndex - halfVisible;
offset = Math.max(0, offset);
offset = Math.min(filteredSessions.length - maxVisibleItems, offset);
return offset;
}
return followScrollOffset;
}, [
centerSelection,
filteredSessions.length,
followScrollOffset,
maxVisibleItems,
selectedIndex,
]);
const visibleSessions = useMemo(
() => filteredSessions.slice(scrollOffset, scrollOffset + maxVisibleItems),
[filteredSessions, maxVisibleItems, scrollOffset],
);
const showScrollUp = scrollOffset > 0;
const showScrollDown =
scrollOffset + maxVisibleItems < filteredSessions.length;
// Initial load
useEffect(() => {
if (!sessionService) {
return;
}
const loadInitialSessions = async () => {
try {
const result: ListSessionsResult = await sessionService.listSessions({
size: SESSION_PAGE_SIZE,
});
setSessionState({
sessions: result.items,
hasMore: result.hasMore,
nextCursor: result.nextCursor,
});
} finally {
setIsLoading(false);
}
};
void loadInitialSessions();
}, [sessionService]);
const loadMoreSessions = useCallback(async () => {
if (!sessionService || !sessionState.hasMore || isLoadingMoreRef.current) {
return;
}
isLoadingMoreRef.current = true;
try {
const result: ListSessionsResult = await sessionService.listSessions({
size: SESSION_PAGE_SIZE,
cursor: sessionState.nextCursor,
});
setSessionState((prev) => ({
sessions: [...prev.sessions, ...result.items],
hasMore: result.hasMore && result.nextCursor !== undefined,
nextCursor: result.nextCursor,
}));
} finally {
isLoadingMoreRef.current = false;
}
}, [sessionService, sessionState.hasMore, sessionState.nextCursor]);
// Reset selection when filter changes
useEffect(() => {
setSelectedIndex(0);
setFollowScrollOffset(0);
}, [filterByBranch]);
// Ensure selectedIndex is valid when filtered sessions change
useEffect(() => {
if (
selectedIndex >= filteredSessions.length &&
filteredSessions.length > 0
) {
setSelectedIndex(filteredSessions.length - 1);
}
}, [filteredSessions.length, selectedIndex]);
// Auto-load more when centered mode hits the sentinel or list is empty.
useEffect(() => {
if (
isLoading ||
!sessionState.hasMore ||
isLoadingMoreRef.current ||
!centerSelection
) {
return;
}
const sentinelVisible =
scrollOffset + maxVisibleItems >= filteredSessions.length;
const shouldLoadMore = filteredSessions.length === 0 || sentinelVisible;
if (shouldLoadMore) {
void loadMoreSessions();
}
}, [
centerSelection,
filteredSessions.length,
isLoading,
loadMoreSessions,
maxVisibleItems,
scrollOffset,
sessionState.hasMore,
]);
// Key handling (KeypressContext)
useKeypress(
(key) => {
const { name, sequence, ctrl } = key;
if (name === 'escape' || (ctrl && name === 'c')) {
onCancel();
return;
}
if (name === 'return') {
const session = filteredSessions[selectedIndex];
if (session) {
onSelect(session.sessionId);
}
return;
}
if (name === 'up' || name === 'k') {
setSelectedIndex((prev) => {
const newIndex = Math.max(0, prev - 1);
if (!centerSelection && newIndex < followScrollOffset) {
setFollowScrollOffset(newIndex);
}
return newIndex;
});
return;
}
if (name === 'down' || name === 'j') {
if (filteredSessions.length === 0) {
return;
}
setSelectedIndex((prev) => {
const newIndex = Math.min(filteredSessions.length - 1, prev + 1);
if (
!centerSelection &&
newIndex >= followScrollOffset + maxVisibleItems
) {
setFollowScrollOffset(newIndex - maxVisibleItems + 1);
}
// Follow mode: load more when near the end.
if (!centerSelection && newIndex >= filteredSessions.length - 3) {
void loadMoreSessions();
}
return newIndex;
});
return;
}
if (sequence === 'b' || sequence === 'B') {
if (currentBranch) {
setFilterByBranch((prev) => !prev);
}
}
},
{ isActive },
);
return {
selectedIndex,
sessionState,
filteredSessions,
filterByBranch,
isLoading,
scrollOffset,
visibleSessions,
showScrollUp,
showScrollDown,
loadMoreSessions,
};
}

View File

@@ -1,45 +0,0 @@
/**
* @license
* Copyright 2025 Qwen Code
* SPDX-License-Identifier: Apache-2.0
*/
import { describe, it, expect } from 'vitest';
import { truncateText } from './sessionPickerUtils.js';
describe('sessionPickerUtils', () => {
describe('truncateText', () => {
it('returns the original text when it fits and has no newline', () => {
expect(truncateText('hello', 10)).toBe('hello');
});
it('truncates long text with ellipsis', () => {
expect(truncateText('hello world', 5)).toBe('he...');
});
it('truncates without ellipsis when maxWidth <= 3', () => {
expect(truncateText('hello', 3)).toBe('hel');
expect(truncateText('hello', 2)).toBe('he');
});
it('breaks at newline and returns only the first line', () => {
expect(truncateText('hello\nworld', 20)).toBe('hello');
expect(truncateText('hello\r\nworld', 20)).toBe('hello');
});
it('breaks at newline and still truncates the first line when needed', () => {
expect(truncateText('hello\nworld', 2)).toBe('he');
expect(truncateText('hello\nworld', 3)).toBe('hel');
expect(truncateText('hello\nworld', 4)).toBe('h...');
});
it('does not add ellipsis when the string ends at a newline', () => {
expect(truncateText('hello\n', 20)).toBe('hello');
expect(truncateText('hello\r\n', 20)).toBe('hello');
});
it('returns only the first line even if there are multiple line breaks', () => {
expect(truncateText('hello\n\nworld', 20)).toBe('hello');
});
});
});

View File

@@ -1,59 +0,0 @@
/**
* @license
* Copyright 2025 Qwen Code
* SPDX-License-Identifier: Apache-2.0
*/
import type { SessionListItem } from '@qwen-code/qwen-code-core';
/**
* State for managing loaded sessions in the session picker.
*/
export interface SessionState {
sessions: SessionListItem[];
hasMore: boolean;
nextCursor?: number;
}
/**
* Page size for loading sessions.
*/
export const SESSION_PAGE_SIZE = 20;
/**
* Truncates text to fit within a given width, adding ellipsis if needed.
*/
export function truncateText(text: string, maxWidth: number): string {
const firstLine = text.split(/\r?\n/, 1)[0];
if (firstLine.length <= maxWidth) {
return firstLine;
}
if (maxWidth <= 3) {
return firstLine.slice(0, maxWidth);
}
return firstLine.slice(0, maxWidth - 3) + '...';
}
/**
* Filters sessions optionally by branch.
*/
export function filterSessions(
sessions: SessionListItem[],
filterByBranch: boolean,
currentBranch?: string,
): SessionListItem[] {
return sessions.filter((session) => {
// Apply branch filter if enabled
if (filterByBranch && currentBranch) {
return session.gitBranch === currentBranch;
}
return true;
});
}
/**
* Formats message count for display with proper pluralization.
*/
export function formatMessageCount(count: number): string {
return count === 1 ? '1 message' : `${count} messages`;
}

View File

@@ -76,105 +76,6 @@ describe('getGitHubRepoInfo', async () => {
);
expect(getGitHubRepoInfo()).toEqual({ owner: 'owner', repo: 'repo' });
});
// Tests for credential formats
it('returns the owner and repo for URL with classic PAT token (ghp_)', async () => {
vi.mocked(child_process.execSync).mockReturnValueOnce(
'https://ghp_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx@github.com/owner/repo.git',
);
expect(getGitHubRepoInfo()).toEqual({ owner: 'owner', repo: 'repo' });
});
it('returns the owner and repo for URL with fine-grained PAT token (github_pat_)', async () => {
vi.mocked(child_process.execSync).mockReturnValueOnce(
'https://github_pat_xxxxxxxxxxxxxxxxxxxxxx_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx@github.com/owner/repo.git',
);
expect(getGitHubRepoInfo()).toEqual({ owner: 'owner', repo: 'repo' });
});
it('returns the owner and repo for URL with username:password format', async () => {
vi.mocked(child_process.execSync).mockReturnValueOnce(
'https://username:password@github.com/owner/repo.git',
);
expect(getGitHubRepoInfo()).toEqual({ owner: 'owner', repo: 'repo' });
});
it('returns the owner and repo for URL with OAuth token (oauth2:token)', async () => {
vi.mocked(child_process.execSync).mockReturnValueOnce(
'https://oauth2:gho_xxxxxxxxxxxx@github.com/owner/repo.git',
);
expect(getGitHubRepoInfo()).toEqual({ owner: 'owner', repo: 'repo' });
});
it('returns the owner and repo for URL with GitHub Actions token (x-access-token)', async () => {
vi.mocked(child_process.execSync).mockReturnValueOnce(
'https://x-access-token:ghs_xxxxxxxxxxxx@github.com/owner/repo.git',
);
expect(getGitHubRepoInfo()).toEqual({ owner: 'owner', repo: 'repo' });
});
// Tests for case insensitivity
it('returns the owner and repo for URL with uppercase GITHUB.COM', async () => {
vi.mocked(child_process.execSync).mockReturnValueOnce(
'https://GITHUB.COM/owner/repo.git',
);
expect(getGitHubRepoInfo()).toEqual({ owner: 'owner', repo: 'repo' });
});
it('returns the owner and repo for URL with mixed case GitHub.Com', async () => {
vi.mocked(child_process.execSync).mockReturnValueOnce(
'https://GitHub.Com/owner/repo.git',
);
expect(getGitHubRepoInfo()).toEqual({ owner: 'owner', repo: 'repo' });
});
// Tests for SSH format
it('returns the owner and repo for SSH URL', async () => {
vi.mocked(child_process.execSync).mockReturnValueOnce(
'git@github.com:owner/repo.git',
);
expect(getGitHubRepoInfo()).toEqual({ owner: 'owner', repo: 'repo' });
});
it('throws for non-GitHub SSH URL', async () => {
vi.mocked(child_process.execSync).mockReturnValueOnce(
'git@gitlab.com:owner/repo.git',
);
expect(() => {
getGitHubRepoInfo();
}).toThrowError(/Owner & repo could not be extracted from remote URL/);
});
// Tests for edge cases
it('returns the owner and repo for URL without .git suffix', async () => {
vi.mocked(child_process.execSync).mockReturnValueOnce(
'https://github.com/owner/repo',
);
expect(getGitHubRepoInfo()).toEqual({ owner: 'owner', repo: 'repo' });
});
it('throws for non-GitHub HTTPS URL', async () => {
vi.mocked(child_process.execSync).mockReturnValueOnce(
'https://gitlab.com/owner/repo.git',
);
expect(() => {
getGitHubRepoInfo();
}).toThrowError(/Owner & repo could not be extracted from remote URL/);
});
it('handles repo names containing .git substring', async () => {
vi.mocked(child_process.execSync).mockReturnValueOnce(
'https://github.com/owner/my.git.repo.git',
);
expect(getGitHubRepoInfo()).toEqual({
owner: 'owner',
repo: 'my.git.repo',
});
});
});
describe('getGitRepoRoot', async () => {

View File

@@ -103,38 +103,17 @@ export function getGitHubRepoInfo(): { owner: string; repo: string } {
encoding: 'utf-8',
}).trim();
// Handle SCP-style SSH URLs (git@github.com:owner/repo.git)
let urlToParse = remoteUrl;
if (remoteUrl.startsWith('git@github.com:')) {
urlToParse = remoteUrl.replace('git@github.com:', '');
} else if (remoteUrl.startsWith('git@')) {
// SSH URL for a different provider (GitLab, Bitbucket, etc.)
// Matches either https://github.com/owner/repo.git or git@github.com:owner/repo.git
const match = remoteUrl.match(
/(?:https?:\/\/|git@)github\.com(?::|\/)([^/]+)\/([^/]+?)(?:\.git)?$/,
);
// If the regex fails match, throw an error.
if (!match || !match[1] || !match[2]) {
throw new Error(
`Owner & repo could not be extracted from remote URL: ${remoteUrl}`,
);
}
let parsedUrl: URL;
try {
parsedUrl = new URL(urlToParse, 'https://github.com');
} catch {
throw new Error(
`Owner & repo could not be extracted from remote URL: ${remoteUrl}`,
);
}
if (parsedUrl.host !== 'github.com') {
throw new Error(
`Owner & repo could not be extracted from remote URL: ${remoteUrl}`,
);
}
const parts = parsedUrl.pathname.split('/').filter((part) => part !== '');
if (parts.length !== 2 || !parts[0] || !parts[1]) {
throw new Error(
`Owner & repo could not be extracted from remote URL: ${remoteUrl}`,
);
}
return { owner: parts[0], repo: parts[1].replace(/\.git$/, '') };
return { owner: match[1], repo: match[2] };
}

View File

@@ -115,7 +115,7 @@ describe('relaunchAppInChildProcess', () => {
vi.clearAllMocks();
process.env = { ...originalEnv };
delete process.env['QWEN_CODE_NO_RELAUNCH'];
delete process.env['GEMINI_CLI_NO_RELAUNCH'];
process.execArgv = [...originalExecArgv];
process.argv = [...originalArgv];
@@ -145,9 +145,9 @@ describe('relaunchAppInChildProcess', () => {
stdinResumeSpy.mockRestore();
});
describe('when QWEN_CODE_NO_RELAUNCH is set', () => {
describe('when GEMINI_CLI_NO_RELAUNCH is set', () => {
it('should return early without spawning a child process', async () => {
process.env['QWEN_CODE_NO_RELAUNCH'] = 'true';
process.env['GEMINI_CLI_NO_RELAUNCH'] = 'true';
await relaunchAppInChildProcess(['--test'], ['--verbose']);
@@ -156,9 +156,9 @@ describe('relaunchAppInChildProcess', () => {
});
});
describe('when QWEN_CODE_NO_RELAUNCH is not set', () => {
describe('when GEMINI_CLI_NO_RELAUNCH is not set', () => {
beforeEach(() => {
delete process.env['QWEN_CODE_NO_RELAUNCH'];
delete process.env['GEMINI_CLI_NO_RELAUNCH'];
});
it('should construct correct node arguments from execArgv, additionalNodeArgs, script, additionalScriptArgs, and argv', () => {

View File

@@ -27,7 +27,7 @@ export async function relaunchAppInChildProcess(
additionalNodeArgs: string[],
additionalScriptArgs: string[],
) {
if (process.env['QWEN_CODE_NO_RELAUNCH']) {
if (process.env['GEMINI_CLI_NO_RELAUNCH']) {
return;
}
@@ -44,7 +44,7 @@ export async function relaunchAppInChildProcess(
...additionalScriptArgs,
...scriptArgs,
];
const newEnv = { ...process.env, QWEN_CODE_NO_RELAUNCH: 'true' };
const newEnv = { ...process.env, GEMINI_CLI_NO_RELAUNCH: 'true' };
// The parent process should not be reading from stdin while the child is running.
process.stdin.pause();

View File

@@ -63,7 +63,7 @@
"simple-git": "^3.28.0",
"strip-ansi": "^7.1.0",
"tiktoken": "^1.0.21",
"undici": "^6.22.0",
"undici": "^7.10.0",
"uuid": "^9.0.1",
"ws": "^8.18.0"
},

View File

@@ -287,7 +287,7 @@ export interface ConfigParameters {
contextFileName?: string | string[];
accessibility?: AccessibilitySettings;
telemetry?: TelemetrySettings;
gitCoAuthor?: boolean;
gitCoAuthor?: GitCoAuthorSettings;
usageStatisticsEnabled?: boolean;
fileFiltering?: {
respectGitIgnore?: boolean;
@@ -534,9 +534,9 @@ export class Config {
useCollector: params.telemetry?.useCollector,
};
this.gitCoAuthor = {
enabled: params.gitCoAuthor ?? true,
name: 'Qwen-Coder',
email: 'qwen-coder@alibabacloud.com',
enabled: params.gitCoAuthor?.enabled ?? true,
name: params.gitCoAuthor?.name ?? 'Qwen-Coder',
email: params.gitCoAuthor?.email ?? 'qwen-coder@alibabacloud.com',
};
this.usageStatisticsEnabled = params.usageStatisticsEnabled ?? true;
@@ -741,12 +741,9 @@ export class Config {
/**
* Starts a new session and resets session-scoped services.
*/
startNewSession(
sessionId?: string,
sessionData?: ResumedSessionData,
): string {
startNewSession(sessionId?: string): string {
this.sessionId = sessionId ?? randomUUID();
this.sessionData = sessionData;
this.sessionData = undefined;
this.chatRecordingService = this.chatRecordingEnabled
? new ChatRecordingService(this)
: undefined;

View File

@@ -15,7 +15,6 @@ export const OAUTH_FILE = 'oauth_creds.json';
const TMP_DIR_NAME = 'tmp';
const BIN_DIR_NAME = 'bin';
const PROJECT_DIR_NAME = 'projects';
const IDE_DIR_NAME = 'ide';
export class Storage {
private readonly targetDir: string;
@@ -60,10 +59,6 @@ export class Storage {
return path.join(Storage.getGlobalQwenDir(), TMP_DIR_NAME);
}
static getGlobalIdeDir(): string {
return path.join(Storage.getGlobalQwenDir(), IDE_DIR_NAME);
}
static getGlobalBinDir(): string {
return path.join(Storage.getGlobalQwenDir(), BIN_DIR_NAME);
}

View File

@@ -151,7 +151,8 @@ describe('BaseLlmClient', () => {
contents: defaultOptions.contents,
config: {
abortSignal: defaultOptions.abortSignal,
topP: 0.8,
temperature: 0,
topP: 1,
tools: [
{
functionDeclarations: [
@@ -188,7 +189,7 @@ describe('BaseLlmClient', () => {
expect.objectContaining({
config: expect.objectContaining({
temperature: 0.8,
topP: 0.8, // Default should remain if not overridden
topP: 1, // Default should remain if not overridden
topK: 10,
tools: expect.any(Array),
}),

View File

@@ -66,7 +66,8 @@ export interface GenerateJsonOptions {
export class BaseLlmClient {
// Default configuration for utility tasks
private readonly defaultUtilityConfig: GenerateContentConfig = {
topP: 0.8,
temperature: 0,
topP: 1,
};
constructor(

View File

@@ -2310,7 +2310,7 @@ ${JSON.stringify(
abortSignal,
systemInstruction: getCoreSystemPrompt(''),
temperature: 0.5,
topP: 0.8,
topP: 1,
},
contents,
},

View File

@@ -94,7 +94,8 @@ const MAX_TURNS = 100;
export class GeminiClient {
private chat?: GeminiChat;
private readonly generateContentConfig: GenerateContentConfig = {
topP: 0.8,
temperature: 0,
topP: 1,
};
private sessionTurnCount = 0;

View File

@@ -76,8 +76,6 @@ export type ContentGeneratorConfig = {
};
proxy?: string | undefined;
userAgent?: string;
// Schema compliance mode for tool definitions
schemaCompliance?: 'auto' | 'openapi_30';
};
export function createContentGeneratorConfig(

View File

@@ -22,10 +22,6 @@ import { GenerateContentResponse, FinishReason } from '@google/genai';
import type OpenAI from 'openai';
import { safeJsonParse } from '../../utils/safeJsonParse.js';
import { StreamingToolCallParser } from './streamingToolCallParser.js';
import {
convertSchema,
type SchemaComplianceMode,
} from '../../utils/schemaConverter.js';
/**
* Extended usage type that supports both OpenAI standard format and alternative formats
@@ -84,13 +80,11 @@ interface ParsedParts {
*/
export class OpenAIContentConverter {
private model: string;
private schemaCompliance: SchemaComplianceMode;
private streamingToolCallParser: StreamingToolCallParser =
new StreamingToolCallParser();
constructor(model: string, schemaCompliance: SchemaComplianceMode = 'auto') {
constructor(model: string) {
this.model = model;
this.schemaCompliance = schemaCompliance;
}
/**
@@ -211,10 +205,6 @@ export class OpenAIContentConverter {
);
}
if (parameters) {
parameters = convertSchema(parameters, this.schemaCompliance);
}
openAITools.push({
type: 'function',
function: {

View File

@@ -108,10 +108,7 @@ describe('ContentGenerationPipeline', () => {
describe('constructor', () => {
it('should initialize with correct configuration', () => {
expect(mockProvider.buildClient).toHaveBeenCalled();
expect(OpenAIContentConverter).toHaveBeenCalledWith(
'test-model',
undefined,
);
expect(OpenAIContentConverter).toHaveBeenCalledWith('test-model');
});
});

View File

@@ -34,7 +34,6 @@ export class ContentGenerationPipeline {
this.client = this.config.provider.buildClient();
this.converter = new OpenAIContentConverter(
this.contentGeneratorConfig.model,
this.contentGeneratorConfig.schemaCompliance,
);
}

View File

@@ -32,7 +32,6 @@ vi.mock('node:fs', async (importOriginal) => {
...actual.promises,
readFile: vi.fn(),
readdir: vi.fn(),
stat: vi.fn(),
},
realpathSync: (p: string) => p,
existsSync: () => false,
@@ -69,7 +68,6 @@ describe('IdeClient', () => {
command: 'test-ide',
});
vi.mocked(os.tmpdir).mockReturnValue('/tmp');
vi.mocked(os.homedir).mockReturnValue('/home/test');
// Mock MCP client and transports
mockClient = {
@@ -99,15 +97,19 @@ describe('IdeClient', () => {
describe('connect', () => {
it('should connect using HTTP when port is provided in config file', async () => {
process.env['QWEN_CODE_IDE_SERVER_PORT'] = '8080';
const config = { port: '8080' };
vi.mocked(fs.promises.readFile).mockResolvedValue(JSON.stringify(config));
(
vi.mocked(fs.promises.readdir) as Mock<
(path: fs.PathLike) => Promise<string[]>
>
).mockResolvedValue([]);
const ideClient = await IdeClient.getInstance();
await ideClient.connect();
expect(fs.promises.readFile).toHaveBeenCalledWith(
path.join('/home/test', '.qwen', 'ide', '8080.lock'),
path.join('/tmp', 'qwen-code-ide-server-12345.json'),
'utf8',
);
expect(StreamableHTTPClientTransport).toHaveBeenCalledWith(
@@ -118,13 +120,16 @@ describe('IdeClient', () => {
expect(ideClient.getConnectionStatus().status).toBe(
IDEConnectionStatus.Connected,
);
delete process.env['QWEN_CODE_IDE_SERVER_PORT'];
});
it('should connect using stdio when stdio config is provided in file', async () => {
process.env['QWEN_CODE_IDE_SERVER_PORT'] = '8080';
const config = { stdio: { command: 'test-cmd', args: ['--foo'] } };
vi.mocked(fs.promises.readFile).mockResolvedValue(JSON.stringify(config));
(
vi.mocked(fs.promises.readdir) as Mock<
(path: fs.PathLike) => Promise<string[]>
>
).mockResolvedValue([]);
const ideClient = await IdeClient.getInstance();
await ideClient.connect();
@@ -137,16 +142,19 @@ describe('IdeClient', () => {
expect(ideClient.getConnectionStatus().status).toBe(
IDEConnectionStatus.Connected,
);
delete process.env['QWEN_CODE_IDE_SERVER_PORT'];
});
it('should prioritize port over stdio when both are in config file', async () => {
process.env['QWEN_CODE_IDE_SERVER_PORT'] = '8080';
const config = {
port: '8080',
stdio: { command: 'test-cmd', args: ['--foo'] },
};
vi.mocked(fs.promises.readFile).mockResolvedValue(JSON.stringify(config));
(
vi.mocked(fs.promises.readdir) as Mock<
(path: fs.PathLike) => Promise<string[]>
>
).mockResolvedValue([]);
const ideClient = await IdeClient.getInstance();
await ideClient.connect();
@@ -156,7 +164,6 @@ describe('IdeClient', () => {
expect(ideClient.getConnectionStatus().status).toBe(
IDEConnectionStatus.Connected,
);
delete process.env['QWEN_CODE_IDE_SERVER_PORT'];
});
it('should connect using HTTP when port is provided in environment variables', async () => {
@@ -256,8 +263,7 @@ describe('IdeClient', () => {
});
describe('getConnectionConfigFromFile', () => {
it('should return config from the env port lock file if it exists', async () => {
process.env['QWEN_CODE_IDE_SERVER_PORT'] = '1234';
it('should return config from the specific pid file if it exists', async () => {
const config = { port: '1234', workspacePath: '/test/workspace' };
vi.mocked(fs.promises.readFile).mockResolvedValue(JSON.stringify(config));
@@ -271,14 +277,18 @@ describe('IdeClient', () => {
expect(result).toEqual(config);
expect(fs.promises.readFile).toHaveBeenCalledWith(
path.join('/home/test', '.qwen', 'ide', '1234.lock'),
path.join('/tmp', 'qwen-code-ide-server-12345.json'),
'utf8',
);
delete process.env['QWEN_CODE_IDE_SERVER_PORT'];
});
it('should return undefined if no config files are found', async () => {
vi.mocked(fs.promises.readFile).mockRejectedValue(new Error('not found'));
(
vi.mocked(fs.promises.readdir) as Mock<
(path: fs.PathLike) => Promise<string[]>
>
).mockResolvedValue([]);
const ideClient = await IdeClient.getInstance();
const result = await (
@@ -290,15 +300,20 @@ describe('IdeClient', () => {
expect(result).toBeUndefined();
});
it('should read legacy pid config when available', async () => {
const config = {
port: '5678',
workspacePath: '/test/workspace',
ppid: 12345,
};
vi.mocked(fs.promises.readFile).mockResolvedValueOnce(
JSON.stringify(config),
);
it('should find and parse a single config file with the new naming scheme', async () => {
const config = { port: '5678', workspacePath: '/test/workspace' };
vi.mocked(fs.promises.readFile).mockRejectedValueOnce(
new Error('not found'),
); // For old path
(
vi.mocked(fs.promises.readdir) as Mock<
(path: fs.PathLike) => Promise<string[]>
>
).mockResolvedValue(['qwen-code-ide-server-12345-123.json']);
vi.mocked(fs.promises.readFile).mockResolvedValue(JSON.stringify(config));
vi.spyOn(IdeClient, 'validateWorkspacePath').mockReturnValue({
isValid: true,
});
const ideClient = await IdeClient.getInstance();
const result = await (
@@ -309,18 +324,110 @@ describe('IdeClient', () => {
expect(result).toEqual(config);
expect(fs.promises.readFile).toHaveBeenCalledWith(
path.join('/tmp', 'qwen-code-ide-server-12345.json'),
path.join('/tmp/gemini/ide', 'qwen-code-ide-server-12345-123.json'),
'utf8',
);
});
it('should fall back to legacy port file when pid file is missing', async () => {
process.env['QWEN_CODE_IDE_SERVER_PORT'] = '2222';
const config2 = { port: '2222', workspacePath: '/test/workspace2' };
it('should filter out configs with invalid workspace paths', async () => {
const validConfig = {
port: '5678',
workspacePath: '/test/workspace',
};
const invalidConfig = {
port: '1111',
workspacePath: '/invalid/workspace',
};
vi.mocked(fs.promises.readFile).mockRejectedValueOnce(
new Error('not found'),
);
(
vi.mocked(fs.promises.readdir) as Mock<
(path: fs.PathLike) => Promise<string[]>
>
).mockResolvedValue([
'qwen-code-ide-server-12345-111.json',
'qwen-code-ide-server-12345-222.json',
]);
vi.mocked(fs.promises.readFile)
.mockRejectedValueOnce(new Error('not found')) // ~/.qwen/ide/<port>.lock
.mockRejectedValueOnce(new Error('not found')) // legacy pid file
.mockResolvedValueOnce(JSON.stringify(invalidConfig))
.mockResolvedValueOnce(JSON.stringify(validConfig));
const validateSpy = vi
.spyOn(IdeClient, 'validateWorkspacePath')
.mockReturnValueOnce({ isValid: false })
.mockReturnValueOnce({ isValid: true });
const ideClient = await IdeClient.getInstance();
const result = await (
ideClient as unknown as {
getConnectionConfigFromFile: () => Promise<unknown>;
}
).getConnectionConfigFromFile();
expect(result).toEqual(validConfig);
expect(validateSpy).toHaveBeenCalledWith(
'/invalid/workspace',
'/test/workspace/sub-dir',
);
expect(validateSpy).toHaveBeenCalledWith(
'/test/workspace',
'/test/workspace/sub-dir',
);
});
it('should return the first valid config when multiple workspaces are valid', async () => {
const config1 = { port: '1111', workspacePath: '/test/workspace' };
const config2 = { port: '2222', workspacePath: '/test/workspace2' };
vi.mocked(fs.promises.readFile).mockRejectedValueOnce(
new Error('not found'),
);
(
vi.mocked(fs.promises.readdir) as Mock<
(path: fs.PathLike) => Promise<string[]>
>
).mockResolvedValue([
'qwen-code-ide-server-12345-111.json',
'qwen-code-ide-server-12345-222.json',
]);
vi.mocked(fs.promises.readFile)
.mockResolvedValueOnce(JSON.stringify(config1))
.mockResolvedValueOnce(JSON.stringify(config2));
vi.spyOn(IdeClient, 'validateWorkspacePath').mockReturnValue({
isValid: true,
});
const ideClient = await IdeClient.getInstance();
const result = await (
ideClient as unknown as {
getConnectionConfigFromFile: () => Promise<unknown>;
}
).getConnectionConfigFromFile();
expect(result).toEqual(config1);
});
it('should prioritize the config matching the port from the environment variable', async () => {
process.env['QWEN_CODE_IDE_SERVER_PORT'] = '2222';
const config1 = { port: '1111', workspacePath: '/test/workspace' };
const config2 = { port: '2222', workspacePath: '/test/workspace2' };
vi.mocked(fs.promises.readFile).mockRejectedValueOnce(
new Error('not found'),
);
(
vi.mocked(fs.promises.readdir) as Mock<
(path: fs.PathLike) => Promise<string[]>
>
).mockResolvedValue([
'qwen-code-ide-server-12345-111.json',
'qwen-code-ide-server-12345-222.json',
]);
vi.mocked(fs.promises.readFile)
.mockResolvedValueOnce(JSON.stringify(config1))
.mockResolvedValueOnce(JSON.stringify(config2));
vi.spyOn(IdeClient, 'validateWorkspacePath').mockReturnValue({
isValid: true,
});
const ideClient = await IdeClient.getInstance();
const result = await (
@@ -330,23 +437,28 @@ describe('IdeClient', () => {
).getConnectionConfigFromFile();
expect(result).toEqual(config2);
expect(fs.promises.readFile).toHaveBeenCalledWith(
path.join('/tmp', 'qwen-code-ide-server-12345.json'),
'utf8',
);
expect(fs.promises.readFile).toHaveBeenCalledWith(
path.join('/tmp', 'qwen-code-ide-server-2222.json'),
'utf8',
);
delete process.env['QWEN_CODE_IDE_SERVER_PORT'];
});
it('should fall back to legacy config when env lock file has invalid JSON', async () => {
process.env['QWEN_CODE_IDE_SERVER_PORT'] = '3333';
const config = { port: '1111', workspacePath: '/test/workspace' };
it('should handle invalid JSON in one of the config files', async () => {
const validConfig = { port: '2222', workspacePath: '/test/workspace' };
vi.mocked(fs.promises.readFile).mockRejectedValueOnce(
new Error('not found'),
);
(
vi.mocked(fs.promises.readdir) as Mock<
(path: fs.PathLike) => Promise<string[]>
>
).mockResolvedValue([
'qwen-code-ide-server-12345-111.json',
'qwen-code-ide-server-12345-222.json',
]);
vi.mocked(fs.promises.readFile)
.mockResolvedValueOnce('invalid json')
.mockResolvedValueOnce(JSON.stringify(config));
.mockResolvedValueOnce(JSON.stringify(validConfig));
vi.spyOn(IdeClient, 'validateWorkspacePath').mockReturnValue({
isValid: true,
});
const ideClient = await IdeClient.getInstance();
const result = await (
@@ -355,7 +467,96 @@ describe('IdeClient', () => {
}
).getConnectionConfigFromFile();
expect(result).toEqual(config);
expect(result).toEqual(validConfig);
});
it('should return undefined if readdir throws an error', async () => {
vi.mocked(fs.promises.readFile).mockRejectedValueOnce(
new Error('not found'),
);
vi.mocked(fs.promises.readdir).mockRejectedValue(
new Error('readdir failed'),
);
const ideClient = await IdeClient.getInstance();
const result = await (
ideClient as unknown as {
getConnectionConfigFromFile: () => Promise<unknown>;
}
).getConnectionConfigFromFile();
expect(result).toBeUndefined();
});
it('should ignore files with invalid names', async () => {
const validConfig = { port: '3333', workspacePath: '/test/workspace' };
vi.mocked(fs.promises.readFile).mockRejectedValueOnce(
new Error('not found'),
);
(
vi.mocked(fs.promises.readdir) as Mock<
(path: fs.PathLike) => Promise<string[]>
>
).mockResolvedValue([
'qwen-code-ide-server-12345-111.json', // valid
'not-a-config-file.txt', // invalid
'qwen-code-ide-server-asdf.json', // invalid
]);
vi.mocked(fs.promises.readFile).mockResolvedValueOnce(
JSON.stringify(validConfig),
);
vi.spyOn(IdeClient, 'validateWorkspacePath').mockReturnValue({
isValid: true,
});
const ideClient = await IdeClient.getInstance();
const result = await (
ideClient as unknown as {
getConnectionConfigFromFile: () => Promise<unknown>;
}
).getConnectionConfigFromFile();
expect(result).toEqual(validConfig);
expect(fs.promises.readFile).toHaveBeenCalledWith(
path.join('/tmp/gemini/ide', 'qwen-code-ide-server-12345-111.json'),
'utf8',
);
expect(fs.promises.readFile).not.toHaveBeenCalledWith(
path.join('/tmp/gemini/ide', 'not-a-config-file.txt'),
'utf8',
);
});
it('should match env port string to a number port in the config', async () => {
process.env['QWEN_CODE_IDE_SERVER_PORT'] = '3333';
const config1 = { port: 1111, workspacePath: '/test/workspace' };
const config2 = { port: 3333, workspacePath: '/test/workspace2' };
vi.mocked(fs.promises.readFile).mockRejectedValueOnce(
new Error('not found'),
);
(
vi.mocked(fs.promises.readdir) as Mock<
(path: fs.PathLike) => Promise<string[]>
>
).mockResolvedValue([
'qwen-code-ide-server-12345-111.json',
'qwen-code-ide-server-12345-222.json',
]);
vi.mocked(fs.promises.readFile)
.mockResolvedValueOnce(JSON.stringify(config1))
.mockResolvedValueOnce(JSON.stringify(config2));
vi.spyOn(IdeClient, 'validateWorkspacePath').mockReturnValue({
isValid: true,
});
const ideClient = await IdeClient.getInstance();
const result = await (
ideClient as unknown as {
getConnectionConfigFromFile: () => Promise<unknown>;
}
).getConnectionConfigFromFile();
expect(result).toEqual(config2);
delete process.env['QWEN_CODE_IDE_SERVER_PORT'];
});
});

View File

@@ -8,7 +8,6 @@ import * as fs from 'node:fs';
import { isSubpath } from '../utils/paths.js';
import { detectIde, type IdeInfo } from '../ide/detect-ide.js';
import { ideContextStore } from './ideContext.js';
import { Storage } from '../config/storage.js';
import {
IdeContextNotificationSchema,
IdeDiffAcceptedNotificationSchema,
@@ -573,103 +572,98 @@ export class IdeClient {
| (ConnectionConfig & { workspacePath?: string; ideInfo?: IdeInfo })
| undefined
> {
const portFromEnv = this.getPortFromEnv();
if (portFromEnv) {
try {
const ideDir = Storage.getGlobalIdeDir();
const lockFile = path.join(ideDir, `${portFromEnv}.lock`);
const lockFileContents = await fs.promises.readFile(lockFile, 'utf8');
return JSON.parse(lockFileContents);
} catch (_) {
// Fall through to legacy discovery.
}
if (!this.ideProcessInfo) {
return undefined;
}
// Legacy discovery for VSCode extension < v0.5.1.
return this.getLegacyConnectionConfig(portFromEnv);
}
// Legacy connection files were written in the global temp directory.
private async getLegacyConnectionConfig(
portFromEnv?: string,
): Promise<
| (ConnectionConfig & { workspacePath?: string; ideInfo?: IdeInfo })
| undefined
> {
if (this.ideProcessInfo) {
try {
const portFile = path.join(
os.tmpdir(),
`qwen-code-ide-server-${this.ideProcessInfo.pid}.json`,
);
const portFileContents = await fs.promises.readFile(portFile, 'utf8');
return JSON.parse(portFileContents);
} catch (_) {
// For older/newer extension versions, the file name matches the pattern
// /^qwen-code-ide-server-${pid}-\d+\.json$/. If multiple IDE
// windows are open, multiple files matching the pattern are expected to
// exist.
}
}
if (portFromEnv) {
try {
const portFile = path.join(
os.tmpdir(),
`qwen-code-ide-server-${portFromEnv}.json`,
);
const portFileContents = await fs.promises.readFile(portFile, 'utf8');
return JSON.parse(portFileContents);
} catch (_) {
// Ignore and fall through.
}
}
return undefined;
}
protected async getAllConnectionConfigs(
ideDir: string,
): Promise<
ConnectionConfig & Array<{ workspacePath?: string; ideInfo?: IdeInfo }>
> {
const fileRegex = new RegExp('^\\d+\\.lock$');
let lockFiles: string[];
// For backwards compatability
try {
lockFiles = (await fs.promises.readdir(ideDir)).filter((file) =>
fileRegex.test(file),
const portFile = path.join(
os.tmpdir(),
`qwen-code-ide-server-${this.ideProcessInfo.pid}.json`,
);
const portFileContents = await fs.promises.readFile(portFile, 'utf8');
return JSON.parse(portFileContents);
} catch (_) {
// For newer extension versions, the file name matches the pattern
// /^qwen-code-ide-server-${pid}-\d+\.json$/. If multiple IDE
// windows are open, multiple files matching the pattern are expected to
// exist.
}
const portFileDir = path.join(os.tmpdir(), 'gemini', 'ide');
let portFiles;
try {
portFiles = await fs.promises.readdir(portFileDir);
} catch (e) {
logger.debug('Failed to read IDE connection directory:', e);
return [];
return undefined;
}
const fileContents = await Promise.all(
lockFiles.map(async (file) => {
const fullPath = path.join(ideDir, file);
try {
const stat = await fs.promises.stat(fullPath);
const content = await fs.promises.readFile(fullPath, 'utf8');
try {
const parsed = JSON.parse(content);
return { file, mtimeMs: stat.mtimeMs, parsed };
} catch (e) {
logger.debug('Failed to parse JSON from lock file: ', e);
return { file, mtimeMs: stat.mtimeMs, parsed: undefined };
}
} catch (e) {
// If we can't stat/read the file, treat it as very old so it doesn't
// win ties, and skip parsing by returning undefined content.
logger.debug('Failed to read/stat IDE lock file:', e);
return { file, mtimeMs: -Infinity, parsed: undefined };
}
}),
);
if (!portFiles) {
return undefined;
}
return fileContents
.filter(({ parsed }) => parsed !== undefined)
.sort((a, b) => b.mtimeMs - a.mtimeMs)
.map(({ parsed }) => parsed);
const fileRegex = new RegExp(
`^qwen-code-ide-server-${this.ideProcessInfo.pid}-\\d+\\.json$`,
);
const matchingFiles = portFiles
.filter((file) => fileRegex.test(file))
.sort();
if (matchingFiles.length === 0) {
return undefined;
}
let fileContents: string[];
try {
fileContents = await Promise.all(
matchingFiles.map((file) =>
fs.promises.readFile(path.join(portFileDir, file), 'utf8'),
),
);
} catch (e) {
logger.debug('Failed to read IDE connection config file(s):', e);
return undefined;
}
const parsedContents = fileContents.map((content) => {
try {
return JSON.parse(content);
} catch (e) {
logger.debug('Failed to parse JSON from config file: ', e);
return undefined;
}
});
const validWorkspaces = parsedContents.filter((content) => {
if (!content) {
return false;
}
const { isValid } = IdeClient.validateWorkspacePath(
content.workspacePath,
process.cwd(),
);
return isValid;
});
if (validWorkspaces.length === 0) {
return undefined;
}
if (validWorkspaces.length === 1) {
return validWorkspaces[0];
}
const portFromEnv = this.getPortFromEnv();
if (portFromEnv) {
const matchingPort = validWorkspaces.find(
(content) => String(content.port) === portFromEnv,
);
if (matchingPort) {
return matchingPort;
}
}
return validWorkspaces[0];
}
private createProxyAwareFetch() {

View File

@@ -50,7 +50,7 @@ describe('getIdeProcessInfo', () => {
expect(result).toEqual({ pid: 700, command: '/usr/lib/vscode/code' });
});
it('should return shell process info if grandparent lookup fails', async () => {
it('should return parent process info if grandparent lookup fails', async () => {
(os.platform as Mock).mockReturnValue('linux');
mockedExec
.mockResolvedValueOnce({ stdout: '800 /bin/bash' }) // pid 1000 -> ppid 800 (shell)
@@ -63,96 +63,134 @@ describe('getIdeProcessInfo', () => {
});
describe('on Windows', () => {
it('should return great-grandparent process using heuristic', async () => {
it('should traverse up and find the great-grandchild of the root process', async () => {
(os.platform as Mock).mockReturnValue('win32');
const processes = [
{
ProcessId: 1000,
ParentProcessId: 900,
Name: 'node.exe',
CommandLine: 'node.exe',
},
{
ProcessId: 900,
ParentProcessId: 800,
Name: 'powershell.exe',
CommandLine: 'powershell.exe',
},
{
ProcessId: 800,
ParentProcessId: 700,
Name: 'code.exe',
CommandLine: 'code.exe',
},
{
ProcessId: 700,
ParentProcessId: 0,
Name: 'wininit.exe',
CommandLine: 'wininit.exe',
},
];
mockedExec.mockImplementation((file: string, _args: string[]) => {
if (file === 'powershell') {
return Promise.resolve({ stdout: JSON.stringify(processes) });
const processInfoMap = new Map([
[
1000,
{
stdout:
'{"Name":"node.exe","ParentProcessId":900,"CommandLine":"node.exe"}',
},
],
[
900,
{
stdout:
'{"Name":"powershell.exe","ParentProcessId":800,"CommandLine":"powershell.exe"}',
},
],
[
800,
{
stdout:
'{"Name":"code.exe","ParentProcessId":700,"CommandLine":"code.exe"}',
},
],
[
700,
{
stdout:
'{"Name":"wininit.exe","ParentProcessId":0,"CommandLine":"wininit.exe"}',
},
],
]);
mockedExec.mockImplementation((command: string) => {
const pidMatch = command.match(/ProcessId=(\d+)/);
if (pidMatch) {
const pid = parseInt(pidMatch[1], 10);
return Promise.resolve(processInfoMap.get(pid));
}
return Promise.resolve({ stdout: '' });
return Promise.reject(new Error('Invalid command for mock'));
});
const result = await getIdeProcessInfo();
// Process chain: 1000 (node.exe) -> 900 (powershell.exe) -> 800 (code.exe) -> 700 (wininit.exe)
// ancestors = [1000, 900, 800, 700], length = 4
// Heuristic: return ancestors[length-3] = ancestors[1] = 900 (powershell.exe)
expect(result).toEqual({ pid: 900, command: 'powershell.exe' });
});
it('should handle empty process list gracefully', async () => {
it('should handle non-existent process gracefully', async () => {
(os.platform as Mock).mockReturnValue('win32');
mockedExec.mockResolvedValue({ stdout: '[]' });
mockedExec
.mockResolvedValueOnce({ stdout: '' }) // Non-existent PID returns empty due to -ErrorAction SilentlyContinue
.mockResolvedValueOnce({
stdout:
'{"Name":"fallback.exe","ParentProcessId":0,"CommandLine":"fallback.exe"}',
}); // Fallback call
const result = await getIdeProcessInfo();
// Should return current pid and empty command because process not found in map
expect(result).toEqual({ pid: 1000, command: '' });
expect(result).toEqual({ pid: 1000, command: 'fallback.exe' });
});
it('should handle malformed JSON output gracefully', async () => {
(os.platform as Mock).mockReturnValue('win32');
mockedExec.mockResolvedValue({ stdout: '{"invalid":json}' });
mockedExec
.mockResolvedValueOnce({ stdout: '{"invalid":json}' }) // Malformed JSON
.mockResolvedValueOnce({
stdout:
'{"Name":"fallback.exe","ParentProcessId":0,"CommandLine":"fallback.exe"}',
}); // Fallback call
const result = await getIdeProcessInfo();
expect(result).toEqual({ pid: 1000, command: '' });
expect(result).toEqual({ pid: 1000, command: 'fallback.exe' });
});
it('should return last ancestor if chain is too short', async () => {
it('should handle PowerShell errors without crashing the process chain', async () => {
(os.platform as Mock).mockReturnValue('win32');
const processInfoMap = new Map([
[1000, { stdout: '' }], // First process doesn't exist (empty due to -ErrorAction)
[
1001,
{
stdout:
'{"Name":"parent.exe","ParentProcessId":800,"CommandLine":"parent.exe"}',
},
],
[
800,
{
stdout:
'{"Name":"ide.exe","ParentProcessId":0,"CommandLine":"ide.exe"}',
},
],
]);
const processes = [
{
ProcessId: 1000,
ParentProcessId: 900,
Name: 'node.exe',
CommandLine: 'node.exe',
},
{
ProcessId: 900,
ParentProcessId: 0,
Name: 'explorer.exe',
CommandLine: 'explorer.exe',
},
];
// Mock the process.pid to test traversal with missing processes
Object.defineProperty(process, 'pid', {
value: 1001,
configurable: true,
});
mockedExec.mockImplementation((file: string, _args: string[]) => {
if (file === 'powershell') {
return Promise.resolve({ stdout: JSON.stringify(processes) });
mockedExec.mockImplementation((command: string) => {
const pidMatch = command.match(/ProcessId=(\d+)/);
if (pidMatch) {
const pid = parseInt(pidMatch[1], 10);
return Promise.resolve(processInfoMap.get(pid) || { stdout: '' });
}
return Promise.resolve({ stdout: '' });
return Promise.reject(new Error('Invalid command for mock'));
});
const result = await getIdeProcessInfo();
// ancestors = [1000, 900], length = 2 (< 3)
// Heuristic: return ancestors[length-1] = ancestors[1] = 900 (explorer.exe)
expect(result).toEqual({ pid: 900, command: 'explorer.exe' });
// Should return the current process command since traversal continues despite missing processes
expect(result).toEqual({ pid: 1001, command: 'parent.exe' });
// Reset process.pid
Object.defineProperty(process, 'pid', {
value: 1000,
configurable: true,
});
});
it('should handle partial JSON data with defaults', async () => {
(os.platform as Mock).mockReturnValue('win32');
mockedExec
.mockResolvedValueOnce({ stdout: '{"Name":"partial.exe"}' }) // Missing ParentProcessId, defaults to 0
.mockResolvedValueOnce({
stdout:
'{"Name":"root.exe","ParentProcessId":0,"CommandLine":"root.exe"}',
}); // Get grandparent info
const result = await getIdeProcessInfo();
expect(result).toEqual({ pid: 1000, command: 'root.exe' });
});
});
});

View File

@@ -4,28 +4,74 @@
* SPDX-License-Identifier: Apache-2.0
*/
import { exec, execFile } from 'node:child_process';
import { exec } from 'node:child_process';
import { promisify } from 'node:util';
import os from 'node:os';
import path from 'node:path';
const execAsync = promisify(exec);
const execFileAsync = promisify(execFile);
const MAX_TRAVERSAL_DEPTH = 32;
/**
* Fetches the parent process ID, name, and command for a given process ID.
*
* @param pid The process ID to inspect.
* @returns A promise that resolves to the parent's PID, name, and command.
*/
async function getProcessInfo(pid: number): Promise<{
parentPid: number;
name: string;
command: string;
}> {
// Only used for Unix systems (macOS and Linux)
const { stdout } = await execAsync(`ps -p ${pid} -o ppid=,comm=`);
const [ppidStr, ...commandParts] = stdout.trim().split(/\s+/);
const parentPid = parseInt(ppidStr, 10);
const command = commandParts.join(' ');
return { parentPid, name: path.basename(command), command };
try {
const platform = os.platform();
if (platform === 'win32') {
const powershellCommand = [
'$p = Get-CimInstance Win32_Process',
`-Filter 'ProcessId=${pid}'`,
'-ErrorAction SilentlyContinue;',
'if ($p) {',
'@{Name=$p.Name;ParentProcessId=$p.ParentProcessId;CommandLine=$p.CommandLine}',
'| ConvertTo-Json',
'}',
].join(' ');
const { stdout } = await execAsync(`powershell "${powershellCommand}"`);
const output = stdout.trim();
if (!output) return { parentPid: 0, name: '', command: '' };
const {
Name = '',
ParentProcessId = 0,
CommandLine = '',
} = JSON.parse(output);
return {
parentPid: ParentProcessId,
name: Name,
command: CommandLine ?? '',
};
} else {
const command = `ps -o ppid=,command= -p ${pid}`;
const { stdout } = await execAsync(command);
const trimmedStdout = stdout.trim();
if (!trimmedStdout) {
return { parentPid: 0, name: '', command: '' };
}
const ppidString = trimmedStdout.split(/\s+/)[0];
const parentPid = parseInt(ppidString, 10);
const fullCommand = trimmedStdout.substring(ppidString.length).trim();
const processName = path.basename(fullCommand.split(' ')[0]);
return {
parentPid: isNaN(parentPid) ? 1 : parentPid,
name: processName,
command: fullCommand,
};
}
} catch (_e) {
console.debug(`Failed to get process info for pid ${pid}:`, _e);
return { parentPid: 0, name: '', command: '' };
}
}
/**
* Finds the IDE process info on Unix-like systems.
*
@@ -60,15 +106,15 @@ async function getIdeProcessInfoForUnix(): Promise<{
} catch {
// Ignore if getting grandparent fails, we'll just use the parent pid.
}
const { command: ideCommand } = await getProcessInfo(idePid);
return { pid: idePid, command: ideCommand };
const { command } = await getProcessInfo(idePid);
return { pid: idePid, command };
}
if (parentPid <= 1) {
break; // Reached the root
}
currentPid = parentPid;
} catch (_e) {
} catch {
// Process in chain died
break;
}
@@ -78,104 +124,50 @@ async function getIdeProcessInfoForUnix(): Promise<{
return { pid: currentPid, command };
}
interface ProcessInfo {
pid: number;
parentPid: number;
name: string;
command: string;
}
interface RawProcessInfo {
ProcessId?: number;
ParentProcessId?: number;
Name?: string;
CommandLine?: string;
}
/**
* Fetches the entire process table on Windows.
* Finds the IDE process info on Windows.
*
* The strategy is to find the great-grandchild of the root process.
*
* @returns A promise that resolves to the PID and command of the IDE process.
*/
async function getProcessTableWindows(): Promise<Map<number, ProcessInfo>> {
const processMap = new Map<number, ProcessInfo>();
try {
const powershellCommand =
'Get-CimInstance Win32_Process | Select-Object ProcessId,ParentProcessId,Name,CommandLine | ConvertTo-Json -Compress';
const { stdout } = await execFileAsync(
'powershell',
['-NoProfile', '-NonInteractive', '-Command', powershellCommand],
{ maxBuffer: 10 * 1024 * 1024 },
);
if (!stdout.trim()) {
return processMap;
}
let processes: RawProcessInfo | RawProcessInfo[];
try {
processes = JSON.parse(stdout);
} catch (_e) {
return processMap;
}
if (!Array.isArray(processes)) {
processes = [processes];
}
for (const p of processes) {
if (p && typeof p.ProcessId === 'number') {
processMap.set(p.ProcessId, {
pid: p.ProcessId,
parentPid: p.ParentProcessId || 0,
name: p.Name || '',
command: p.CommandLine || '',
});
}
}
} catch (_e) {
// Fallback or error handling if PowerShell fails
}
return processMap;
}
async function getIdeProcessInfoForWindows(): Promise<{
pid: number;
command: string;
}> {
// Fetch the entire process table in one go.
const processMap = await getProcessTableWindows();
let currentPid = process.pid;
let previousPid = process.pid;
const myPid = process.pid;
const myProc = processMap.get(myPid);
for (let i = 0; i < MAX_TRAVERSAL_DEPTH; i++) {
try {
const { parentPid } = await getProcessInfo(currentPid);
if (!myProc) {
// Fallback: return current process info if snapshot fails
return { pid: myPid, command: '' };
}
if (parentPid > 0) {
try {
const { parentPid: grandParentPid } = await getProcessInfo(parentPid);
if (grandParentPid === 0) {
// We've found the grandchild of the root (`currentPid`). The IDE
// process is its child, which we've stored in `previousPid`.
const { command } = await getProcessInfo(previousPid);
return { pid: previousPid, command };
}
} catch {
// getting grandparent failed, proceed
}
}
// Perform tree traversal in memory
const ancestors: ProcessInfo[] = [];
let curr: ProcessInfo | undefined = myProc;
for (let i = 0; i < MAX_TRAVERSAL_DEPTH && curr; i++) {
ancestors.push(curr);
if (curr.parentPid === 0 || !processMap.has(curr.parentPid)) {
// Parent process not in map, stop traversal
if (parentPid <= 0) {
break; // Reached the root
}
previousPid = currentPid;
currentPid = parentPid;
} catch {
// Process in chain died
break;
}
curr = processMap.get(curr.parentPid);
}
// Use heuristic: return the great-grandparent (ancestors[length-3])
if (ancestors.length >= 3) {
const target = ancestors[ancestors.length - 3];
return { pid: target.pid, command: target.command };
} else if (ancestors.length > 0) {
const target = ancestors[ancestors.length - 1];
return { pid: target.pid, command: target.command };
}
return { pid: myPid, command: myProc.command };
const { command } = await getProcessInfo(currentPid);
return { pid: currentPid, command };
}
/**

View File

@@ -608,36 +608,6 @@ describe('ShellTool', () => {
);
});
it('should handle git commit with combined short flags like -am', async () => {
const command = 'git commit -am "Add feature"';
const invocation = shellTool.build({ command, is_background: false });
const promise = invocation.execute(mockAbortSignal);
resolveExecutionPromise({
rawOutput: Buffer.from(''),
output: '',
exitCode: 0,
signal: null,
error: null,
aborted: false,
pid: 12345,
executionMethod: 'child_process',
});
await promise;
expect(mockShellExecutionService).toHaveBeenCalledWith(
expect.stringContaining(
'Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>',
),
expect.any(String),
expect.any(Function),
mockAbortSignal,
false,
{},
);
});
it('should not modify non-git commands', async () => {
const command = 'npm install';
const invocation = shellTool.build({ command, is_background: false });
@@ -798,69 +768,6 @@ describe('ShellTool', () => {
{},
);
});
it('should add co-author when git commit is prefixed with cd command', async () => {
const command = 'cd /tmp/test && git commit -m "Test commit"';
const invocation = shellTool.build({ command, is_background: false });
const promise = invocation.execute(mockAbortSignal);
resolveExecutionPromise({
rawOutput: Buffer.from(''),
output: '',
exitCode: 0,
signal: null,
error: null,
aborted: false,
pid: 12345,
executionMethod: 'child_process',
});
await promise;
expect(mockShellExecutionService).toHaveBeenCalledWith(
expect.stringContaining(
'Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>',
),
expect.any(String),
expect.any(Function),
mockAbortSignal,
false,
{},
);
});
it('should add co-author to git commit with multi-line message', async () => {
const command = `git commit -m "Fix bug
This is a detailed description
spanning multiple lines"`;
const invocation = shellTool.build({ command, is_background: false });
const promise = invocation.execute(mockAbortSignal);
resolveExecutionPromise({
rawOutput: Buffer.from(''),
output: '',
exitCode: 0,
signal: null,
error: null,
aborted: false,
pid: 12345,
executionMethod: 'child_process',
});
await promise;
expect(mockShellExecutionService).toHaveBeenCalledWith(
expect.stringContaining(
'Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>',
),
expect.any(String),
expect.any(Function),
mockAbortSignal,
false,
{},
);
});
});
});

View File

@@ -334,14 +334,13 @@ export class ShellToolInvocation extends BaseToolInvocation<
private addCoAuthorToGitCommit(command: string): string {
// Check if co-author feature is enabled
const gitCoAuthorSettings = this.config.getGitCoAuthor();
if (!gitCoAuthorSettings.enabled) {
return command;
}
// Check if this is a git commit command (anywhere in the command, e.g., after "cd /path &&")
const gitCommitPattern = /\bgit\s+commit\b/;
if (!gitCommitPattern.test(command)) {
// Check if this is a git commit command
const gitCommitPattern = /^git\s+commit/;
if (!gitCommitPattern.test(command.trim())) {
return command;
}
@@ -350,27 +349,15 @@ export class ShellToolInvocation extends BaseToolInvocation<
Co-authored-by: ${gitCoAuthorSettings.name} <${gitCoAuthorSettings.email}>`;
// Handle different git commit patterns:
// Match -m "message" or -m 'message', including combined flags like -am
// Use separate patterns to avoid ReDoS (catastrophic backtracking)
//
// Pattern breakdown:
// -[a-zA-Z]*m matches -m, -am, -nm, etc. (combined short flags)
// \s+ matches whitespace after the flag
// [^"\\] matches any char except double-quote and backslash
// \\. matches escape sequences like \" or \\
// (?:...|...)* matches normal chars or escapes, repeated
const doubleQuotePattern = /(-[a-zA-Z]*m\s+)"((?:[^"\\]|\\.)*)"/;
const singleQuotePattern = /(-[a-zA-Z]*m\s+)'((?:[^'\\]|\\.)*)'/;
const doubleMatch = command.match(doubleQuotePattern);
const singleMatch = command.match(singleQuotePattern);
const match = doubleMatch ?? singleMatch;
const quote = doubleMatch ? '"' : "'";
// Handle different git commit patterns
// Match -m "message" or -m 'message'
const messagePattern = /(-m\s+)(['"])((?:\\.|[^\\])*?)(\2)/;
const match = command.match(messagePattern);
if (match) {
const [fullMatch, prefix, existingMessage] = match;
const [fullMatch, prefix, quote, existingMessage, closingQuote] = match;
const newMessage = existingMessage + coAuthor;
const replacement = prefix + quote + newMessage + quote;
const replacement = prefix + quote + newMessage + closingQuote;
return command.replace(fullMatch, replacement);
}

View File

@@ -1,118 +0,0 @@
/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import { describe, it, expect } from 'vitest';
import { convertSchema } from './schemaConverter.js';
describe('convertSchema', () => {
describe('mode: auto (default)', () => {
it('should preserve type arrays', () => {
const input = { type: ['string', 'null'] };
expect(convertSchema(input, 'auto')).toEqual(input);
});
it('should preserve items array (tuples)', () => {
const input = {
type: 'array',
items: [{ type: 'string' }, { type: 'number' }],
};
expect(convertSchema(input, 'auto')).toEqual(input);
});
it('should preserve mixed enums', () => {
const input = { enum: [1, 2, '3'] };
expect(convertSchema(input, 'auto')).toEqual(input);
});
it('should preserve unsupported keywords', () => {
const input = {
$schema: 'http://json-schema.org/draft-07/schema#',
exclusiveMinimum: 10,
type: 'number',
};
expect(convertSchema(input, 'auto')).toEqual(input);
});
});
describe('mode: openapi_30 (strict)', () => {
it('should convert type arrays to nullable', () => {
const input = { type: ['string', 'null'] };
const expected = { type: 'string', nullable: true };
expect(convertSchema(input, 'openapi_30')).toEqual(expected);
});
it('should fallback to first type for non-nullable arrays', () => {
const input = { type: ['string', 'number'] };
const expected = { type: 'string' };
expect(convertSchema(input, 'openapi_30')).toEqual(expected);
});
it('should convert const to enum', () => {
const input = { const: 'foo' };
const expected = { enum: ['foo'] };
expect(convertSchema(input, 'openapi_30')).toEqual(expected);
});
it('should convert exclusiveMinimum number to boolean', () => {
const input = { type: 'number', exclusiveMinimum: 10 };
const expected = {
type: 'number',
minimum: 10,
exclusiveMinimum: true,
};
expect(convertSchema(input, 'openapi_30')).toEqual(expected);
});
it('should convert nested objects recursively', () => {
const input = {
type: 'object',
properties: {
prop1: { type: ['integer', 'null'], exclusiveMaximum: 5 },
},
};
const expected = {
type: 'object',
properties: {
prop1: {
type: 'integer',
nullable: true,
maximum: 5,
exclusiveMaximum: true,
},
},
};
expect(convertSchema(input, 'openapi_30')).toEqual(expected);
});
it('should stringify enums', () => {
const input = { enum: [1, 2, '3'] };
const expected = { enum: ['1', '2', '3'] };
expect(convertSchema(input, 'openapi_30')).toEqual(expected);
});
it('should remove tuple items (array of schemas)', () => {
const input = {
type: 'array',
items: [{ type: 'string' }, { type: 'number' }],
};
const expected = { type: 'array' };
expect(convertSchema(input, 'openapi_30')).toEqual(expected);
});
it('should remove unsupported keywords', () => {
const input = {
$schema: 'http://json-schema.org/draft-07/schema#',
$id: '#foo',
type: 'string',
default: 'bar',
dependencies: { foo: ['bar'] },
patternProperties: { '^foo': { type: 'string' } },
};
const expected = { type: 'string' };
expect(convertSchema(input, 'openapi_30')).toEqual(expected);
});
});
});

View File

@@ -1,135 +0,0 @@
/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
/**
* Utility for converting JSON Schemas to be compatible with different LLM providers.
* Specifically focuses on downgrading modern JSON Schema (Draft 7/2020-12) to
* OpenAPI 3.0 compatible Schema Objects, which is required for Google Gemini API.
*/
export type SchemaComplianceMode = 'auto' | 'openapi_30';
/**
* Converts a JSON Schema to be compatible with the specified compliance mode.
*/
export function convertSchema(
schema: Record<string, unknown>,
mode: SchemaComplianceMode = 'auto',
): Record<string, unknown> {
if (mode === 'openapi_30') {
return toOpenAPI30(schema);
}
// Default ('auto') mode now does nothing.
return schema;
}
/**
* Converts Modern JSON Schema to OpenAPI 3.0 Schema Object.
* Attempts to preserve semantics where possible through transformations.
*/
function toOpenAPI30(schema: Record<string, unknown>): Record<string, unknown> {
const convert = (obj: unknown): unknown => {
if (typeof obj !== 'object' || obj === null) {
return obj;
}
if (Array.isArray(obj)) {
return obj.map(convert);
}
const source = obj as Record<string, unknown>;
const target: Record<string, unknown> = {};
// 1. Type Handling
if (Array.isArray(source['type'])) {
const types = source['type'] as string[];
// Handle ["string", "null"] pattern common in modern schemas
if (types.length === 2 && types.includes('null')) {
target['type'] = types.find((t) => t !== 'null');
target['nullable'] = true;
} else {
// Fallback for other unions: take the first non-null type
// OpenAPI 3.0 doesn't support type arrays.
// Ideal fix would be anyOf, but simple fallback is safer for now.
target['type'] = types[0];
}
} else if (source['type'] !== undefined) {
target['type'] = source['type'];
}
// 2. Const Handling (Draft 6+) -> Enum (OpenAPI 3.0)
if (source['const'] !== undefined) {
target['enum'] = [source['const']];
delete target['const'];
}
// 3. Exclusive Limits (Draft 6+ number) -> (Draft 4 boolean)
// exclusiveMinimum: 10 -> minimum: 10, exclusiveMinimum: true
if (typeof source['exclusiveMinimum'] === 'number') {
target['minimum'] = source['exclusiveMinimum'];
target['exclusiveMinimum'] = true;
}
if (typeof source['exclusiveMaximum'] === 'number') {
target['maximum'] = source['exclusiveMaximum'];
target['exclusiveMaximum'] = true;
}
// 4. Array Items (Tuple -> Single Schema)
// OpenAPI 3.0 items must be a schema object, not an array of schemas
if (Array.isArray(source['items'])) {
// Tuple support is tricky.
// Best effort: Use the first item's schema as a generic array type
// or convert to an empty object (any type) if mixed.
// For now, we'll strip it to allow validation to pass (accepts any items)
// This matches the legacy behavior but is explicit.
// Ideally, we could use `oneOf` on the items if we wanted to be stricter.
delete target['items'];
} else if (
typeof source['items'] === 'object' &&
source['items'] !== null
) {
target['items'] = convert(source['items']);
}
// 5. Enum Stringification
// Gemini strictly requires enums to be strings
if (Array.isArray(source['enum'])) {
target['enum'] = source['enum'].map(String);
}
// 6. Recursively process other properties
for (const [key, value] of Object.entries(source)) {
// Skip fields we've already handled or want to remove
if (
key === 'type' ||
key === 'const' ||
key === 'exclusiveMinimum' ||
key === 'exclusiveMaximum' ||
key === 'items' ||
key === 'enum' ||
key === '$schema' ||
key === '$id' ||
key === 'default' || // Optional: Gemini sometimes complains about defaults conflicting with types
key === 'dependencies' ||
key === 'patternProperties'
) {
continue;
}
target[key] = convert(value);
}
// Preserve default if it doesn't conflict (simple pass-through)
// if (source['default'] !== undefined) {
// target['default'] = source['default'];
// }
return target;
};
return convert(schema) as Record<string, unknown>;
}

View File

@@ -13,8 +13,9 @@ npm install @qwen-code/sdk
## Requirements
- Node.js >= 20.0.0
- [Qwen Code](https://github.com/QwenLM/qwen-code) >= 0.4.0 (stable) installed and accessible in PATH
> From v0.1.1, the CLI is bundled with the SDK. So no standalone CLI installation is needed.
> **Note for nvm users**: If you use nvm to manage Node.js versions, the SDK may not be able to auto-detect the Qwen Code executable. You should explicitly set the `pathToQwenExecutable` option to the full path of the `qwen` binary.
## Quick Start
@@ -371,23 +372,6 @@ try {
}
```
## FAQ / Troubleshooting
### Version 0.1.0 Requirements
If you're using SDK version **0.1.0**, please note the following requirements:
#### Qwen Code Installation Required
Version 0.1.0 requires [Qwen Code](https://github.com/QwenLM/qwen-code) **>= 0.4.0** to be installed separately and accessible in your PATH.
```bash
# Install Qwen Code globally
npm install -g qwen-code@^0.4.0
```
**Note**: From version **0.1.1** onwards, the CLI is bundled with the SDK, so no separate Qwen Code installation is needed.
## License
Apache-2.0 - see [LICENSE](./LICENSE) for details.

View File

@@ -45,8 +45,7 @@
"node": ">=18.0.0"
},
"dependencies": {
"@modelcontextprotocol/sdk": "^1.0.4",
"tiktoken": "^1.0.21"
"@modelcontextprotocol/sdk": "^1.0.4"
},
"devDependencies": {
"@types/node": "^20.14.0",

View File

@@ -91,35 +91,3 @@ if (existsSync(licenseSource)) {
console.warn('Could not copy LICENSE:', error.message);
}
}
console.log('Bundling CLI into SDK package...');
const repoRoot = join(rootDir, '..', '..');
const rootDistDir = join(repoRoot, 'dist');
if (!existsSync(rootDistDir) || !existsSync(join(rootDistDir, 'cli.js'))) {
console.log('Building CLI bundle...');
try {
execSync('npm run bundle', { stdio: 'inherit', cwd: repoRoot });
} catch (error) {
console.error('Failed to build CLI bundle:', error.message);
throw error;
}
}
const cliDistDir = join(rootDir, 'dist', 'cli');
mkdirSync(cliDistDir, { recursive: true });
console.log('Copying CLI bundle...');
cpSync(join(rootDistDir, 'cli.js'), join(cliDistDir, 'cli.js'));
const vendorSource = join(rootDistDir, 'vendor');
if (existsSync(vendorSource)) {
cpSync(vendorSource, join(cliDistDir, 'vendor'), { recursive: true });
}
const localesSource = join(rootDistDir, 'locales');
if (existsSync(localesSource)) {
cpSync(localesSource, join(cliDistDir, 'locales'), { recursive: true });
}
console.log('CLI bundle copied successfully to SDK package');

View File

@@ -2,16 +2,24 @@
* CLI path auto-detection and subprocess spawning utilities
*
* Supports multiple execution modes:
* 1. Bundled CLI: Node.js bundle included in the SDK package (default)
* 2. Node.js bundle: 'node /path/to/cli.js' (custom path)
* 1. Native binary: 'qwen' (production)
* 2. Node.js bundle: 'node /path/to/cli.js' (production validation)
* 3. Bun bundle: 'bun /path/to/cli.js' (alternative runtime)
* 4. TypeScript source: 'tsx /path/to/index.ts' (development)
*
* Auto-detection locations for native binary:
* 1. QWEN_CODE_CLI_PATH environment variable
* 2. ~/.volta/bin/qwen
* 3. ~/.npm-global/bin/qwen
* 4. /usr/local/bin/qwen
* 5. ~/.local/bin/qwen
* 6. ~/node_modules/.bin/qwen
* 7. ~/.yarn/bin/qwen
*/
import * as fs from 'node:fs';
import * as path from 'node:path';
import { execSync } from 'node:child_process';
import { fileURLToPath } from 'node:url';
/**
* Executable types supported by the SDK
@@ -32,38 +40,49 @@ export type SpawnInfo = {
originalInput: string;
};
function getBundledCliPath(): string | null {
try {
const currentFile =
typeof __filename !== 'undefined'
? __filename
: fileURLToPath(import.meta.url);
const currentDir = path.dirname(currentFile);
const bundledCliPath = path.join(currentDir, 'cli', 'cli.js');
if (fs.existsSync(bundledCliPath)) {
return bundledCliPath;
}
return null;
} catch {
return null;
}
}
export function findNativeCliPath(): string {
const bundledCli = getBundledCliPath();
if (bundledCli) {
return bundledCli;
const homeDir = process.env['HOME'] || process.env['USERPROFILE'] || '';
const candidates: Array<string | undefined> = [
// 1. Environment variable (highest priority)
process.env['QWEN_CODE_CLI_PATH'],
// 2. Volta bin
path.join(homeDir, '.volta', 'bin', 'qwen'),
// 3. Global npm installations
path.join(homeDir, '.npm-global', 'bin', 'qwen'),
// 4. Common Unix binary locations
'/usr/local/bin/qwen',
// 5. User local bin
path.join(homeDir, '.local', 'bin', 'qwen'),
// 6. Node modules bin in home directory
path.join(homeDir, 'node_modules', '.bin', 'qwen'),
// 7. Yarn global bin
path.join(homeDir, '.yarn', 'bin', 'qwen'),
];
// Find first existing candidate
for (const candidate of candidates) {
if (candidate && fs.existsSync(candidate)) {
return path.resolve(candidate);
}
}
// Not found - throw helpful error
throw new Error(
'Bundled qwen CLI not found. The CLI should be included in the SDK package.\n' +
'If you need to use a custom CLI, provide explicit executable:\n' +
' query({ pathToQwenExecutable: "/path/to/cli.js" })\n' +
'qwen CLI not found. Please:\n' +
' 1. Install qwen globally: npm install -g qwen\n' +
' 2. Or provide explicit executable: query({ pathToQwenExecutable: "/path/to/qwen" })\n' +
' 3. Or set environment variable: QWEN_CODE_CLI_PATH="/path/to/qwen"\n' +
'\n' +
'For development/testing, you can also use:\n' +
' • TypeScript source: query({ pathToQwenExecutable: "/path/to/index.ts" })\n' +
' • Node.js bundle: query({ pathToQwenExecutable: "/path/to/cli.js" })\n' +
' • Force specific runtime: query({ pathToQwenExecutable: "bun:/path/to/cli.js" })',
);
}

View File

@@ -38,8 +38,6 @@ describe('CLI Path Utilities', () => {
mockFs.statSync.mockReturnValue({
isFile: () => true,
} as ReturnType<typeof import('fs').statSync>);
// Default: return true for existsSync (can be overridden in specific tests)
mockFs.existsSync.mockReturnValue(true);
});
afterEach(() => {
@@ -52,26 +50,28 @@ describe('CLI Path Utilities', () => {
describe('parseExecutableSpec', () => {
describe('auto-detection (no spec provided)', () => {
it('should auto-detect bundled CLI when no spec provided', () => {
// Mock existsSync to return true for bundled CLI
mockFs.existsSync.mockImplementation((p) => {
const pathStr = p.toString();
return (
pathStr.includes('cli/cli.js') || pathStr.includes('cli\\cli.js')
);
});
it('should auto-detect native CLI when no spec provided', () => {
// Mock environment variable
const originalEnv = process.env['QWEN_CODE_CLI_PATH'];
process.env['QWEN_CODE_CLI_PATH'] = '/usr/local/bin/qwen';
mockFs.existsSync.mockReturnValue(true);
const result = parseExecutableSpec();
expect(result.executablePath).toContain('cli.js');
expect(result.isExplicitRuntime).toBe(false);
expect(result).toEqual({
executablePath: path.resolve('/usr/local/bin/qwen'),
isExplicitRuntime: false,
});
// Restore env
process.env['QWEN_CODE_CLI_PATH'] = originalEnv;
});
it('should throw when bundled CLI not found', () => {
it('should throw when auto-detection fails', () => {
mockFs.existsSync.mockReturnValue(false);
expect(() => parseExecutableSpec()).toThrow(
'Bundled qwen CLI not found',
'qwen CLI not found. Please:',
);
});
});
@@ -361,44 +361,65 @@ describe('CLI Path Utilities', () => {
});
describe('auto-detection fallback', () => {
it('should auto-detect bundled CLI when no spec provided', () => {
// Mock existsSync to return true for bundled CLI
mockFs.existsSync.mockImplementation((p) => {
const pathStr = p.toString();
return (
pathStr.includes('cli/cli.js') || pathStr.includes('cli\\cli.js')
);
});
it('should auto-detect when no spec provided', () => {
// Mock environment variable
const originalEnv = process.env['QWEN_CODE_CLI_PATH'];
process.env['QWEN_CODE_CLI_PATH'] = '/usr/local/bin/qwen';
const result = prepareSpawnInfo();
expect(result.command).toBe(process.execPath);
expect(result.args[0]).toContain('cli.js');
expect(result.type).toBe('node');
expect(result.originalInput).toBe('');
expect(result).toEqual({
command: path.resolve('/usr/local/bin/qwen'),
args: [],
type: 'native',
originalInput: '',
});
// Restore env
process.env['QWEN_CODE_CLI_PATH'] = originalEnv;
});
});
});
describe('findNativeCliPath', () => {
it('should find bundled CLI', () => {
// Mock existsSync to return true for bundled CLI
it('should find CLI from environment variable', () => {
const originalEnv = process.env['QWEN_CODE_CLI_PATH'];
process.env['QWEN_CODE_CLI_PATH'] = '/custom/path/to/qwen';
mockFs.existsSync.mockReturnValue(true);
const result = findNativeCliPath();
expect(result).toBe(path.resolve('/custom/path/to/qwen'));
process.env['QWEN_CODE_CLI_PATH'] = originalEnv;
});
it('should search common installation locations', () => {
const originalEnv = process.env['QWEN_CODE_CLI_PATH'];
delete process.env['QWEN_CODE_CLI_PATH'];
// Mock fs.existsSync to return true for volta bin
// Use path.join to match platform-specific path separators
const voltaBinPath = path.join('.volta', 'bin', 'qwen');
mockFs.existsSync.mockImplementation((p) => {
const pathStr = p.toString();
return (
pathStr.includes('cli/cli.js') || pathStr.includes('cli\\cli.js')
);
return p.toString().includes(voltaBinPath);
});
const result = findNativeCliPath();
expect(result).toContain('cli.js');
expect(result).toContain(voltaBinPath);
process.env['QWEN_CODE_CLI_PATH'] = originalEnv;
});
it('should throw descriptive error when bundled CLI not found', () => {
it('should throw descriptive error when CLI not found', () => {
const originalEnv = process.env['QWEN_CODE_CLI_PATH'];
delete process.env['QWEN_CODE_CLI_PATH'];
mockFs.existsSync.mockReturnValue(false);
expect(() => findNativeCliPath()).toThrow('Bundled qwen CLI not found');
expect(() => findNativeCliPath()).toThrow('qwen CLI not found. Please:');
process.env['QWEN_CODE_CLI_PATH'] = originalEnv;
});
});
@@ -613,10 +634,13 @@ describe('CLI Path Utilities', () => {
mockFs.existsSync.mockReturnValue(false);
expect(() => parseExecutableSpec('/missing/file')).toThrow(
'Executable file not found at',
'Set QWEN_CODE_CLI_PATH environment variable',
);
expect(() => parseExecutableSpec('/missing/file')).toThrow(
'Please check the file path and ensure the file exists',
'Install qwen globally: npm install -g qwen',
);
expect(() => parseExecutableSpec('/missing/file')).toThrow(
'Force specific runtime: bun:/path/to/cli.js or tsx:/path/to/index.ts',
);
});
});

View File

@@ -11,7 +11,7 @@
"directory": "packages/vscode-ide-companion"
},
"engines": {
"vscode": "^1.85.0"
"vscode": "^1.99.0"
},
"license": "LICENSE",
"preview": true,
@@ -137,7 +137,7 @@
"@types/react": "^19.1.8",
"@types/react-dom": "^19.1.6",
"@types/semver": "^7.7.1",
"@types/vscode": "^1.85.0",
"@types/vscode": "^1.99.0",
"@typescript-eslint/eslint-plugin": "^8.31.1",
"@typescript-eslint/parser": "^8.31.1",
"@vscode/vsce": "^3.6.0",

View File

@@ -35,7 +35,7 @@ function npmBin() {
function run(cmd, args, opts = {}) {
const res = spawnSync(cmd, args, {
stdio: 'inherit',
shell: process.platform === 'win32',
shell: process.platform === 'win32' ? true : false,
...opts,
});
if (res.error) {

View File

@@ -27,14 +27,13 @@ vi.mock('node:fs/promises', () => ({
writeFile: vi.fn(() => Promise.resolve(undefined)),
unlink: vi.fn(() => Promise.resolve(undefined)),
chmod: vi.fn(() => Promise.resolve(undefined)),
mkdir: vi.fn(() => Promise.resolve(undefined)),
}));
vi.mock('node:os', async (importOriginal) => {
const actual = await importOriginal<typeof os>();
return {
...actual,
homedir: vi.fn(() => '/home/test'),
tmpdir: vi.fn(() => '/tmp'),
};
});
@@ -129,24 +128,30 @@ describe('IDEServer', () => {
);
const port = getPortFromMock(replaceMock);
const expectedLockFile = path.join(
'/home/test',
'.qwen',
'ide',
`${port}.lock`,
const expectedPortFile = path.join(
'/tmp',
`qwen-code-ide-server-${port}.json`,
);
const expectedPpidPortFile = path.join(
'/tmp',
`qwen-code-ide-server-${process.ppid}.json`,
);
const expectedContent = JSON.stringify({
port: parseInt(port, 10),
workspacePath: expectedWorkspacePaths,
ppid: process.ppid,
authToken: 'test-auth-token',
ideName: 'VS Code',
});
expect(fs.writeFile).toHaveBeenCalledWith(
expectedLockFile,
expectedPortFile,
expectedContent,
);
expect(fs.chmod).toHaveBeenCalledWith(expectedLockFile, 0o600);
expect(fs.writeFile).toHaveBeenCalledWith(
expectedPpidPortFile,
expectedContent,
);
expect(fs.chmod).toHaveBeenCalledWith(expectedPortFile, 0o600);
expect(fs.chmod).toHaveBeenCalledWith(expectedPpidPortFile, 0o600);
});
it('should set a single folder path', async () => {
@@ -161,24 +166,30 @@ describe('IDEServer', () => {
);
const port = getPortFromMock(replaceMock);
const expectedLockFile = path.join(
'/home/test',
'.qwen',
'ide',
`${port}.lock`,
const expectedPortFile = path.join(
'/tmp',
`qwen-code-ide-server-${port}.json`,
);
const expectedPpidPortFile = path.join(
'/tmp',
`qwen-code-ide-server-${process.ppid}.json`,
);
const expectedContent = JSON.stringify({
port: parseInt(port, 10),
workspacePath: '/foo/bar',
ppid: process.ppid,
authToken: 'test-auth-token',
ideName: 'VS Code',
});
expect(fs.writeFile).toHaveBeenCalledWith(
expectedLockFile,
expectedPortFile,
expectedContent,
);
expect(fs.chmod).toHaveBeenCalledWith(expectedLockFile, 0o600);
expect(fs.writeFile).toHaveBeenCalledWith(
expectedPpidPortFile,
expectedContent,
);
expect(fs.chmod).toHaveBeenCalledWith(expectedPortFile, 0o600);
expect(fs.chmod).toHaveBeenCalledWith(expectedPpidPortFile, 0o600);
});
it('should set an empty string if no folders are open', async () => {
@@ -193,24 +204,30 @@ describe('IDEServer', () => {
);
const port = getPortFromMock(replaceMock);
const expectedLockFile = path.join(
'/home/test',
'.qwen',
'ide',
`${port}.lock`,
const expectedPortFile = path.join(
'/tmp',
`qwen-code-ide-server-${port}.json`,
);
const expectedPpidPortFile = path.join(
'/tmp',
`qwen-code-ide-server-${process.ppid}.json`,
);
const expectedContent = JSON.stringify({
port: parseInt(port, 10),
workspacePath: '',
ppid: process.ppid,
authToken: 'test-auth-token',
ideName: 'VS Code',
});
expect(fs.writeFile).toHaveBeenCalledWith(
expectedLockFile,
expectedPortFile,
expectedContent,
);
expect(fs.chmod).toHaveBeenCalledWith(expectedLockFile, 0o600);
expect(fs.writeFile).toHaveBeenCalledWith(
expectedPpidPortFile,
expectedContent,
);
expect(fs.chmod).toHaveBeenCalledWith(expectedPortFile, 0o600);
expect(fs.chmod).toHaveBeenCalledWith(expectedPpidPortFile, 0o600);
});
it('should update the path when workspace folders change', async () => {
@@ -239,24 +256,30 @@ describe('IDEServer', () => {
);
const port = getPortFromMock(replaceMock);
const expectedLockFile = path.join(
'/home/test',
'.qwen',
'ide',
`${port}.lock`,
const expectedPortFile = path.join(
'/tmp',
`qwen-code-ide-server-${port}.json`,
);
const expectedPpidPortFile = path.join(
'/tmp',
`qwen-code-ide-server-${process.ppid}.json`,
);
const expectedContent = JSON.stringify({
port: parseInt(port, 10),
workspacePath: expectedWorkspacePaths,
ppid: process.ppid,
authToken: 'test-auth-token',
ideName: 'VS Code',
});
expect(fs.writeFile).toHaveBeenCalledWith(
expectedLockFile,
expectedPortFile,
expectedContent,
);
expect(fs.chmod).toHaveBeenCalledWith(expectedLockFile, 0o600);
expect(fs.writeFile).toHaveBeenCalledWith(
expectedPpidPortFile,
expectedContent,
);
expect(fs.chmod).toHaveBeenCalledWith(expectedPortFile, 0o600);
expect(fs.chmod).toHaveBeenCalledWith(expectedPpidPortFile, 0o600);
// Simulate removing a folder
vscodeMock.workspace.workspaceFolders = [{ uri: { fsPath: '/baz/qux' } }];
@@ -271,26 +294,36 @@ describe('IDEServer', () => {
workspacePath: '/baz/qux',
ppid: process.ppid,
authToken: 'test-auth-token',
ideName: 'VS Code',
});
expect(fs.writeFile).toHaveBeenCalledWith(
expectedLockFile,
expectedPortFile,
expectedContent2,
);
expect(fs.chmod).toHaveBeenCalledWith(expectedLockFile, 0o600);
expect(fs.writeFile).toHaveBeenCalledWith(
expectedPpidPortFile,
expectedContent2,
);
expect(fs.chmod).toHaveBeenCalledWith(expectedPortFile, 0o600);
expect(fs.chmod).toHaveBeenCalledWith(expectedPpidPortFile, 0o600);
});
it('should clear env vars and delete lock file on stop', async () => {
it('should clear env vars and delete port file on stop', async () => {
await ideServer.start(mockContext);
const replaceMock = mockContext.environmentVariableCollection.replace;
const port = getPortFromMock(replaceMock);
const lockFile = path.join('/home/test', '.qwen', 'ide', `${port}.lock`);
expect(fs.writeFile).toHaveBeenCalledWith(lockFile, expect.any(String));
const portFile = path.join('/tmp', `qwen-code-ide-server-${port}.json`);
const ppidPortFile = path.join(
'/tmp',
`qwen-code-ide-server-${process.ppid}.json`,
);
expect(fs.writeFile).toHaveBeenCalledWith(portFile, expect.any(String));
expect(fs.writeFile).toHaveBeenCalledWith(ppidPortFile, expect.any(String));
await ideServer.stop();
expect(mockContext.environmentVariableCollection.clear).toHaveBeenCalled();
expect(fs.unlink).toHaveBeenCalledWith(lockFile);
expect(fs.unlink).toHaveBeenCalledWith(portFile);
expect(fs.unlink).toHaveBeenCalledWith(ppidPortFile);
});
it.skipIf(process.platform !== 'win32')(
@@ -311,24 +344,30 @@ describe('IDEServer', () => {
);
const port = getPortFromMock(replaceMock);
const expectedLockFile = path.join(
'/home/test',
'.qwen',
'ide',
`${port}.lock`,
const expectedPortFile = path.join(
'/tmp',
`qwen-code-ide-server-${port}.json`,
);
const expectedPpidPortFile = path.join(
'/tmp',
`qwen-code-ide-server-${process.ppid}.json`,
);
const expectedContent = JSON.stringify({
port: parseInt(port, 10),
workspacePath: expectedWorkspacePaths,
ppid: process.ppid,
authToken: 'test-auth-token',
ideName: 'VS Code',
});
expect(fs.writeFile).toHaveBeenCalledWith(
expectedLockFile,
expectedPortFile,
expectedContent,
);
expect(fs.chmod).toHaveBeenCalledWith(expectedLockFile, 0o600);
expect(fs.writeFile).toHaveBeenCalledWith(
expectedPpidPortFile,
expectedContent,
);
expect(fs.chmod).toHaveBeenCalledWith(expectedPortFile, 0o600);
expect(fs.chmod).toHaveBeenCalledWith(expectedPpidPortFile, 0o600);
},
);
@@ -340,7 +379,7 @@ describe('IDEServer', () => {
port = (ideServer as unknown as { port: number }).port;
});
it('should reject request without auth token', async () => {
it('should allow request without auth token for backwards compatibility', async () => {
const response = await fetch(`http://localhost:${port}/mcp`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
@@ -351,9 +390,7 @@ describe('IDEServer', () => {
id: 1,
}),
});
expect(response.status).toBe(401);
const body = await response.text();
expect(body).toBe('Unauthorized');
expect(response.status).not.toBe(401);
});
it('should allow request with valid auth token', async () => {
@@ -513,7 +550,6 @@ describe('IDEServer HTTP endpoints', () => {
headers: {
Host: `localhost:${port}`,
'Content-Type': 'application/json',
Authorization: 'Bearer test-auth-token',
},
},
JSON.stringify({ jsonrpc: '2.0', method: 'initialize' }),

View File

@@ -10,7 +10,6 @@ import {
IdeContextNotificationSchema,
OpenDiffRequestSchema,
} from '@qwen-code/qwen-code-core/src/ide/types.js';
import { detectIdeFromEnv } from '@qwen-code/qwen-code-core/src/ide/detect-ide.js';
import { isInitializeRequest } from '@modelcontextprotocol/sdk/types.js';
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
import { StreamableHTTPServerTransport } from '@modelcontextprotocol/sdk/server/streamableHttp.js';
@@ -39,24 +38,12 @@ class CORSError extends Error {
const MCP_SESSION_ID_HEADER = 'mcp-session-id';
const IDE_SERVER_PORT_ENV_VAR = 'QWEN_CODE_IDE_SERVER_PORT';
const IDE_WORKSPACE_PATH_ENV_VAR = 'QWEN_CODE_IDE_WORKSPACE_PATH';
const QWEN_DIR = '.qwen';
const IDE_DIR = 'ide';
async function getGlobalIdeDir(): Promise<string> {
const homeDir = os.homedir();
// Prefer home dir, but fall back to tmpdir if unavailable (matches core Storage behavior).
const baseDir = homeDir
? path.join(homeDir, QWEN_DIR)
: path.join(os.tmpdir(), QWEN_DIR);
const ideDir = path.join(baseDir, IDE_DIR);
await fs.mkdir(ideDir, { recursive: true });
return ideDir;
}
interface WritePortAndWorkspaceArgs {
context: vscode.ExtensionContext;
port: number;
lockFile: string;
portFile: string;
ppidPortFile: string;
authToken: string;
log: (message: string) => void;
}
@@ -64,7 +51,8 @@ interface WritePortAndWorkspaceArgs {
async function writePortAndWorkspace({
context,
port,
lockFile,
portFile,
ppidPortFile,
authToken,
log,
}: WritePortAndWorkspaceArgs): Promise<void> {
@@ -83,24 +71,26 @@ async function writePortAndWorkspace({
workspacePath,
);
const ideInfo = detectIdeFromEnv();
const content = JSON.stringify({
port,
workspacePath,
ppid: process.ppid,
authToken,
ideName: ideInfo.displayName,
});
log(`Writing IDE lock file to: ${lockFile}`);
log(`Writing port file to: ${portFile}`);
log(`Writing ppid port file to: ${ppidPortFile}`);
try {
await fs.mkdir(path.dirname(lockFile), { recursive: true });
await fs.writeFile(lockFile, content);
await fs.chmod(lockFile, 0o600);
await Promise.all([
fs.writeFile(portFile, content).then(() => fs.chmod(portFile, 0o600)),
fs
.writeFile(ppidPortFile, content)
.then(() => fs.chmod(ppidPortFile, 0o600)),
]);
} catch (err) {
const message = err instanceof Error ? err.message : String(err);
log(`Failed to write IDE lock file: ${message}`);
log(`Failed to write port to file: ${message}`);
}
}
@@ -131,7 +121,8 @@ export class IDEServer {
private server: HTTPServer | undefined;
private context: vscode.ExtensionContext | undefined;
private log: (message: string) => void;
private lockFile: string | undefined;
private portFile: string | undefined;
private ppidPortFile: string | undefined;
private port: number | undefined;
private authToken: string | undefined;
private transports: { [sessionId: string]: StreamableHTTPServerTransport } =
@@ -183,24 +174,19 @@ export class IDEServer {
app.use((req, res, next) => {
const authHeader = req.headers.authorization;
if (!authHeader) {
this.log('Missing Authorization header. Rejecting request.');
res.status(401).send('Unauthorized');
return;
}
const parts = authHeader.split(' ');
if (parts.length !== 2 || parts[0] !== 'Bearer') {
this.log('Malformed Authorization header. Rejecting request.');
res.status(401).send('Unauthorized');
return;
}
const token = parts[1];
if (token !== this.authToken) {
this.log('Invalid auth token provided. Rejecting request.');
res.status(401).send('Unauthorized');
return;
if (authHeader) {
const parts = authHeader.split(' ');
if (parts.length !== 2 || parts[0] !== 'Bearer') {
this.log('Malformed Authorization header. Rejecting request.');
res.status(401).send('Unauthorized');
return;
}
const token = parts[1];
if (token !== this.authToken) {
this.log('Invalid auth token provided. Rejecting request.');
res.status(401).send('Unauthorized');
return;
}
}
next();
});
@@ -341,21 +327,22 @@ export class IDEServer {
const address = (this.server as HTTPServer).address();
if (address && typeof address !== 'string') {
this.port = address.port;
try {
const ideDir = await getGlobalIdeDir();
// Name the lock file by port to support multiple server instances.
this.lockFile = path.join(ideDir, `${this.port}.lock`);
} catch (err) {
const message = err instanceof Error ? err.message : String(err);
this.log(`Failed to determine IDE lock directory: ${message}`);
}
this.portFile = path.join(
os.tmpdir(),
`qwen-code-ide-server-${this.port}.json`,
);
this.ppidPortFile = path.join(
os.tmpdir(),
`qwen-code-ide-server-${process.ppid}.json`,
);
this.log(`IDE server listening on http://127.0.0.1:${this.port}`);
if (this.authToken && this.lockFile) {
if (this.authToken) {
await writePortAndWorkspace({
context,
port: this.port,
lockFile: this.lockFile,
portFile: this.portFile,
ppidPortFile: this.ppidPortFile,
authToken: this.authToken,
log: this.log,
});
@@ -384,13 +371,15 @@ export class IDEServer {
this.context &&
this.server &&
this.port &&
this.lockFile &&
this.portFile &&
this.ppidPortFile &&
this.authToken
) {
await writePortAndWorkspace({
context: this.context,
port: this.port,
lockFile: this.lockFile,
portFile: this.portFile,
ppidPortFile: this.ppidPortFile,
authToken: this.authToken,
log: this.log,
});
@@ -416,9 +405,16 @@ export class IDEServer {
if (this.context) {
this.context.environmentVariableCollection.clear();
}
if (this.lockFile) {
if (this.portFile) {
try {
await fs.unlink(this.lockFile);
await fs.unlink(this.portFile);
} catch (_err) {
// Ignore errors if the file doesn't exist.
}
}
if (this.ppidPortFile) {
try {
await fs.unlink(this.ppidPortFile);
} catch (_err) {
// Ignore errors if the file doesn't exist.
}

View File

@@ -54,31 +54,27 @@ export class AcpSessionManager {
};
return new Promise((resolve, reject) => {
// No timeout for session_prompt as LLM tasks can take 5-10 minutes or longer
// The request should always terminate with a stop_reason
let timeoutId: NodeJS.Timeout | undefined;
let timeoutDuration: number | undefined;
if (method !== AGENT_METHODS.session_prompt) {
// Set timeout for other methods
timeoutDuration = method === AGENT_METHODS.initialize ? 120000 : 60000;
timeoutId = setTimeout(() => {
pendingRequests.delete(id);
reject(new Error(`Request ${method} timed out`));
}, timeoutDuration);
// different timeout durations based on methods
let timeoutDuration = 60000; // default 60 seconds
if (
method === AGENT_METHODS.session_prompt ||
method === AGENT_METHODS.initialize
) {
timeoutDuration = 120000; // 2min for session_prompt and initialize
}
const timeoutId = setTimeout(() => {
pendingRequests.delete(id);
reject(new Error(`Request ${method} timed out`));
}, timeoutDuration);
const pendingRequest: PendingRequest<T> = {
resolve: (value: T) => {
if (timeoutId) {
clearTimeout(timeoutId);
}
clearTimeout(timeoutId);
resolve(value);
},
reject: (error: Error) => {
if (timeoutId) {
clearTimeout(timeoutId);
}
clearTimeout(timeoutId);
reject(error);
},
timeoutId,

View File

@@ -144,7 +144,10 @@ export const InputForm: React.FC<InputFormProps> = ({
: '';
return (
<div className="p-1 px-4 pb-4 absolute bottom-0 left-0 right-0 bg-gradient-to-b from-transparent to-[var(--app-primary-background)]">
<div
className="p-1 px-4 pb-4 absolute bottom-0 left-0 right-0"
style={{ backgroundColor: 'var(--app-primary-background)' }}
>
<div className="block">
<form className="composer-form" onSubmit={onSubmit}>
{/* Inner background layer */}

View File

@@ -48,5 +48,5 @@
}
.assistant-message-container.assistant-message-loading::after {
display: none;
display: none
}

View File

@@ -172,8 +172,7 @@
/* Loading animation for toolcall header */
@keyframes toolcallHeaderPulse {
0%,
100% {
0%, 100% {
opacity: 1;
}
50% {

View File

@@ -152,24 +152,6 @@ export class SessionMessageHandler extends BaseMessageHandler {
this.currentStreamContent = '';
}
/**
* Notify the webview that streaming has finished.
*/
private sendStreamEnd(reason?: string): void {
const data: { timestamp: number; reason?: string } = {
timestamp: Date.now(),
};
if (reason) {
data.reason = reason;
}
this.sendToWebView({
type: 'streamEnd',
data,
});
}
/**
* Prompt user to login and invoke the registered login handler/command.
* Returns true if a login was initiated.
@@ -391,7 +373,10 @@ export class SessionMessageHandler extends BaseMessageHandler {
);
}
this.sendStreamEnd();
this.sendToWebView({
type: 'streamEnd',
data: { timestamp: Date.now() },
});
} catch (error) {
console.error('[SessionMessageHandler] Error sending message:', error);
@@ -413,7 +398,10 @@ export class SessionMessageHandler extends BaseMessageHandler {
if (isAbortLike) {
// Do not show VS Code error popup for intentional cancellations.
// Ensure the webview knows the stream ended due to user action.
this.sendStreamEnd('user_cancelled');
this.sendToWebView({
type: 'streamEnd',
data: { timestamp: Date.now(), reason: 'user_cancelled' },
});
return;
}
// Check for session not found error and handle it appropriately
@@ -435,39 +423,12 @@ export class SessionMessageHandler extends BaseMessageHandler {
type: 'sessionExpired',
data: { message: 'Session expired. Please login again.' },
});
this.sendStreamEnd('session_expired');
} else {
const isTimeoutError =
lower.includes('timeout') || lower.includes('timed out');
if (isTimeoutError) {
// Note: session_prompt no longer has a timeout, so this should rarely occur
// This path may still be hit for other methods (initialize, etc.) or network-level timeouts
console.warn(
'[SessionMessageHandler] Request timed out; suppressing popup',
);
const timeoutMessage: ChatMessage = {
role: 'assistant',
content:
'Request timed out. This may be due to a network issue. Please try again.',
timestamp: Date.now(),
};
// Send a timeout message to the WebView
this.sendToWebView({
type: 'message',
data: timeoutMessage,
});
this.sendStreamEnd('timeout');
} else {
// Handling of Non-Timeout Errors
vscode.window.showErrorMessage(`Error sending message: ${error}`);
this.sendToWebView({
type: 'error',
data: { message: errorMsg },
});
this.sendStreamEnd('error');
}
vscode.window.showErrorMessage(`Error sending message: ${error}`);
this.sendToWebView({
type: 'error',
data: { message: errorMsg },
});
}
}
}

View File

@@ -15,14 +15,6 @@ import type { ToolCallUpdate } from '../../types/chatTypes.js';
import type { ApprovalModeValue } from '../../types/approvalModeValueTypes.js';
import type { PlanEntry } from '../../types/chatTypes.js';
const FORCE_CLEAR_STREAM_END_REASONS = new Set([
'user_cancelled',
'cancelled',
'timeout',
'error',
'session_expired',
]);
interface UseWebViewMessagesProps {
// Session management
sessionManagement: {
@@ -372,12 +364,12 @@ export const useWebViewMessages = ({
).toLowerCase();
/**
* Handle different types of stream end reasons that require a full reset:
* - 'user_cancelled' / 'cancelled': user explicitly cancelled
* - 'timeout' / 'error' / 'session_expired': request failed unexpectedly
* For these cases, immediately clear all active states.
* Handle different types of stream end reasons:
* - 'user_cancelled': User explicitly cancelled operation
* - 'cancelled': General cancellation
* For these cases, immediately clear all active states
*/
if (FORCE_CLEAR_STREAM_END_REASONS.has(reason)) {
if (reason === 'user_cancelled' || reason === 'cancelled') {
// Clear active execution tool call tracking, reset state
activeExecToolCallsRef.current.clear();
// Clear waiting response state to ensure UI returns to normal
@@ -401,9 +393,6 @@ export const useWebViewMessages = ({
}
case 'error':
handlers.messageHandling.endStreaming();
handlers.messageHandling.clearThinking();
activeExecToolCallsRef.current.clear();
handlers.messageHandling.clearWaitingForResponse();
break;

View File

@@ -43,7 +43,7 @@
/* Composer: form wrapper */
.composer-form {
@apply relative flex flex-col max-w-[680px] mx-auto rounded-large border shadow-sm transition-colors duration-200 z-[1];
@apply relative flex flex-col max-w-[680px] mx-auto rounded-large border shadow-sm transition-colors duration-200;
background: var(--app-input-secondary-background);
border-color: var(--app-input-border);
color: var(--app-input-foreground);
@@ -51,8 +51,7 @@
.composer-form:focus-within {
/* match existing highlight behavior */
border-color: var(--app-input-highlight);
box-shadow: 0 1px 2px
color-mix(in srgb, var(--app-input-highlight), transparent 80%);
box-shadow: 0 1px 2px color-mix(in srgb, var(--app-input-highlight), transparent 80%);
}
/* Composer: input editable area */
@@ -67,7 +66,7 @@
The data attribute is needed because some browsers insert a <br> in
contentEditable, which breaks :empty matching. */
.composer-input:empty:before,
.composer-input[data-empty='true']::before {
.composer-input[data-empty="true"]::before {
content: attr(data-placeholder);
color: var(--app-input-placeholder-foreground);
pointer-events: none;
@@ -81,7 +80,7 @@
outline: none;
}
.composer-input:disabled,
.composer-input[contenteditable='false'] {
.composer-input[contenteditable="false"] {
color: #999;
cursor: not-allowed;
}

View File

@@ -67,7 +67,7 @@ const env = {
if (process.env.DEBUG) {
// If this is not set, the debugger will pause on the outer process rather
// than the relaunched process making it harder to debug.
env.QWEN_CODE_NO_RELAUNCH = 'true';
env.GEMINI_CLI_NO_RELAUNCH = 'true';
}
// Use process.cwd() to inherit the working directory from launch.json cwd setting
// This allows debugging from a specific directory (e.g., .todo)