mirror of
https://github.com/QwenLM/qwen-code.git
synced 2026-01-08 09:59:14 +00:00
Compare commits
58 Commits
docs-fix
...
mingholy/c
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
fa7d857945 | ||
|
|
90489933fd | ||
|
|
3354b56a05 | ||
|
|
d40447cee4 | ||
|
|
ba87cf63f6 | ||
|
|
00a8c6a924 | ||
|
|
156134d3d4 | ||
|
|
b720209888 | ||
|
|
dfe667c364 | ||
|
|
d07ae35c90 | ||
|
|
d464f61b72 | ||
|
|
f866f7f071 | ||
|
|
8106a6b0f4 | ||
|
|
c0839dceac | ||
|
|
12f84fb730 | ||
|
|
e274b4469a | ||
|
|
0a39c91264 | ||
|
|
8fd7490d8f | ||
|
|
4f1766e19a | ||
|
|
bf52c6db0f | ||
|
|
9267677d38 | ||
|
|
fb8412a96a | ||
|
|
2837aa6b7c | ||
|
|
25d9c4f1a7 | ||
|
|
9942b2b877 | ||
|
|
850c52dc79 | ||
|
|
61e378644e | ||
|
|
bd3bdd82ea | ||
|
|
fc58291c5c | ||
|
|
633148b257 | ||
|
|
573c33f68a | ||
|
|
725843f9b3 | ||
|
|
07fb6faf5f | ||
|
|
1956507d90 | ||
|
|
54fd63f04b | ||
|
|
5d94763581 | ||
|
|
5bd1822b7d | ||
|
|
65392a057d | ||
|
|
3b9d38a325 | ||
|
|
4930a24d07 | ||
|
|
7a97fcd5f1 | ||
|
|
4504c7a0ac | ||
|
|
56a62bcb2a | ||
|
|
1098c23b26 | ||
|
|
e76f47512c | ||
|
|
f5c868702b | ||
|
|
0d40cf2213 | ||
|
|
12877ac849 | ||
|
|
2de50ae436 | ||
|
|
a761be80a5 | ||
|
|
6c77303172 | ||
|
|
44794121a8 | ||
|
|
bf905dcc17 | ||
|
|
95d3e5b744 | ||
|
|
6d3cf4cd98 | ||
|
|
68295d0bbf | ||
|
|
84cccfe99a | ||
|
|
b6a3ab11e0 |
12
.github/workflows/release-sdk.yml
vendored
12
.github/workflows/release-sdk.yml
vendored
@@ -121,6 +121,11 @@ jobs:
|
|||||||
IS_PREVIEW: '${{ steps.vars.outputs.is_preview }}'
|
IS_PREVIEW: '${{ steps.vars.outputs.is_preview }}'
|
||||||
MANUAL_VERSION: '${{ inputs.version }}'
|
MANUAL_VERSION: '${{ inputs.version }}'
|
||||||
|
|
||||||
|
- name: 'Build CLI Bundle'
|
||||||
|
run: |
|
||||||
|
npm run build
|
||||||
|
npm run bundle
|
||||||
|
|
||||||
- name: 'Run Tests'
|
- name: 'Run Tests'
|
||||||
if: |-
|
if: |-
|
||||||
${{ github.event.inputs.force_skip_tests != 'true' }}
|
${{ github.event.inputs.force_skip_tests != 'true' }}
|
||||||
@@ -132,13 +137,6 @@ jobs:
|
|||||||
OPENAI_BASE_URL: '${{ secrets.OPENAI_BASE_URL }}'
|
OPENAI_BASE_URL: '${{ secrets.OPENAI_BASE_URL }}'
|
||||||
OPENAI_MODEL: '${{ secrets.OPENAI_MODEL }}'
|
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'
|
- name: 'Run SDK Integration Tests'
|
||||||
if: |-
|
if: |-
|
||||||
${{ github.event.inputs.force_skip_tests != 'true' }}
|
${{ github.event.inputs.force_skip_tests != 'true' }}
|
||||||
|
|||||||
4
.github/workflows/release.yml
vendored
4
.github/workflows/release.yml
vendored
@@ -133,8 +133,8 @@ jobs:
|
|||||||
${{ github.event.inputs.force_skip_tests != 'true' }}
|
${{ github.event.inputs.force_skip_tests != 'true' }}
|
||||||
run: |
|
run: |
|
||||||
npm run preflight
|
npm run preflight
|
||||||
npm run test:integration:sandbox:none
|
npm run test:integration:cli:sandbox:none
|
||||||
npm run test:integration:sandbox:docker
|
npm run test:integration:cli:sandbox:docker
|
||||||
env:
|
env:
|
||||||
OPENAI_API_KEY: '${{ secrets.OPENAI_API_KEY }}'
|
OPENAI_API_KEY: '${{ secrets.OPENAI_API_KEY }}'
|
||||||
OPENAI_BASE_URL: '${{ secrets.OPENAI_BASE_URL }}'
|
OPENAI_BASE_URL: '${{ secrets.OPENAI_BASE_URL }}'
|
||||||
|
|||||||
359
README.md
359
README.md
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
<div align="center">
|
<div align="center">
|
||||||
|
|
||||||

|

|
||||||
|
|
||||||
[](https://www.npmjs.com/package/@qwen-code/qwen-code)
|
[](https://www.npmjs.com/package/@qwen-code/qwen-code)
|
||||||
[](./LICENSE)
|
[](./LICENSE)
|
||||||
@@ -11,36 +11,52 @@
|
|||||||
|
|
||||||
**AI-powered command-line workflow tool for developers**
|
**AI-powered command-line workflow tool for developers**
|
||||||
|
|
||||||
<a href="https://qwenlm.github.io/qwen-code-docs/zh/">中文</a> |
|
[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/de/">Deutsch</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/fr">français</a> |
|
||||||
<a href="https://qwenlm.github.io/qwen-code-docs/ja/">日本語</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/ru">Русский</a> |
|
||||||
|
<a href="https://qwenlm.github.io/qwen-code-docs/zh/">中文</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>
|
</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.
|
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:
|
||||||
|
|
||||||
## 📌 Why Qwen Code?
|
### 🔥 Qwen OAuth (Recommended)
|
||||||
|
|
||||||
- 🎯 Free Access Available: Get started with 2,000 free requests per day via Qwen OAuth.
|
- **2,000 requests per day** with no token limits
|
||||||
- 🧠 Code Understanding & Editing - Query and edit large codebases beyond traditional context window limits
|
- **60 requests per minute** rate limit
|
||||||
- 🤖 Workflow Automation - Automate operational tasks like handling pull requests and complex rebases
|
- Simply run `qwen` and authenticate with your qwen.ai account
|
||||||
- 💻 Terminal-first: Designed for developers who live in the command line.
|
- Automatic credential management and refresh
|
||||||
- 🧰 VS Code: Install the VS Code extension to seamlessly integrate into your existing workflow.
|
- Use `/auth` command to switch to Qwen OAuth if you have initialized with OpenAI compatible mode
|
||||||
- 📦 Simple Setup: Easy installation with npm, Homebrew, or source for quick deployment.
|
|
||||||
|
|
||||||
>👉 Know more [workflows](https://qwenlm.github.io/qwen-code-docs/en/users/common-workflow/)
|
### 🌏 Regional Free Tiers
|
||||||
>
|
|
||||||
> 📦 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
|
||||||
|
|
||||||
## ❓ How to use Qwen Code?
|
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
|
||||||
|
|
||||||
### Prerequisites
|
### Prerequisites
|
||||||
|
|
||||||
@@ -54,57 +70,313 @@ curl -qL https://www.npmjs.com/install.sh | sh
|
|||||||
|
|
||||||
```bash
|
```bash
|
||||||
npm install -g @qwen-code/qwen-code@latest
|
npm install -g @qwen-code/qwen-code@latest
|
||||||
|
qwen --version
|
||||||
```
|
```
|
||||||
|
|
||||||
## 🚀 Quick Start
|
### 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
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# Start Qwen Code
|
# Start Qwen Code
|
||||||
qwen
|
qwen
|
||||||
|
|
||||||
# Example commands
|
# Example commands
|
||||||
> What does this project do?
|
|
||||||
> Explain this codebase structure
|
> Explain this codebase structure
|
||||||
> Help me refactor this function
|
> Help me refactor this function
|
||||||
> Generate unit tests for this module
|
> Generate unit tests for this module
|
||||||
```
|
```
|
||||||
|
|
||||||
👇 Click to play video
|
### Session Management
|
||||||
|
|
||||||
[](https://cloud.video.taobao.com/vod/HLfyppnCHplRV9Qhz2xSqeazHeRzYtG-EYJnHAqtzkQ.mp4)
|
Control your token usage with configurable session limits to optimize costs and performance.
|
||||||
|
|
||||||
|
#### 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
|
## Usage Examples
|
||||||
|
|
||||||
### 1️⃣ Interactive Mode
|
### 🔍 Explore Codebases
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
cd your-project/
|
cd your-project/
|
||||||
qwen
|
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
|
||||||
```
|
```
|
||||||
|
|
||||||
Navigate to your project folder and type `qwen` to launch Qwen Code. Start a conversation and use `@` to reference files within the folder.
|
### 💻 Code Development
|
||||||
|
|
||||||
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
|
```bash
|
||||||
cd your-project/
|
# Refactoring
|
||||||
qwen -p "your question"
|
> 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
|
||||||
```
|
```
|
||||||
[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.
|
|
||||||
|
|
||||||
### 3️⃣ Use in IDE
|
### 🔄 Automate Workflows
|
||||||
If you prefer to integrate Qwen Code into your current editor, we now support VS Code and Zed. For details, please refer to:
|
|
||||||
|
|
||||||
- [Use in VS Code](https://qwenlm.github.io/qwen-code-docs/en/users/integration-vscode/)
|
```bash
|
||||||
- [Use in Zed](https://qwenlm.github.io/qwen-code-docs/en/users/integration-zed/)
|
# 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
|
||||||
|
|
||||||
### 4️⃣ SDK
|
# File operations
|
||||||
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:
|
> 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
|
||||||
|
```
|
||||||
|
|
||||||
- [Use the Qwen Code SDK](./packages/sdk-typescript/README.md)
|
### 🐛 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
|
||||||
|
```
|
||||||
|
|
||||||
## Commands & Shortcuts
|
## Commands & Shortcuts
|
||||||
|
|
||||||
@@ -122,11 +394,6 @@ Qwen Code now supports an SDK designed to simplify integration with the Qwen Cod
|
|||||||
- `Ctrl+D` - Exit (on empty line)
|
- `Ctrl+D` - Exit (on empty line)
|
||||||
- `Up/Down` - Navigate command history
|
- `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
|
## Benchmark Results
|
||||||
|
|
||||||
### Terminal-Bench Performance
|
### Terminal-Bench Performance
|
||||||
@@ -138,13 +405,13 @@ Qwen Code now supports an SDK designed to simplify integration with the Qwen Cod
|
|||||||
|
|
||||||
## Development & Contributing
|
## Development & Contributing
|
||||||
|
|
||||||
See [CONTRIBUTING.md](https://qwenlm.github.io/qwen-code-docs/en/developers/contributing/) to learn how to contribute to the project.
|
See [CONTRIBUTING.md](./CONTRIBUTING.md) to learn how to contribute to the project.
|
||||||
|
|
||||||
For detailed authentication setup, see the [authentication guide](https://qwenlm.github.io/qwen-code-docs/en/users/configuration/auth/).
|
For detailed authentication setup, see the [authentication guide](./docs/cli/authentication.md).
|
||||||
|
|
||||||
## Troubleshooting
|
## Troubleshooting
|
||||||
|
|
||||||
If you encounter issues, check the [troubleshooting guide](https://qwenlm.github.io/qwen-code-docs/en/users/support/troubleshooting/).
|
If you encounter issues, check the [troubleshooting guide](docs/troubleshooting.md).
|
||||||
|
|
||||||
## Acknowledgments
|
## Acknowledgments
|
||||||
|
|
||||||
|
|||||||
@@ -627,7 +627,12 @@ The MCP integration tracks several states:
|
|||||||
|
|
||||||
### Schema Compatibility
|
### Schema Compatibility
|
||||||
|
|
||||||
- **Property stripping:** The system automatically removes certain schema properties (`$schema`, `additionalProperties`) for Qwen API 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`
|
||||||
- **Name sanitization:** Tool names are automatically sanitized to meet API requirements
|
- **Name sanitization:** Tool names are automatically sanitized to meet API requirements
|
||||||
- **Conflict resolution:** Tool name conflicts between servers are resolved through automatic prefixing
|
- **Conflict resolution:** Tool name conflicts between servers are resolved through automatic prefixing
|
||||||
|
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ Welcome to the Qwen Code documentation. Qwen Code is an agentic coding tool that
|
|||||||
## Documentation Sections
|
## Documentation Sections
|
||||||
|
|
||||||
### [User Guide](./users/overview)
|
### [User Guide](./users/overview)
|
||||||
|
|
||||||
Learn how to use Qwen Code as an end user. This section covers:
|
Learn how to use Qwen Code as an end user. This section covers:
|
||||||
|
|
||||||
- Basic installation and setup
|
- Basic installation and setup
|
||||||
|
|||||||
@@ -20,9 +20,9 @@ You can update your `.qwenignore` file at any time. To apply the changes, you mu
|
|||||||
|
|
||||||
## How to use `.qwenignore`
|
## How to use `.qwenignore`
|
||||||
|
|
||||||
| Step | Description |
|
| Step | Description |
|
||||||
| ---------------------- | ------------------------------------------------------------ |
|
| ---------------------- | -------------------------------------------------------------------------------------- |
|
||||||
| **Enable .qwenignore** | Create a file named `.qwenignore` in your project root directory |
|
| **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` |
|
| **Add ignore rules** | Open `.qwenignore` file and add paths to ignore, example: `/archive/` or `apikeys.txt` |
|
||||||
|
|
||||||
### `.qwenignore` examples
|
### `.qwenignore` examples
|
||||||
|
|||||||
@@ -50,13 +50,14 @@ Settings are organized into categories. All settings should be placed within the
|
|||||||
|
|
||||||
#### general
|
#### general
|
||||||
|
|
||||||
| Setting | Type | Description | Default |
|
| Setting | Type | Description | Default |
|
||||||
| ------------------------------- | ------- | ------------------------------------------ | ----------- |
|
| ------------------------------- | ------- | ---------------------------------------------------------------------------------------------------------- | ----------- |
|
||||||
| `general.preferredEditor` | string | The preferred editor to open files in. | `undefined` |
|
| `general.preferredEditor` | string | The preferred editor to open files in. | `undefined` |
|
||||||
| `general.vimMode` | boolean | Enable Vim keybindings. | `false` |
|
| `general.vimMode` | boolean | Enable Vim keybindings. | `false` |
|
||||||
| `general.disableAutoUpdate` | boolean | Disable automatic updates. | `false` |
|
| `general.disableAutoUpdate` | boolean | Disable automatic updates. | `false` |
|
||||||
| `general.disableUpdateNag` | boolean | Disable update notification prompts. | `false` |
|
| `general.disableUpdateNag` | boolean | Disable update notification prompts. | `false` |
|
||||||
| `general.checkpointing.enabled` | boolean | Enable session checkpointing for recovery. | `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` |
|
||||||
|
|
||||||
#### output
|
#### output
|
||||||
|
|
||||||
@@ -68,7 +69,7 @@ Settings are organized into categories. All settings should be placed within the
|
|||||||
|
|
||||||
| Setting | Type | Description | Default |
|
| 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.customThemes` | object | Custom theme definitions. | `{}` |
|
||||||
| `ui.hideWindowTitle` | boolean | Hide the window title bar. | `false` |
|
| `ui.hideWindowTitle` | boolean | Hide the window title bar. | `false` |
|
||||||
| `ui.hideTips` | boolean | Hide helpful tips in the UI. | `false` |
|
| `ui.hideTips` | boolean | Hide helpful tips in the UI. | `false` |
|
||||||
@@ -356,38 +357,38 @@ Arguments passed directly when running the CLI can override other configurations
|
|||||||
|
|
||||||
### Command-Line Arguments Table
|
### Command-Line Arguments Table
|
||||||
|
|
||||||
| Argument | Alias | Description | Possible Values | Notes |
|
| 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` |
|
| `--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` | `-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"` |
|
| `--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. |
|
| `--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. |
|
| `--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. |
|
| `--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` | `-s` | Enables sandbox mode for this session. | | |
|
||||||
| `--sandbox-image` | | Sets the sandbox image URI. | | |
|
| `--sandbox-image` | | Sets the sandbox image URI. | | |
|
||||||
| `--debug` | `-d` | Enables debug mode for this session, providing more verbose output. | | |
|
| `--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. | | |
|
| `--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. | | |
|
| `--help` | `-h` | Displays help information about command-line arguments. | | |
|
||||||
| `--show-memory-usage` | | Displays the current memory usage. | | |
|
| `--show-memory-usage` | | Displays the current memory usage. | | |
|
||||||
| `--yolo` | | Enables YOLO mode, which automatically approves all tool calls. | | |
|
| `--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). |
|
| `--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)"` |
|
| `--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` | | Enables [telemetry](/developers/development/telemetry). | | |
|
||||||
| `--telemetry-target` | | Sets the telemetry target. | | See [telemetry](/developers/development/telemetry) for more information. |
|
| `--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-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-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. |
|
| `--telemetry-log-prompts` | | Enables logging of prompts for telemetry. | | See [telemetry](../../developers/development/telemetry) for more information. |
|
||||||
| `--checkpointing` | | Enables [checkpointing](../features/checkpointing). | | |
|
| `--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` |
|
| `--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. | | |
|
| `--list-extensions` | `-l` | Lists all available extensions and exits. | | |
|
||||||
| `--proxy` | | Sets the proxy for the CLI. | Proxy URL | Example: `--proxy http://localhost:7890`. |
|
| `--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` |
|
| `--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. | | |
|
| `--screen-reader` | | Enables screen reader mode, which adjusts the TUI for better compatibility with screen readers. | | |
|
||||||
| `--version` | | Displays the version of the CLI. | | |
|
| `--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` | | 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` |
|
| `--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` |
|
| `--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)
|
## Context Files (Hierarchical Instructional Context)
|
||||||
|
|
||||||
|
|||||||
@@ -140,8 +140,6 @@ The theme file must be a valid JSON file that follows the same structure as a cu
|
|||||||
|
|
||||||
### Example Custom Theme
|
### 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;" />
|
<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
|
### Using Your Custom Theme
|
||||||
@@ -150,15 +148,13 @@ 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`.
|
- 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.
|
- 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
|
## Themes Preview
|
||||||
|
|
||||||
| Dark Theme | Preview | Light Theme | 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;" /> |
|
| 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;" /> |
|
| 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;" /> |
|
| 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;" /> |
|
| 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;" /> |
|
| 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;" /> |
|
| 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;" /> |
|
||||||
|
|||||||
@@ -20,10 +20,11 @@ These commands help you save, restore, and summarize work progress.
|
|||||||
|
|
||||||
| Command | Description | Usage Examples |
|
| Command | Description | Usage Examples |
|
||||||
| ----------- | --------------------------------------------------------- | ------------------------------------ |
|
| ----------- | --------------------------------------------------------- | ------------------------------------ |
|
||||||
|
| `/init` | Analyze current directory and create initial context file | `/init` |
|
||||||
| `/summary` | Generate project summary based on conversation history | `/summary` |
|
| `/summary` | Generate project summary based on conversation history | `/summary` |
|
||||||
| `/compress` | Replace chat history with summary to save Tokens | `/compress` |
|
| `/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>` |
|
| `/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
|
### 1.2 Interface and Workspace Control
|
||||||
|
|
||||||
|
|||||||
@@ -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.
|
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 |
|
| Exit Code | Error Type | Description |
|
||||||
| --------- | -------------------------- | ------------------------------------------------------------ |
|
| --------- | -------------------------- | --------------------------------------------------------------------------------------------------- |
|
||||||
| 41 | `FatalAuthenticationError` | An error occurred during the authentication process. |
|
| 41 | `FatalAuthenticationError` | An error occurred during the authentication process. |
|
||||||
| 42 | `FatalInputError` | Invalid or missing input was provided to the CLI. (non-interactive mode only) |
|
| 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). |
|
| 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. |
|
| 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) |
|
| 53 | `FatalTurnLimitedError` | The maximum number of conversational turns for the session was reached. (non-interactive mode only) |
|
||||||
|
|
||||||
## Debugging Tips
|
## Debugging Tips
|
||||||
|
|||||||
@@ -5,8 +5,8 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import { describe, it, expect, beforeAll, afterAll } from 'vitest';
|
import { describe, it, expect, beforeAll, afterAll } from 'vitest';
|
||||||
import { writeFileSync, readFileSync } from 'node:fs';
|
import { writeFileSync } from 'node:fs';
|
||||||
import { join, resolve } from 'node:path';
|
import { join } from 'node:path';
|
||||||
import { TestRig } from './test-helper.js';
|
import { TestRig } from './test-helper.js';
|
||||||
|
|
||||||
// Windows skip (Option A: avoid infra scope)
|
// Windows skip (Option A: avoid infra scope)
|
||||||
@@ -121,21 +121,4 @@ d('BOM end-to-end integration', () => {
|
|||||||
'BOM_OK UTF-32BE',
|
'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();
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -206,6 +206,18 @@ describe('parseArguments', () => {
|
|||||||
expect(argv.prompt).toBeUndefined();
|
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 () => {
|
it('should convert positional query argument to prompt by default', async () => {
|
||||||
process.argv = ['node', 'script.js', 'Hi Gemini'];
|
process.argv = ['node', 'script.js', 'Hi Gemini'];
|
||||||
const argv = await parseArguments({} as Settings);
|
const argv = await parseArguments({} as Settings);
|
||||||
|
|||||||
@@ -299,7 +299,6 @@ 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)',
|
'Set the approval mode: plan (plan only), default (prompt for approval), auto-edit (auto-approve edit tools), yolo (auto-approve all tools)',
|
||||||
})
|
})
|
||||||
.option('checkpointing', {
|
.option('checkpointing', {
|
||||||
alias: 'c',
|
|
||||||
type: 'boolean',
|
type: 'boolean',
|
||||||
description: 'Enables checkpointing of file edits',
|
description: 'Enables checkpointing of file edits',
|
||||||
default: false,
|
default: false,
|
||||||
@@ -422,12 +421,14 @@ export async function parseArguments(settings: Settings): Promise<CliArgs> {
|
|||||||
default: false,
|
default: false,
|
||||||
})
|
})
|
||||||
.option('continue', {
|
.option('continue', {
|
||||||
|
alias: 'c',
|
||||||
type: 'boolean',
|
type: 'boolean',
|
||||||
description:
|
description:
|
||||||
'Resume the most recent session for the current project.',
|
'Resume the most recent session for the current project.',
|
||||||
default: false,
|
default: false,
|
||||||
})
|
})
|
||||||
.option('resume', {
|
.option('resume', {
|
||||||
|
alias: 'r',
|
||||||
type: 'string',
|
type: 'string',
|
||||||
description:
|
description:
|
||||||
'Resume a specific session by its ID. Use without an ID to show session picker.',
|
'Resume a specific session by its ID. Use without an ID to show session picker.',
|
||||||
@@ -1002,6 +1003,7 @@ export async function loadCliConfig(
|
|||||||
enableToolOutputTruncation: settings.tools?.enableToolOutputTruncation,
|
enableToolOutputTruncation: settings.tools?.enableToolOutputTruncation,
|
||||||
eventEmitter: appEvents,
|
eventEmitter: appEvents,
|
||||||
useSmartEdit: argv.useSmartEdit ?? settings.useSmartEdit,
|
useSmartEdit: argv.useSmartEdit ?? settings.useSmartEdit,
|
||||||
|
gitCoAuthor: settings.general?.gitCoAuthor,
|
||||||
output: {
|
output: {
|
||||||
format: outputSettingsFormat,
|
format: outputSettingsFormat,
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -581,7 +581,7 @@ function extensionConsentString(extensionConfig: ExtensionConfig): string {
|
|||||||
}
|
}
|
||||||
if (extensionConfig.contextFileName) {
|
if (extensionConfig.contextFileName) {
|
||||||
output.push(
|
output.push(
|
||||||
`This extension will append info to your gemini.md context using ${extensionConfig.contextFileName}`,
|
`This extension will append info to your QWEN.md context using ${extensionConfig.contextFileName}`,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
if (extensionConfig.excludeTools) {
|
if (extensionConfig.excludeTools) {
|
||||||
|
|||||||
@@ -147,6 +147,16 @@ const SETTINGS_SCHEMA = {
|
|||||||
description: 'Disable update notification prompts.',
|
description: 'Disable update notification prompts.',
|
||||||
showInDialog: false,
|
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: {
|
checkpointing: {
|
||||||
type: 'object',
|
type: 'object',
|
||||||
label: 'Checkpointing',
|
label: 'Checkpointing',
|
||||||
@@ -284,7 +294,7 @@ const SETTINGS_SCHEMA = {
|
|||||||
requiresRestart: false,
|
requiresRestart: false,
|
||||||
default: false,
|
default: false,
|
||||||
description:
|
description:
|
||||||
'Show Gemini CLI status and thoughts in the terminal window title',
|
'Show Qwen Code status and thoughts in the terminal window title',
|
||||||
showInDialog: true,
|
showInDialog: true,
|
||||||
},
|
},
|
||||||
hideTips: {
|
hideTips: {
|
||||||
@@ -312,7 +322,7 @@ const SETTINGS_SCHEMA = {
|
|||||||
requiresRestart: false,
|
requiresRestart: false,
|
||||||
default: false,
|
default: false,
|
||||||
description:
|
description:
|
||||||
'Hide the context summary (GEMINI.md, MCP servers) above the input.',
|
'Hide the context summary (QWEN.md, MCP servers) above the input.',
|
||||||
showInDialog: true,
|
showInDialog: true,
|
||||||
},
|
},
|
||||||
footer: {
|
footer: {
|
||||||
@@ -518,7 +528,7 @@ const SETTINGS_SCHEMA = {
|
|||||||
category: 'Model',
|
category: 'Model',
|
||||||
requiresRestart: false,
|
requiresRestart: false,
|
||||||
default: undefined as string | undefined,
|
default: undefined as string | undefined,
|
||||||
description: 'The Gemini model to use for conversations.',
|
description: 'The model to use for conversations.',
|
||||||
showInDialog: false,
|
showInDialog: false,
|
||||||
},
|
},
|
||||||
maxSessionTurns: {
|
maxSessionTurns: {
|
||||||
@@ -649,6 +659,22 @@ const SETTINGS_SCHEMA = {
|
|||||||
childKey: 'disableCacheControl',
|
childKey: 'disableCacheControl',
|
||||||
showInDialog: true,
|
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' },
|
||||||
|
],
|
||||||
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -379,8 +379,8 @@ describe('gemini.tsx main function kitty protocol', () => {
|
|||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
// Set no relaunch in tests since process spawning causing issues in tests
|
// Set no relaunch in tests since process spawning causing issues in tests
|
||||||
originalEnvNoRelaunch = process.env['GEMINI_CLI_NO_RELAUNCH'];
|
originalEnvNoRelaunch = process.env['QWEN_CODE_NO_RELAUNCH'];
|
||||||
process.env['GEMINI_CLI_NO_RELAUNCH'] = 'true';
|
process.env['QWEN_CODE_NO_RELAUNCH'] = 'true';
|
||||||
|
|
||||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
if (!(process.stdin as any).setRawMode) {
|
if (!(process.stdin as any).setRawMode) {
|
||||||
@@ -402,9 +402,9 @@ describe('gemini.tsx main function kitty protocol', () => {
|
|||||||
afterEach(() => {
|
afterEach(() => {
|
||||||
// Restore original env variables
|
// Restore original env variables
|
||||||
if (originalEnvNoRelaunch !== undefined) {
|
if (originalEnvNoRelaunch !== undefined) {
|
||||||
process.env['GEMINI_CLI_NO_RELAUNCH'] = originalEnvNoRelaunch;
|
process.env['QWEN_CODE_NO_RELAUNCH'] = originalEnvNoRelaunch;
|
||||||
} else {
|
} else {
|
||||||
delete process.env['GEMINI_CLI_NO_RELAUNCH'];
|
delete process.env['QWEN_CODE_NO_RELAUNCH'];
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -58,7 +58,7 @@ import { getUserStartupWarnings } from './utils/userStartupWarnings.js';
|
|||||||
import { getCliVersion } from './utils/version.js';
|
import { getCliVersion } from './utils/version.js';
|
||||||
import { computeWindowTitle } from './utils/windowTitle.js';
|
import { computeWindowTitle } from './utils/windowTitle.js';
|
||||||
import { validateNonInteractiveAuth } from './validateNonInterActiveAuth.js';
|
import { validateNonInteractiveAuth } from './validateNonInterActiveAuth.js';
|
||||||
import { showResumeSessionPicker } from './ui/components/ResumeSessionPicker.js';
|
import { showResumeSessionPicker } from './ui/components/StandaloneSessionPicker.js';
|
||||||
|
|
||||||
export function validateDnsResolutionOrder(
|
export function validateDnsResolutionOrder(
|
||||||
order: string | undefined,
|
order: string | undefined,
|
||||||
@@ -92,7 +92,7 @@ function getNodeMemoryArgs(isDebugMode: boolean): string[] {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (process.env['GEMINI_CLI_NO_RELAUNCH']) {
|
if (process.env['QWEN_CODE_NO_RELAUNCH']) {
|
||||||
return [];
|
return [];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -310,6 +310,7 @@ export default {
|
|||||||
'Tool Output Truncation Lines': 'Tool Output Truncation Lines',
|
'Tool Output Truncation Lines': 'Tool Output Truncation Lines',
|
||||||
'Folder Trust': 'Folder Trust',
|
'Folder Trust': 'Folder Trust',
|
||||||
'Vision Model Preview': 'Vision Model Preview',
|
'Vision Model Preview': 'Vision Model Preview',
|
||||||
|
'Tool Schema Compliance': 'Tool Schema Compliance',
|
||||||
// Settings enum options
|
// Settings enum options
|
||||||
'Auto (detect from system)': 'Auto (detect from system)',
|
'Auto (detect from system)': 'Auto (detect from system)',
|
||||||
Text: 'Text',
|
Text: 'Text',
|
||||||
@@ -635,8 +636,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.':
|
||||||
'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}}",
|
"Error adding '{{path}}': {{error}}": "Error adding '{{path}}': {{error}}",
|
||||||
'Successfully added GEMINI.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 QWEN.md files from the following directories if there are:\n- {{directories}}',
|
||||||
'Error refreshing memory: {{error}}': 'Error refreshing memory: {{error}}',
|
'Error refreshing memory: {{error}}': 'Error refreshing memory: {{error}}',
|
||||||
'Successfully added directories:\n- {{directories}}':
|
'Successfully added directories:\n- {{directories}}':
|
||||||
'Successfully added directories:\n- {{directories}}',
|
'Successfully added directories:\n- {{directories}}',
|
||||||
|
|||||||
@@ -300,6 +300,7 @@ export default {
|
|||||||
'Tool Output Truncation Lines': '工具输出截断行数',
|
'Tool Output Truncation Lines': '工具输出截断行数',
|
||||||
'Folder Trust': '文件夹信任',
|
'Folder Trust': '文件夹信任',
|
||||||
'Vision Model Preview': '视觉模型预览',
|
'Vision Model Preview': '视觉模型预览',
|
||||||
|
'Tool Schema Compliance': '工具 Schema 兼容性',
|
||||||
// Settings enum options
|
// Settings enum options
|
||||||
'Auto (detect from system)': '自动(从系统检测)',
|
'Auto (detect from system)': '自动(从系统检测)',
|
||||||
Text: '文本',
|
Text: '文本',
|
||||||
@@ -601,8 +602,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.':
|
||||||
'/directory add 命令在限制性沙箱配置文件中不受支持。请改为在启动会话时使用 --include-directories。',
|
'/directory add 命令在限制性沙箱配置文件中不受支持。请改为在启动会话时使用 --include-directories。',
|
||||||
"Error adding '{{path}}': {{error}}": "添加 '{{path}}' 时出错:{{error}}",
|
"Error adding '{{path}}': {{error}}": "添加 '{{path}}' 时出错:{{error}}",
|
||||||
'Successfully added GEMINI.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}}':
|
||||||
'如果存在,已成功从以下目录添加 GEMINI.md 文件:\n- {{directories}}',
|
'如果存在,已成功从以下目录添加 QWEN.md 文件:\n- {{directories}}',
|
||||||
'Error refreshing memory: {{error}}': '刷新内存时出错:{{error}}',
|
'Error refreshing memory: {{error}}': '刷新内存时出错:{{error}}',
|
||||||
'Successfully added directories:\n- {{directories}}':
|
'Successfully added directories:\n- {{directories}}':
|
||||||
'成功添加目录:\n- {{directories}}',
|
'成功添加目录:\n- {{directories}}',
|
||||||
|
|||||||
@@ -29,6 +29,7 @@ import { modelCommand } from '../ui/commands/modelCommand.js';
|
|||||||
import { permissionsCommand } from '../ui/commands/permissionsCommand.js';
|
import { permissionsCommand } from '../ui/commands/permissionsCommand.js';
|
||||||
import { quitCommand } from '../ui/commands/quitCommand.js';
|
import { quitCommand } from '../ui/commands/quitCommand.js';
|
||||||
import { restoreCommand } from '../ui/commands/restoreCommand.js';
|
import { restoreCommand } from '../ui/commands/restoreCommand.js';
|
||||||
|
import { resumeCommand } from '../ui/commands/resumeCommand.js';
|
||||||
import { settingsCommand } from '../ui/commands/settingsCommand.js';
|
import { settingsCommand } from '../ui/commands/settingsCommand.js';
|
||||||
import { statsCommand } from '../ui/commands/statsCommand.js';
|
import { statsCommand } from '../ui/commands/statsCommand.js';
|
||||||
import { summaryCommand } from '../ui/commands/summaryCommand.js';
|
import { summaryCommand } from '../ui/commands/summaryCommand.js';
|
||||||
@@ -76,6 +77,7 @@ export class BuiltinCommandLoader implements ICommandLoader {
|
|||||||
...(this.config?.getFolderTrust() ? [permissionsCommand] : []),
|
...(this.config?.getFolderTrust() ? [permissionsCommand] : []),
|
||||||
quitCommand,
|
quitCommand,
|
||||||
restoreCommand(this.config),
|
restoreCommand(this.config),
|
||||||
|
resumeCommand,
|
||||||
statsCommand,
|
statsCommand,
|
||||||
summaryCommand,
|
summaryCommand,
|
||||||
themeCommand,
|
themeCommand,
|
||||||
|
|||||||
@@ -53,6 +53,7 @@ import { useEditorSettings } from './hooks/useEditorSettings.js';
|
|||||||
import { useSettingsCommand } from './hooks/useSettingsCommand.js';
|
import { useSettingsCommand } from './hooks/useSettingsCommand.js';
|
||||||
import { useModelCommand } from './hooks/useModelCommand.js';
|
import { useModelCommand } from './hooks/useModelCommand.js';
|
||||||
import { useApprovalModeCommand } from './hooks/useApprovalModeCommand.js';
|
import { useApprovalModeCommand } from './hooks/useApprovalModeCommand.js';
|
||||||
|
import { useResumeCommand } from './hooks/useResumeCommand.js';
|
||||||
import { useSlashCommandProcessor } from './hooks/slashCommandProcessor.js';
|
import { useSlashCommandProcessor } from './hooks/slashCommandProcessor.js';
|
||||||
import { useVimMode } from './contexts/VimModeContext.js';
|
import { useVimMode } from './contexts/VimModeContext.js';
|
||||||
import { useConsoleMessages } from './hooks/useConsoleMessages.js';
|
import { useConsoleMessages } from './hooks/useConsoleMessages.js';
|
||||||
@@ -203,7 +204,7 @@ export const AppContainer = (props: AppContainerProps) => {
|
|||||||
const { stdout } = useStdout();
|
const { stdout } = useStdout();
|
||||||
|
|
||||||
// Additional hooks moved from App.tsx
|
// Additional hooks moved from App.tsx
|
||||||
const { stats: sessionStats } = useSessionStats();
|
const { stats: sessionStats, startNewSession } = useSessionStats();
|
||||||
const logger = useLogger(config.storage, sessionStats.sessionId);
|
const logger = useLogger(config.storage, sessionStats.sessionId);
|
||||||
const branchName = useGitBranchName(config.getTargetDir());
|
const branchName = useGitBranchName(config.getTargetDir());
|
||||||
|
|
||||||
@@ -435,6 +436,18 @@ export const AppContainer = (props: AppContainerProps) => {
|
|||||||
const { isModelDialogOpen, openModelDialog, closeModelDialog } =
|
const { isModelDialogOpen, openModelDialog, closeModelDialog } =
|
||||||
useModelCommand();
|
useModelCommand();
|
||||||
|
|
||||||
|
const {
|
||||||
|
isResumeDialogOpen,
|
||||||
|
openResumeDialog,
|
||||||
|
closeResumeDialog,
|
||||||
|
handleResume,
|
||||||
|
} = useResumeCommand({
|
||||||
|
config,
|
||||||
|
historyManager,
|
||||||
|
startNewSession,
|
||||||
|
remount: refreshStatic,
|
||||||
|
});
|
||||||
|
|
||||||
const {
|
const {
|
||||||
showWorkspaceMigrationDialog,
|
showWorkspaceMigrationDialog,
|
||||||
workspaceExtensions,
|
workspaceExtensions,
|
||||||
@@ -488,6 +501,7 @@ export const AppContainer = (props: AppContainerProps) => {
|
|||||||
addConfirmUpdateExtensionRequest,
|
addConfirmUpdateExtensionRequest,
|
||||||
openSubagentCreateDialog,
|
openSubagentCreateDialog,
|
||||||
openAgentsManagerDialog,
|
openAgentsManagerDialog,
|
||||||
|
openResumeDialog,
|
||||||
}),
|
}),
|
||||||
[
|
[
|
||||||
openAuthDialog,
|
openAuthDialog,
|
||||||
@@ -502,6 +516,7 @@ export const AppContainer = (props: AppContainerProps) => {
|
|||||||
addConfirmUpdateExtensionRequest,
|
addConfirmUpdateExtensionRequest,
|
||||||
openSubagentCreateDialog,
|
openSubagentCreateDialog,
|
||||||
openAgentsManagerDialog,
|
openAgentsManagerDialog,
|
||||||
|
openResumeDialog,
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -1194,7 +1209,8 @@ export const AppContainer = (props: AppContainerProps) => {
|
|||||||
!!proQuotaRequest ||
|
!!proQuotaRequest ||
|
||||||
isSubagentCreateDialogOpen ||
|
isSubagentCreateDialogOpen ||
|
||||||
isAgentsManagerDialogOpen ||
|
isAgentsManagerDialogOpen ||
|
||||||
isApprovalModeDialogOpen;
|
isApprovalModeDialogOpen ||
|
||||||
|
isResumeDialogOpen;
|
||||||
|
|
||||||
const pendingHistoryItems = useMemo(
|
const pendingHistoryItems = useMemo(
|
||||||
() => [...pendingSlashCommandHistoryItems, ...pendingGeminiHistoryItems],
|
() => [...pendingSlashCommandHistoryItems, ...pendingGeminiHistoryItems],
|
||||||
@@ -1222,6 +1238,7 @@ export const AppContainer = (props: AppContainerProps) => {
|
|||||||
isModelDialogOpen,
|
isModelDialogOpen,
|
||||||
isPermissionsDialogOpen,
|
isPermissionsDialogOpen,
|
||||||
isApprovalModeDialogOpen,
|
isApprovalModeDialogOpen,
|
||||||
|
isResumeDialogOpen,
|
||||||
slashCommands,
|
slashCommands,
|
||||||
pendingSlashCommandHistoryItems,
|
pendingSlashCommandHistoryItems,
|
||||||
commandContext,
|
commandContext,
|
||||||
@@ -1312,6 +1329,7 @@ export const AppContainer = (props: AppContainerProps) => {
|
|||||||
isModelDialogOpen,
|
isModelDialogOpen,
|
||||||
isPermissionsDialogOpen,
|
isPermissionsDialogOpen,
|
||||||
isApprovalModeDialogOpen,
|
isApprovalModeDialogOpen,
|
||||||
|
isResumeDialogOpen,
|
||||||
slashCommands,
|
slashCommands,
|
||||||
pendingSlashCommandHistoryItems,
|
pendingSlashCommandHistoryItems,
|
||||||
commandContext,
|
commandContext,
|
||||||
@@ -1421,6 +1439,10 @@ export const AppContainer = (props: AppContainerProps) => {
|
|||||||
// Subagent dialogs
|
// Subagent dialogs
|
||||||
closeSubagentCreateDialog,
|
closeSubagentCreateDialog,
|
||||||
closeAgentsManagerDialog,
|
closeAgentsManagerDialog,
|
||||||
|
// Resume session dialog
|
||||||
|
openResumeDialog,
|
||||||
|
closeResumeDialog,
|
||||||
|
handleResume,
|
||||||
}),
|
}),
|
||||||
[
|
[
|
||||||
handleThemeSelect,
|
handleThemeSelect,
|
||||||
@@ -1453,6 +1475,10 @@ export const AppContainer = (props: AppContainerProps) => {
|
|||||||
// Subagent dialogs
|
// Subagent dialogs
|
||||||
closeSubagentCreateDialog,
|
closeSubagentCreateDialog,
|
||||||
closeAgentsManagerDialog,
|
closeAgentsManagerDialog,
|
||||||
|
// Resume session dialog
|
||||||
|
openResumeDialog,
|
||||||
|
closeResumeDialog,
|
||||||
|
handleResume,
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|||||||
@@ -130,7 +130,7 @@ export const directoryCommand: SlashCommand = {
|
|||||||
{
|
{
|
||||||
type: MessageType.INFO,
|
type: MessageType.INFO,
|
||||||
text: t(
|
text: t(
|
||||||
'Successfully added GEMINI.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}}',
|
||||||
{
|
{
|
||||||
directories: added.join('\n- '),
|
directories: added.join('\n- '),
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -89,7 +89,7 @@ describe('restoreCommand', () => {
|
|||||||
).toEqual({
|
).toEqual({
|
||||||
type: 'message',
|
type: 'message',
|
||||||
messageType: 'error',
|
messageType: 'error',
|
||||||
content: 'Could not determine the .gemini directory path.',
|
content: 'Could not determine the .qwen directory path.',
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -28,7 +28,7 @@ async function restoreAction(
|
|||||||
return {
|
return {
|
||||||
type: 'message',
|
type: 'message',
|
||||||
messageType: 'error',
|
messageType: 'error',
|
||||||
content: 'Could not determine the .gemini directory path.',
|
content: 'Could not determine the .qwen directory path.',
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
38
packages/cli/src/ui/commands/resumeCommand.test.ts
Normal file
38
packages/cli/src/ui/commands/resumeCommand.test.ts
Normal file
@@ -0,0 +1,38 @@
|
|||||||
|
/**
|
||||||
|
* @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');
|
||||||
|
});
|
||||||
|
});
|
||||||
21
packages/cli/src/ui/commands/resumeCommand.ts
Normal file
21
packages/cli/src/ui/commands/resumeCommand.ts
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
/**
|
||||||
|
* @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',
|
||||||
|
}),
|
||||||
|
};
|
||||||
@@ -124,7 +124,8 @@ export interface OpenDialogActionReturn {
|
|||||||
| 'subagent_create'
|
| 'subagent_create'
|
||||||
| 'subagent_list'
|
| 'subagent_list'
|
||||||
| 'permissions'
|
| 'permissions'
|
||||||
| 'approval-mode';
|
| 'approval-mode'
|
||||||
|
| 'resume';
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -36,6 +36,7 @@ import { WelcomeBackDialog } from './WelcomeBackDialog.js';
|
|||||||
import { ModelSwitchDialog } from './ModelSwitchDialog.js';
|
import { ModelSwitchDialog } from './ModelSwitchDialog.js';
|
||||||
import { AgentCreationWizard } from './subagents/create/AgentCreationWizard.js';
|
import { AgentCreationWizard } from './subagents/create/AgentCreationWizard.js';
|
||||||
import { AgentsManagerDialog } from './subagents/manage/AgentsManagerDialog.js';
|
import { AgentsManagerDialog } from './subagents/manage/AgentsManagerDialog.js';
|
||||||
|
import { SessionPicker } from './SessionPicker.js';
|
||||||
|
|
||||||
interface DialogManagerProps {
|
interface DialogManagerProps {
|
||||||
addItem: UseHistoryManagerReturn['addItem'];
|
addItem: UseHistoryManagerReturn['addItem'];
|
||||||
@@ -290,5 +291,16 @@ export const DialogManager = ({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (uiState.isResumeDialogOpen) {
|
||||||
|
return (
|
||||||
|
<SessionPicker
|
||||||
|
sessionService={config.getSessionService()}
|
||||||
|
currentBranch={uiState.branchName}
|
||||||
|
onSelect={uiActions.handleResume}
|
||||||
|
onCancel={uiActions.closeResumeDialog}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
return null;
|
return null;
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -115,7 +115,7 @@ export function PermissionsModifyTrustDialog({
|
|||||||
{needsRestart && (
|
{needsRestart && (
|
||||||
<Box marginLeft={1} marginTop={1}>
|
<Box marginLeft={1} marginTop={1}>
|
||||||
<Text color={theme.status.warning}>
|
<Text color={theme.status.warning}>
|
||||||
To apply the trust changes, Gemini CLI must be restarted. Press
|
To apply the trust changes, Qwen Code must be restarted. Press
|
||||||
'r' to restart CLI now.
|
'r' to restart CLI now.
|
||||||
</Text>
|
</Text>
|
||||||
</Box>
|
</Box>
|
||||||
|
|||||||
@@ -1,436 +0,0 @@
|
|||||||
/**
|
|
||||||
* @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);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
}
|
|
||||||
251
packages/cli/src/ui/components/SessionPicker.tsx
Normal file
251
packages/cli/src/ui/components/SessionPicker.tsx
Normal file
@@ -0,0 +1,251 @@
|
|||||||
|
/**
|
||||||
|
* @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>
|
||||||
|
);
|
||||||
|
}
|
||||||
624
packages/cli/src/ui/components/StandaloneSessionPicker.test.tsx
Normal file
624
packages/cli/src/ui/components/StandaloneSessionPicker.test.tsx
Normal file
@@ -0,0 +1,624 @@
|
|||||||
|
/**
|
||||||
|
* @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();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
122
packages/cli/src/ui/components/StandaloneSessionPicker.tsx
Normal file
122
packages/cli/src/ui/components/StandaloneSessionPicker.tsx
Normal file
@@ -0,0 +1,122 @@
|
|||||||
|
/**
|
||||||
|
* @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);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -64,6 +64,10 @@ export interface UIActions {
|
|||||||
// Subagent dialogs
|
// Subagent dialogs
|
||||||
closeSubagentCreateDialog: () => void;
|
closeSubagentCreateDialog: () => void;
|
||||||
closeAgentsManagerDialog: () => void;
|
closeAgentsManagerDialog: () => void;
|
||||||
|
// Resume session dialog
|
||||||
|
openResumeDialog: () => void;
|
||||||
|
closeResumeDialog: () => void;
|
||||||
|
handleResume: (sessionId: string) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const UIActionsContext = createContext<UIActions | null>(null);
|
export const UIActionsContext = createContext<UIActions | null>(null);
|
||||||
|
|||||||
@@ -60,6 +60,7 @@ export interface UIState {
|
|||||||
isModelDialogOpen: boolean;
|
isModelDialogOpen: boolean;
|
||||||
isPermissionsDialogOpen: boolean;
|
isPermissionsDialogOpen: boolean;
|
||||||
isApprovalModeDialogOpen: boolean;
|
isApprovalModeDialogOpen: boolean;
|
||||||
|
isResumeDialogOpen: boolean;
|
||||||
slashCommands: readonly SlashCommand[];
|
slashCommands: readonly SlashCommand[];
|
||||||
pendingSlashCommandHistoryItems: HistoryItemWithoutId[];
|
pendingSlashCommandHistoryItems: HistoryItemWithoutId[];
|
||||||
commandContext: CommandContext;
|
commandContext: CommandContext;
|
||||||
|
|||||||
@@ -56,6 +56,7 @@ const SLASH_COMMANDS_SKIP_RECORDING = new Set([
|
|||||||
'clear',
|
'clear',
|
||||||
'reset',
|
'reset',
|
||||||
'new',
|
'new',
|
||||||
|
'resume',
|
||||||
]);
|
]);
|
||||||
|
|
||||||
interface SlashCommandProcessorActions {
|
interface SlashCommandProcessorActions {
|
||||||
@@ -66,6 +67,7 @@ interface SlashCommandProcessorActions {
|
|||||||
openModelDialog: () => void;
|
openModelDialog: () => void;
|
||||||
openPermissionsDialog: () => void;
|
openPermissionsDialog: () => void;
|
||||||
openApprovalModeDialog: () => void;
|
openApprovalModeDialog: () => void;
|
||||||
|
openResumeDialog: () => void;
|
||||||
quit: (messages: HistoryItem[]) => void;
|
quit: (messages: HistoryItem[]) => void;
|
||||||
setDebugMessage: (message: string) => void;
|
setDebugMessage: (message: string) => void;
|
||||||
dispatchExtensionStateUpdate: (action: ExtensionUpdateAction) => void;
|
dispatchExtensionStateUpdate: (action: ExtensionUpdateAction) => void;
|
||||||
@@ -417,6 +419,9 @@ export const useSlashCommandProcessor = (
|
|||||||
case 'approval-mode':
|
case 'approval-mode':
|
||||||
actions.openApprovalModeDialog();
|
actions.openApprovalModeDialog();
|
||||||
return { type: 'handled' };
|
return { type: 'handled' };
|
||||||
|
case 'resume':
|
||||||
|
actions.openResumeDialog();
|
||||||
|
return { type: 'handled' };
|
||||||
case 'help':
|
case 'help':
|
||||||
return { type: 'handled' };
|
return { type: 'handled' };
|
||||||
default: {
|
default: {
|
||||||
|
|||||||
190
packages/cli/src/ui/hooks/useResumeCommand.test.ts
Normal file
190
packages/cli/src/ui/hooks/useResumeCommand.test.ts
Normal file
@@ -0,0 +1,190 @@
|
|||||||
|
/**
|
||||||
|
* @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);
|
||||||
|
});
|
||||||
|
});
|
||||||
82
packages/cli/src/ui/hooks/useResumeCommand.ts
Normal file
82
packages/cli/src/ui/hooks/useResumeCommand.ts
Normal file
@@ -0,0 +1,82 @@
|
|||||||
|
/**
|
||||||
|
* @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,
|
||||||
|
};
|
||||||
|
}
|
||||||
279
packages/cli/src/ui/hooks/useSessionPicker.ts
Normal file
279
packages/cli/src/ui/hooks/useSessionPicker.ts
Normal file
@@ -0,0 +1,279 @@
|
|||||||
|
/**
|
||||||
|
* @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,
|
||||||
|
};
|
||||||
|
}
|
||||||
45
packages/cli/src/ui/utils/sessionPickerUtils.test.ts
Normal file
45
packages/cli/src/ui/utils/sessionPickerUtils.test.ts
Normal file
@@ -0,0 +1,45 @@
|
|||||||
|
/**
|
||||||
|
* @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');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
59
packages/cli/src/ui/utils/sessionPickerUtils.ts
Normal file
59
packages/cli/src/ui/utils/sessionPickerUtils.ts
Normal file
@@ -0,0 +1,59 @@
|
|||||||
|
/**
|
||||||
|
* @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`;
|
||||||
|
}
|
||||||
@@ -76,6 +76,105 @@ describe('getGitHubRepoInfo', async () => {
|
|||||||
);
|
);
|
||||||
expect(getGitHubRepoInfo()).toEqual({ owner: 'owner', repo: 'repo' });
|
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 () => {
|
describe('getGitRepoRoot', async () => {
|
||||||
|
|||||||
@@ -103,17 +103,38 @@ export function getGitHubRepoInfo(): { owner: string; repo: string } {
|
|||||||
encoding: 'utf-8',
|
encoding: 'utf-8',
|
||||||
}).trim();
|
}).trim();
|
||||||
|
|
||||||
// Matches either https://github.com/owner/repo.git or git@github.com:owner/repo.git
|
// Handle SCP-style SSH URLs (git@github.com:owner/repo.git)
|
||||||
const match = remoteUrl.match(
|
let urlToParse = remoteUrl;
|
||||||
/(?:https?:\/\/|git@)github\.com(?::|\/)([^/]+)\/([^/]+?)(?:\.git)?$/,
|
if (remoteUrl.startsWith('git@github.com:')) {
|
||||||
);
|
urlToParse = remoteUrl.replace('git@github.com:', '');
|
||||||
|
} else if (remoteUrl.startsWith('git@')) {
|
||||||
// If the regex fails match, throw an error.
|
// SSH URL for a different provider (GitLab, Bitbucket, etc.)
|
||||||
if (!match || !match[1] || !match[2]) {
|
|
||||||
throw new Error(
|
throw new Error(
|
||||||
`Owner & repo could not be extracted from remote URL: ${remoteUrl}`,
|
`Owner & repo could not be extracted from remote URL: ${remoteUrl}`,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
return { owner: match[1], repo: match[2] };
|
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$/, '') };
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -115,7 +115,7 @@ describe('relaunchAppInChildProcess', () => {
|
|||||||
vi.clearAllMocks();
|
vi.clearAllMocks();
|
||||||
|
|
||||||
process.env = { ...originalEnv };
|
process.env = { ...originalEnv };
|
||||||
delete process.env['GEMINI_CLI_NO_RELAUNCH'];
|
delete process.env['QWEN_CODE_NO_RELAUNCH'];
|
||||||
|
|
||||||
process.execArgv = [...originalExecArgv];
|
process.execArgv = [...originalExecArgv];
|
||||||
process.argv = [...originalArgv];
|
process.argv = [...originalArgv];
|
||||||
@@ -145,9 +145,9 @@ describe('relaunchAppInChildProcess', () => {
|
|||||||
stdinResumeSpy.mockRestore();
|
stdinResumeSpy.mockRestore();
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('when GEMINI_CLI_NO_RELAUNCH is set', () => {
|
describe('when QWEN_CODE_NO_RELAUNCH is set', () => {
|
||||||
it('should return early without spawning a child process', async () => {
|
it('should return early without spawning a child process', async () => {
|
||||||
process.env['GEMINI_CLI_NO_RELAUNCH'] = 'true';
|
process.env['QWEN_CODE_NO_RELAUNCH'] = 'true';
|
||||||
|
|
||||||
await relaunchAppInChildProcess(['--test'], ['--verbose']);
|
await relaunchAppInChildProcess(['--test'], ['--verbose']);
|
||||||
|
|
||||||
@@ -156,9 +156,9 @@ describe('relaunchAppInChildProcess', () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('when GEMINI_CLI_NO_RELAUNCH is not set', () => {
|
describe('when QWEN_CODE_NO_RELAUNCH is not set', () => {
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
delete process.env['GEMINI_CLI_NO_RELAUNCH'];
|
delete process.env['QWEN_CODE_NO_RELAUNCH'];
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should construct correct node arguments from execArgv, additionalNodeArgs, script, additionalScriptArgs, and argv', () => {
|
it('should construct correct node arguments from execArgv, additionalNodeArgs, script, additionalScriptArgs, and argv', () => {
|
||||||
|
|||||||
@@ -27,7 +27,7 @@ export async function relaunchAppInChildProcess(
|
|||||||
additionalNodeArgs: string[],
|
additionalNodeArgs: string[],
|
||||||
additionalScriptArgs: string[],
|
additionalScriptArgs: string[],
|
||||||
) {
|
) {
|
||||||
if (process.env['GEMINI_CLI_NO_RELAUNCH']) {
|
if (process.env['QWEN_CODE_NO_RELAUNCH']) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -44,7 +44,7 @@ export async function relaunchAppInChildProcess(
|
|||||||
...additionalScriptArgs,
|
...additionalScriptArgs,
|
||||||
...scriptArgs,
|
...scriptArgs,
|
||||||
];
|
];
|
||||||
const newEnv = { ...process.env, GEMINI_CLI_NO_RELAUNCH: 'true' };
|
const newEnv = { ...process.env, QWEN_CODE_NO_RELAUNCH: 'true' };
|
||||||
|
|
||||||
// The parent process should not be reading from stdin while the child is running.
|
// The parent process should not be reading from stdin while the child is running.
|
||||||
process.stdin.pause();
|
process.stdin.pause();
|
||||||
|
|||||||
@@ -287,7 +287,7 @@ export interface ConfigParameters {
|
|||||||
contextFileName?: string | string[];
|
contextFileName?: string | string[];
|
||||||
accessibility?: AccessibilitySettings;
|
accessibility?: AccessibilitySettings;
|
||||||
telemetry?: TelemetrySettings;
|
telemetry?: TelemetrySettings;
|
||||||
gitCoAuthor?: GitCoAuthorSettings;
|
gitCoAuthor?: boolean;
|
||||||
usageStatisticsEnabled?: boolean;
|
usageStatisticsEnabled?: boolean;
|
||||||
fileFiltering?: {
|
fileFiltering?: {
|
||||||
respectGitIgnore?: boolean;
|
respectGitIgnore?: boolean;
|
||||||
@@ -534,9 +534,9 @@ export class Config {
|
|||||||
useCollector: params.telemetry?.useCollector,
|
useCollector: params.telemetry?.useCollector,
|
||||||
};
|
};
|
||||||
this.gitCoAuthor = {
|
this.gitCoAuthor = {
|
||||||
enabled: params.gitCoAuthor?.enabled ?? true,
|
enabled: params.gitCoAuthor ?? true,
|
||||||
name: params.gitCoAuthor?.name ?? 'Qwen-Coder',
|
name: 'Qwen-Coder',
|
||||||
email: params.gitCoAuthor?.email ?? 'qwen-coder@alibabacloud.com',
|
email: 'qwen-coder@alibabacloud.com',
|
||||||
};
|
};
|
||||||
this.usageStatisticsEnabled = params.usageStatisticsEnabled ?? true;
|
this.usageStatisticsEnabled = params.usageStatisticsEnabled ?? true;
|
||||||
|
|
||||||
@@ -741,9 +741,12 @@ export class Config {
|
|||||||
/**
|
/**
|
||||||
* Starts a new session and resets session-scoped services.
|
* Starts a new session and resets session-scoped services.
|
||||||
*/
|
*/
|
||||||
startNewSession(sessionId?: string): string {
|
startNewSession(
|
||||||
|
sessionId?: string,
|
||||||
|
sessionData?: ResumedSessionData,
|
||||||
|
): string {
|
||||||
this.sessionId = sessionId ?? randomUUID();
|
this.sessionId = sessionId ?? randomUUID();
|
||||||
this.sessionData = undefined;
|
this.sessionData = sessionData;
|
||||||
this.chatRecordingService = this.chatRecordingEnabled
|
this.chatRecordingService = this.chatRecordingEnabled
|
||||||
? new ChatRecordingService(this)
|
? new ChatRecordingService(this)
|
||||||
: undefined;
|
: undefined;
|
||||||
|
|||||||
@@ -151,8 +151,7 @@ describe('BaseLlmClient', () => {
|
|||||||
contents: defaultOptions.contents,
|
contents: defaultOptions.contents,
|
||||||
config: {
|
config: {
|
||||||
abortSignal: defaultOptions.abortSignal,
|
abortSignal: defaultOptions.abortSignal,
|
||||||
temperature: 0,
|
topP: 0.8,
|
||||||
topP: 1,
|
|
||||||
tools: [
|
tools: [
|
||||||
{
|
{
|
||||||
functionDeclarations: [
|
functionDeclarations: [
|
||||||
@@ -189,7 +188,7 @@ describe('BaseLlmClient', () => {
|
|||||||
expect.objectContaining({
|
expect.objectContaining({
|
||||||
config: expect.objectContaining({
|
config: expect.objectContaining({
|
||||||
temperature: 0.8,
|
temperature: 0.8,
|
||||||
topP: 1, // Default should remain if not overridden
|
topP: 0.8, // Default should remain if not overridden
|
||||||
topK: 10,
|
topK: 10,
|
||||||
tools: expect.any(Array),
|
tools: expect.any(Array),
|
||||||
}),
|
}),
|
||||||
|
|||||||
@@ -66,8 +66,7 @@ export interface GenerateJsonOptions {
|
|||||||
export class BaseLlmClient {
|
export class BaseLlmClient {
|
||||||
// Default configuration for utility tasks
|
// Default configuration for utility tasks
|
||||||
private readonly defaultUtilityConfig: GenerateContentConfig = {
|
private readonly defaultUtilityConfig: GenerateContentConfig = {
|
||||||
temperature: 0,
|
topP: 0.8,
|
||||||
topP: 1,
|
|
||||||
};
|
};
|
||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
|
|||||||
@@ -2310,7 +2310,7 @@ ${JSON.stringify(
|
|||||||
abortSignal,
|
abortSignal,
|
||||||
systemInstruction: getCoreSystemPrompt(''),
|
systemInstruction: getCoreSystemPrompt(''),
|
||||||
temperature: 0.5,
|
temperature: 0.5,
|
||||||
topP: 1,
|
topP: 0.8,
|
||||||
},
|
},
|
||||||
contents,
|
contents,
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -94,8 +94,7 @@ const MAX_TURNS = 100;
|
|||||||
export class GeminiClient {
|
export class GeminiClient {
|
||||||
private chat?: GeminiChat;
|
private chat?: GeminiChat;
|
||||||
private readonly generateContentConfig: GenerateContentConfig = {
|
private readonly generateContentConfig: GenerateContentConfig = {
|
||||||
temperature: 0,
|
topP: 0.8,
|
||||||
topP: 1,
|
|
||||||
};
|
};
|
||||||
private sessionTurnCount = 0;
|
private sessionTurnCount = 0;
|
||||||
|
|
||||||
|
|||||||
@@ -76,6 +76,8 @@ export type ContentGeneratorConfig = {
|
|||||||
};
|
};
|
||||||
proxy?: string | undefined;
|
proxy?: string | undefined;
|
||||||
userAgent?: string;
|
userAgent?: string;
|
||||||
|
// Schema compliance mode for tool definitions
|
||||||
|
schemaCompliance?: 'auto' | 'openapi_30';
|
||||||
};
|
};
|
||||||
|
|
||||||
export function createContentGeneratorConfig(
|
export function createContentGeneratorConfig(
|
||||||
|
|||||||
@@ -22,6 +22,10 @@ import { GenerateContentResponse, FinishReason } from '@google/genai';
|
|||||||
import type OpenAI from 'openai';
|
import type OpenAI from 'openai';
|
||||||
import { safeJsonParse } from '../../utils/safeJsonParse.js';
|
import { safeJsonParse } from '../../utils/safeJsonParse.js';
|
||||||
import { StreamingToolCallParser } from './streamingToolCallParser.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
|
* Extended usage type that supports both OpenAI standard format and alternative formats
|
||||||
@@ -80,11 +84,13 @@ interface ParsedParts {
|
|||||||
*/
|
*/
|
||||||
export class OpenAIContentConverter {
|
export class OpenAIContentConverter {
|
||||||
private model: string;
|
private model: string;
|
||||||
|
private schemaCompliance: SchemaComplianceMode;
|
||||||
private streamingToolCallParser: StreamingToolCallParser =
|
private streamingToolCallParser: StreamingToolCallParser =
|
||||||
new StreamingToolCallParser();
|
new StreamingToolCallParser();
|
||||||
|
|
||||||
constructor(model: string) {
|
constructor(model: string, schemaCompliance: SchemaComplianceMode = 'auto') {
|
||||||
this.model = model;
|
this.model = model;
|
||||||
|
this.schemaCompliance = schemaCompliance;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -205,6 +211,10 @@ export class OpenAIContentConverter {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (parameters) {
|
||||||
|
parameters = convertSchema(parameters, this.schemaCompliance);
|
||||||
|
}
|
||||||
|
|
||||||
openAITools.push({
|
openAITools.push({
|
||||||
type: 'function',
|
type: 'function',
|
||||||
function: {
|
function: {
|
||||||
|
|||||||
@@ -108,7 +108,10 @@ describe('ContentGenerationPipeline', () => {
|
|||||||
describe('constructor', () => {
|
describe('constructor', () => {
|
||||||
it('should initialize with correct configuration', () => {
|
it('should initialize with correct configuration', () => {
|
||||||
expect(mockProvider.buildClient).toHaveBeenCalled();
|
expect(mockProvider.buildClient).toHaveBeenCalled();
|
||||||
expect(OpenAIContentConverter).toHaveBeenCalledWith('test-model');
|
expect(OpenAIContentConverter).toHaveBeenCalledWith(
|
||||||
|
'test-model',
|
||||||
|
undefined,
|
||||||
|
);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -34,6 +34,7 @@ export class ContentGenerationPipeline {
|
|||||||
this.client = this.config.provider.buildClient();
|
this.client = this.config.provider.buildClient();
|
||||||
this.converter = new OpenAIContentConverter(
|
this.converter = new OpenAIContentConverter(
|
||||||
this.contentGeneratorConfig.model,
|
this.contentGeneratorConfig.model,
|
||||||
|
this.contentGeneratorConfig.schemaCompliance,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -608,6 +608,36 @@ 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 () => {
|
it('should not modify non-git commands', async () => {
|
||||||
const command = 'npm install';
|
const command = 'npm install';
|
||||||
const invocation = shellTool.build({ command, is_background: false });
|
const invocation = shellTool.build({ command, is_background: false });
|
||||||
@@ -768,6 +798,69 @@ 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,
|
||||||
|
{},
|
||||||
|
);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -334,13 +334,14 @@ export class ShellToolInvocation extends BaseToolInvocation<
|
|||||||
private addCoAuthorToGitCommit(command: string): string {
|
private addCoAuthorToGitCommit(command: string): string {
|
||||||
// Check if co-author feature is enabled
|
// Check if co-author feature is enabled
|
||||||
const gitCoAuthorSettings = this.config.getGitCoAuthor();
|
const gitCoAuthorSettings = this.config.getGitCoAuthor();
|
||||||
|
|
||||||
if (!gitCoAuthorSettings.enabled) {
|
if (!gitCoAuthorSettings.enabled) {
|
||||||
return command;
|
return command;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check if this is a git commit command
|
// Check if this is a git commit command (anywhere in the command, e.g., after "cd /path &&")
|
||||||
const gitCommitPattern = /^git\s+commit/;
|
const gitCommitPattern = /\bgit\s+commit\b/;
|
||||||
if (!gitCommitPattern.test(command.trim())) {
|
if (!gitCommitPattern.test(command)) {
|
||||||
return command;
|
return command;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -349,15 +350,27 @@ export class ShellToolInvocation extends BaseToolInvocation<
|
|||||||
|
|
||||||
Co-authored-by: ${gitCoAuthorSettings.name} <${gitCoAuthorSettings.email}>`;
|
Co-authored-by: ${gitCoAuthorSettings.name} <${gitCoAuthorSettings.email}>`;
|
||||||
|
|
||||||
// Handle different git commit patterns
|
// Handle different git commit patterns:
|
||||||
// Match -m "message" or -m 'message'
|
// Match -m "message" or -m 'message', including combined flags like -am
|
||||||
const messagePattern = /(-m\s+)(['"])((?:\\.|[^\\])*?)(\2)/;
|
// Use separate patterns to avoid ReDoS (catastrophic backtracking)
|
||||||
const match = command.match(messagePattern);
|
//
|
||||||
|
// 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 ? '"' : "'";
|
||||||
|
|
||||||
if (match) {
|
if (match) {
|
||||||
const [fullMatch, prefix, quote, existingMessage, closingQuote] = match;
|
const [fullMatch, prefix, existingMessage] = match;
|
||||||
const newMessage = existingMessage + coAuthor;
|
const newMessage = existingMessage + coAuthor;
|
||||||
const replacement = prefix + quote + newMessage + closingQuote;
|
const replacement = prefix + quote + newMessage + quote;
|
||||||
|
|
||||||
return command.replace(fullMatch, replacement);
|
return command.replace(fullMatch, replacement);
|
||||||
}
|
}
|
||||||
|
|||||||
118
packages/core/src/utils/schemaConverter.test.ts
Normal file
118
packages/core/src/utils/schemaConverter.test.ts
Normal file
@@ -0,0 +1,118 @@
|
|||||||
|
/**
|
||||||
|
* @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);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
135
packages/core/src/utils/schemaConverter.ts
Normal file
135
packages/core/src/utils/schemaConverter.ts
Normal file
@@ -0,0 +1,135 @@
|
|||||||
|
/**
|
||||||
|
* @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>;
|
||||||
|
}
|
||||||
@@ -13,9 +13,8 @@ npm install @qwen-code/sdk
|
|||||||
## Requirements
|
## Requirements
|
||||||
|
|
||||||
- Node.js >= 20.0.0
|
- Node.js >= 20.0.0
|
||||||
- [Qwen Code](https://github.com/QwenLM/qwen-code) >= 0.4.0 (stable) installed and accessible in PATH
|
|
||||||
|
|
||||||
> **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.
|
> From v0.1.1, the CLI is bundled with the SDK. So no standalone CLI installation is needed.
|
||||||
|
|
||||||
## Quick Start
|
## Quick Start
|
||||||
|
|
||||||
@@ -372,6 +371,23 @@ 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
|
## License
|
||||||
|
|
||||||
Apache-2.0 - see [LICENSE](./LICENSE) for details.
|
Apache-2.0 - see [LICENSE](./LICENSE) for details.
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@qwen-code/sdk",
|
"name": "@qwen-code/sdk",
|
||||||
"version": "0.5.1",
|
"version": "0.1.1",
|
||||||
"description": "TypeScript SDK for programmatic access to qwen-code CLI",
|
"description": "TypeScript SDK for programmatic access to qwen-code CLI",
|
||||||
"main": "./dist/index.cjs",
|
"main": "./dist/index.cjs",
|
||||||
"module": "./dist/index.mjs",
|
"module": "./dist/index.mjs",
|
||||||
@@ -45,7 +45,8 @@
|
|||||||
"node": ">=18.0.0"
|
"node": ">=18.0.0"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@modelcontextprotocol/sdk": "^1.0.4"
|
"@modelcontextprotocol/sdk": "^1.0.4",
|
||||||
|
"tiktoken": "^1.0.21"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@types/node": "^20.14.0",
|
"@types/node": "^20.14.0",
|
||||||
|
|||||||
@@ -91,3 +91,35 @@ if (existsSync(licenseSource)) {
|
|||||||
console.warn('Could not copy LICENSE:', error.message);
|
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');
|
||||||
|
|||||||
@@ -2,24 +2,16 @@
|
|||||||
* CLI path auto-detection and subprocess spawning utilities
|
* CLI path auto-detection and subprocess spawning utilities
|
||||||
*
|
*
|
||||||
* Supports multiple execution modes:
|
* Supports multiple execution modes:
|
||||||
* 1. Native binary: 'qwen' (production)
|
* 1. Bundled CLI: Node.js bundle included in the SDK package (default)
|
||||||
* 2. Node.js bundle: 'node /path/to/cli.js' (production validation)
|
* 2. Node.js bundle: 'node /path/to/cli.js' (custom path)
|
||||||
* 3. Bun bundle: 'bun /path/to/cli.js' (alternative runtime)
|
* 3. Bun bundle: 'bun /path/to/cli.js' (alternative runtime)
|
||||||
* 4. TypeScript source: 'tsx /path/to/index.ts' (development)
|
* 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 fs from 'node:fs';
|
||||||
import * as path from 'node:path';
|
import * as path from 'node:path';
|
||||||
import { execSync } from 'node:child_process';
|
import { execSync } from 'node:child_process';
|
||||||
|
import { fileURLToPath } from 'node:url';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Executable types supported by the SDK
|
* Executable types supported by the SDK
|
||||||
@@ -40,49 +32,38 @@ export type SpawnInfo = {
|
|||||||
originalInput: string;
|
originalInput: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
export function findNativeCliPath(): string {
|
function getBundledCliPath(): string | null {
|
||||||
const homeDir = process.env['HOME'] || process.env['USERPROFILE'] || '';
|
try {
|
||||||
|
const currentFile =
|
||||||
|
typeof __filename !== 'undefined'
|
||||||
|
? __filename
|
||||||
|
: fileURLToPath(import.meta.url);
|
||||||
|
|
||||||
const candidates: Array<string | undefined> = [
|
const currentDir = path.dirname(currentFile);
|
||||||
// 1. Environment variable (highest priority)
|
|
||||||
process.env['QWEN_CODE_CLI_PATH'],
|
|
||||||
|
|
||||||
// 2. Volta bin
|
const bundledCliPath = path.join(currentDir, 'cli', 'cli.js');
|
||||||
path.join(homeDir, '.volta', 'bin', 'qwen'),
|
|
||||||
|
|
||||||
// 3. Global npm installations
|
if (fs.existsSync(bundledCliPath)) {
|
||||||
path.join(homeDir, '.npm-global', 'bin', 'qwen'),
|
return bundledCliPath;
|
||||||
|
|
||||||
// 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);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function findNativeCliPath(): string {
|
||||||
|
const bundledCli = getBundledCliPath();
|
||||||
|
if (bundledCli) {
|
||||||
|
return bundledCli;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Not found - throw helpful error
|
|
||||||
throw new Error(
|
throw new Error(
|
||||||
'qwen CLI not found. Please:\n' +
|
'Bundled qwen CLI not found. The CLI should be included in the SDK package.\n' +
|
||||||
' 1. Install qwen globally: npm install -g qwen\n' +
|
'If you need to use a custom CLI, provide explicit executable:\n' +
|
||||||
' 2. Or provide explicit executable: query({ pathToQwenExecutable: "/path/to/qwen" })\n' +
|
' • query({ pathToQwenExecutable: "/path/to/cli.js" })\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' +
|
' • 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" })',
|
' • Force specific runtime: query({ pathToQwenExecutable: "bun:/path/to/cli.js" })',
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -38,6 +38,8 @@ describe('CLI Path Utilities', () => {
|
|||||||
mockFs.statSync.mockReturnValue({
|
mockFs.statSync.mockReturnValue({
|
||||||
isFile: () => true,
|
isFile: () => true,
|
||||||
} as ReturnType<typeof import('fs').statSync>);
|
} as ReturnType<typeof import('fs').statSync>);
|
||||||
|
// Default: return true for existsSync (can be overridden in specific tests)
|
||||||
|
mockFs.existsSync.mockReturnValue(true);
|
||||||
});
|
});
|
||||||
|
|
||||||
afterEach(() => {
|
afterEach(() => {
|
||||||
@@ -50,28 +52,26 @@ describe('CLI Path Utilities', () => {
|
|||||||
|
|
||||||
describe('parseExecutableSpec', () => {
|
describe('parseExecutableSpec', () => {
|
||||||
describe('auto-detection (no spec provided)', () => {
|
describe('auto-detection (no spec provided)', () => {
|
||||||
it('should auto-detect native CLI when no spec provided', () => {
|
it('should auto-detect bundled CLI when no spec provided', () => {
|
||||||
// Mock environment variable
|
// Mock existsSync to return true for bundled CLI
|
||||||
const originalEnv = process.env['QWEN_CODE_CLI_PATH'];
|
mockFs.existsSync.mockImplementation((p) => {
|
||||||
process.env['QWEN_CODE_CLI_PATH'] = '/usr/local/bin/qwen';
|
const pathStr = p.toString();
|
||||||
mockFs.existsSync.mockReturnValue(true);
|
return (
|
||||||
|
pathStr.includes('cli/cli.js') || pathStr.includes('cli\\cli.js')
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
const result = parseExecutableSpec();
|
const result = parseExecutableSpec();
|
||||||
|
|
||||||
expect(result).toEqual({
|
expect(result.executablePath).toContain('cli.js');
|
||||||
executablePath: path.resolve('/usr/local/bin/qwen'),
|
expect(result.isExplicitRuntime).toBe(false);
|
||||||
isExplicitRuntime: false,
|
|
||||||
});
|
|
||||||
|
|
||||||
// Restore env
|
|
||||||
process.env['QWEN_CODE_CLI_PATH'] = originalEnv;
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should throw when auto-detection fails', () => {
|
it('should throw when bundled CLI not found', () => {
|
||||||
mockFs.existsSync.mockReturnValue(false);
|
mockFs.existsSync.mockReturnValue(false);
|
||||||
|
|
||||||
expect(() => parseExecutableSpec()).toThrow(
|
expect(() => parseExecutableSpec()).toThrow(
|
||||||
'qwen CLI not found. Please:',
|
'Bundled qwen CLI not found',
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@@ -361,65 +361,44 @@ describe('CLI Path Utilities', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
describe('auto-detection fallback', () => {
|
describe('auto-detection fallback', () => {
|
||||||
it('should auto-detect when no spec provided', () => {
|
it('should auto-detect bundled CLI when no spec provided', () => {
|
||||||
// Mock environment variable
|
// Mock existsSync to return true for bundled CLI
|
||||||
const originalEnv = process.env['QWEN_CODE_CLI_PATH'];
|
mockFs.existsSync.mockImplementation((p) => {
|
||||||
process.env['QWEN_CODE_CLI_PATH'] = '/usr/local/bin/qwen';
|
const pathStr = p.toString();
|
||||||
|
return (
|
||||||
|
pathStr.includes('cli/cli.js') || pathStr.includes('cli\\cli.js')
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
const result = prepareSpawnInfo();
|
const result = prepareSpawnInfo();
|
||||||
|
|
||||||
expect(result).toEqual({
|
expect(result.command).toBe(process.execPath);
|
||||||
command: path.resolve('/usr/local/bin/qwen'),
|
expect(result.args[0]).toContain('cli.js');
|
||||||
args: [],
|
expect(result.type).toBe('node');
|
||||||
type: 'native',
|
expect(result.originalInput).toBe('');
|
||||||
originalInput: '',
|
|
||||||
});
|
|
||||||
|
|
||||||
// Restore env
|
|
||||||
process.env['QWEN_CODE_CLI_PATH'] = originalEnv;
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('findNativeCliPath', () => {
|
describe('findNativeCliPath', () => {
|
||||||
it('should find CLI from environment variable', () => {
|
it('should find bundled CLI', () => {
|
||||||
const originalEnv = process.env['QWEN_CODE_CLI_PATH'];
|
// Mock existsSync to return true for bundled CLI
|
||||||
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) => {
|
mockFs.existsSync.mockImplementation((p) => {
|
||||||
return p.toString().includes(voltaBinPath);
|
const pathStr = p.toString();
|
||||||
|
return (
|
||||||
|
pathStr.includes('cli/cli.js') || pathStr.includes('cli\\cli.js')
|
||||||
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
const result = findNativeCliPath();
|
const result = findNativeCliPath();
|
||||||
|
|
||||||
expect(result).toContain(voltaBinPath);
|
expect(result).toContain('cli.js');
|
||||||
|
|
||||||
process.env['QWEN_CODE_CLI_PATH'] = originalEnv;
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should throw descriptive error when CLI not found', () => {
|
it('should throw descriptive error when bundled CLI not found', () => {
|
||||||
const originalEnv = process.env['QWEN_CODE_CLI_PATH'];
|
|
||||||
delete process.env['QWEN_CODE_CLI_PATH'];
|
|
||||||
mockFs.existsSync.mockReturnValue(false);
|
mockFs.existsSync.mockReturnValue(false);
|
||||||
|
|
||||||
expect(() => findNativeCliPath()).toThrow('qwen CLI not found. Please:');
|
expect(() => findNativeCliPath()).toThrow('Bundled qwen CLI not found');
|
||||||
|
|
||||||
process.env['QWEN_CODE_CLI_PATH'] = originalEnv;
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -634,13 +613,10 @@ describe('CLI Path Utilities', () => {
|
|||||||
mockFs.existsSync.mockReturnValue(false);
|
mockFs.existsSync.mockReturnValue(false);
|
||||||
|
|
||||||
expect(() => parseExecutableSpec('/missing/file')).toThrow(
|
expect(() => parseExecutableSpec('/missing/file')).toThrow(
|
||||||
'Set QWEN_CODE_CLI_PATH environment variable',
|
'Executable file not found at',
|
||||||
);
|
);
|
||||||
expect(() => parseExecutableSpec('/missing/file')).toThrow(
|
expect(() => parseExecutableSpec('/missing/file')).toThrow(
|
||||||
'Install qwen globally: npm install -g qwen',
|
'Please check the file path and ensure the file exists',
|
||||||
);
|
|
||||||
expect(() => parseExecutableSpec('/missing/file')).toThrow(
|
|
||||||
'Force specific runtime: bun:/path/to/cli.js or tsx:/path/to/index.ts',
|
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -35,7 +35,7 @@ function npmBin() {
|
|||||||
function run(cmd, args, opts = {}) {
|
function run(cmd, args, opts = {}) {
|
||||||
const res = spawnSync(cmd, args, {
|
const res = spawnSync(cmd, args, {
|
||||||
stdio: 'inherit',
|
stdio: 'inherit',
|
||||||
shell: process.platform === 'win32' ? true : false,
|
shell: process.platform === 'win32',
|
||||||
...opts,
|
...opts,
|
||||||
});
|
});
|
||||||
if (res.error) {
|
if (res.error) {
|
||||||
|
|||||||
@@ -54,27 +54,31 @@ export class AcpSessionManager {
|
|||||||
};
|
};
|
||||||
|
|
||||||
return new Promise((resolve, reject) => {
|
return new Promise((resolve, reject) => {
|
||||||
// different timeout durations based on methods
|
// No timeout for session_prompt as LLM tasks can take 5-10 minutes or longer
|
||||||
let timeoutDuration = 60000; // default 60 seconds
|
// The request should always terminate with a stop_reason
|
||||||
if (
|
let timeoutId: NodeJS.Timeout | undefined;
|
||||||
method === AGENT_METHODS.session_prompt ||
|
let timeoutDuration: number | undefined;
|
||||||
method === AGENT_METHODS.initialize
|
|
||||||
) {
|
|
||||||
timeoutDuration = 120000; // 2min for session_prompt and initialize
|
|
||||||
}
|
|
||||||
|
|
||||||
const timeoutId = setTimeout(() => {
|
if (method !== AGENT_METHODS.session_prompt) {
|
||||||
pendingRequests.delete(id);
|
// Set timeout for other methods
|
||||||
reject(new Error(`Request ${method} timed out`));
|
timeoutDuration = method === AGENT_METHODS.initialize ? 120000 : 60000;
|
||||||
}, timeoutDuration);
|
timeoutId = setTimeout(() => {
|
||||||
|
pendingRequests.delete(id);
|
||||||
|
reject(new Error(`Request ${method} timed out`));
|
||||||
|
}, timeoutDuration);
|
||||||
|
}
|
||||||
|
|
||||||
const pendingRequest: PendingRequest<T> = {
|
const pendingRequest: PendingRequest<T> = {
|
||||||
resolve: (value: T) => {
|
resolve: (value: T) => {
|
||||||
clearTimeout(timeoutId);
|
if (timeoutId) {
|
||||||
|
clearTimeout(timeoutId);
|
||||||
|
}
|
||||||
resolve(value);
|
resolve(value);
|
||||||
},
|
},
|
||||||
reject: (error: Error) => {
|
reject: (error: Error) => {
|
||||||
clearTimeout(timeoutId);
|
if (timeoutId) {
|
||||||
|
clearTimeout(timeoutId);
|
||||||
|
}
|
||||||
reject(error);
|
reject(error);
|
||||||
},
|
},
|
||||||
timeoutId,
|
timeoutId,
|
||||||
|
|||||||
@@ -144,10 +144,7 @@ export const InputForm: React.FC<InputFormProps> = ({
|
|||||||
: '';
|
: '';
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<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)]">
|
||||||
className="p-1 px-4 pb-4 absolute bottom-0 left-0 right-0"
|
|
||||||
style={{ backgroundColor: 'var(--app-primary-background)' }}
|
|
||||||
>
|
|
||||||
<div className="block">
|
<div className="block">
|
||||||
<form className="composer-form" onSubmit={onSubmit}>
|
<form className="composer-form" onSubmit={onSubmit}>
|
||||||
{/* Inner background layer */}
|
{/* Inner background layer */}
|
||||||
|
|||||||
@@ -48,5 +48,5 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.assistant-message-container.assistant-message-loading::after {
|
.assistant-message-container.assistant-message-loading::after {
|
||||||
display: none
|
display: none;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -172,7 +172,8 @@
|
|||||||
|
|
||||||
/* Loading animation for toolcall header */
|
/* Loading animation for toolcall header */
|
||||||
@keyframes toolcallHeaderPulse {
|
@keyframes toolcallHeaderPulse {
|
||||||
0%, 100% {
|
0%,
|
||||||
|
100% {
|
||||||
opacity: 1;
|
opacity: 1;
|
||||||
}
|
}
|
||||||
50% {
|
50% {
|
||||||
|
|||||||
@@ -152,6 +152,24 @@ export class SessionMessageHandler extends BaseMessageHandler {
|
|||||||
this.currentStreamContent = '';
|
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.
|
* Prompt user to login and invoke the registered login handler/command.
|
||||||
* Returns true if a login was initiated.
|
* Returns true if a login was initiated.
|
||||||
@@ -373,10 +391,7 @@ export class SessionMessageHandler extends BaseMessageHandler {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
this.sendToWebView({
|
this.sendStreamEnd();
|
||||||
type: 'streamEnd',
|
|
||||||
data: { timestamp: Date.now() },
|
|
||||||
});
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('[SessionMessageHandler] Error sending message:', error);
|
console.error('[SessionMessageHandler] Error sending message:', error);
|
||||||
|
|
||||||
@@ -398,10 +413,7 @@ export class SessionMessageHandler extends BaseMessageHandler {
|
|||||||
if (isAbortLike) {
|
if (isAbortLike) {
|
||||||
// Do not show VS Code error popup for intentional cancellations.
|
// Do not show VS Code error popup for intentional cancellations.
|
||||||
// Ensure the webview knows the stream ended due to user action.
|
// Ensure the webview knows the stream ended due to user action.
|
||||||
this.sendToWebView({
|
this.sendStreamEnd('user_cancelled');
|
||||||
type: 'streamEnd',
|
|
||||||
data: { timestamp: Date.now(), reason: 'user_cancelled' },
|
|
||||||
});
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
// Check for session not found error and handle it appropriately
|
// Check for session not found error and handle it appropriately
|
||||||
@@ -423,12 +435,39 @@ export class SessionMessageHandler extends BaseMessageHandler {
|
|||||||
type: 'sessionExpired',
|
type: 'sessionExpired',
|
||||||
data: { message: 'Session expired. Please login again.' },
|
data: { message: 'Session expired. Please login again.' },
|
||||||
});
|
});
|
||||||
|
this.sendStreamEnd('session_expired');
|
||||||
} else {
|
} else {
|
||||||
vscode.window.showErrorMessage(`Error sending message: ${error}`);
|
const isTimeoutError =
|
||||||
this.sendToWebView({
|
lower.includes('timeout') || lower.includes('timed out');
|
||||||
type: 'error',
|
if (isTimeoutError) {
|
||||||
data: { message: errorMsg },
|
// 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');
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -15,6 +15,14 @@ import type { ToolCallUpdate } from '../../types/chatTypes.js';
|
|||||||
import type { ApprovalModeValue } from '../../types/approvalModeValueTypes.js';
|
import type { ApprovalModeValue } from '../../types/approvalModeValueTypes.js';
|
||||||
import type { PlanEntry } from '../../types/chatTypes.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 {
|
interface UseWebViewMessagesProps {
|
||||||
// Session management
|
// Session management
|
||||||
sessionManagement: {
|
sessionManagement: {
|
||||||
@@ -364,12 +372,12 @@ export const useWebViewMessages = ({
|
|||||||
).toLowerCase();
|
).toLowerCase();
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Handle different types of stream end reasons:
|
* Handle different types of stream end reasons that require a full reset:
|
||||||
* - 'user_cancelled': User explicitly cancelled operation
|
* - 'user_cancelled' / 'cancelled': user explicitly cancelled
|
||||||
* - 'cancelled': General cancellation
|
* - 'timeout' / 'error' / 'session_expired': request failed unexpectedly
|
||||||
* For these cases, immediately clear all active states
|
* For these cases, immediately clear all active states.
|
||||||
*/
|
*/
|
||||||
if (reason === 'user_cancelled' || reason === 'cancelled') {
|
if (FORCE_CLEAR_STREAM_END_REASONS.has(reason)) {
|
||||||
// Clear active execution tool call tracking, reset state
|
// Clear active execution tool call tracking, reset state
|
||||||
activeExecToolCallsRef.current.clear();
|
activeExecToolCallsRef.current.clear();
|
||||||
// Clear waiting response state to ensure UI returns to normal
|
// Clear waiting response state to ensure UI returns to normal
|
||||||
@@ -393,6 +401,9 @@ export const useWebViewMessages = ({
|
|||||||
}
|
}
|
||||||
|
|
||||||
case 'error':
|
case 'error':
|
||||||
|
handlers.messageHandling.endStreaming();
|
||||||
|
handlers.messageHandling.clearThinking();
|
||||||
|
activeExecToolCallsRef.current.clear();
|
||||||
handlers.messageHandling.clearWaitingForResponse();
|
handlers.messageHandling.clearWaitingForResponse();
|
||||||
break;
|
break;
|
||||||
|
|
||||||
|
|||||||
@@ -43,7 +43,7 @@
|
|||||||
|
|
||||||
/* Composer: form wrapper */
|
/* Composer: form wrapper */
|
||||||
.composer-form {
|
.composer-form {
|
||||||
@apply relative flex flex-col max-w-[680px] mx-auto rounded-large border shadow-sm transition-colors duration-200;
|
@apply relative flex flex-col max-w-[680px] mx-auto rounded-large border shadow-sm transition-colors duration-200 z-[1];
|
||||||
background: var(--app-input-secondary-background);
|
background: var(--app-input-secondary-background);
|
||||||
border-color: var(--app-input-border);
|
border-color: var(--app-input-border);
|
||||||
color: var(--app-input-foreground);
|
color: var(--app-input-foreground);
|
||||||
@@ -51,7 +51,8 @@
|
|||||||
.composer-form:focus-within {
|
.composer-form:focus-within {
|
||||||
/* match existing highlight behavior */
|
/* match existing highlight behavior */
|
||||||
border-color: var(--app-input-highlight);
|
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 */
|
/* Composer: input editable area */
|
||||||
@@ -66,7 +67,7 @@
|
|||||||
The data attribute is needed because some browsers insert a <br> in
|
The data attribute is needed because some browsers insert a <br> in
|
||||||
contentEditable, which breaks :empty matching. */
|
contentEditable, which breaks :empty matching. */
|
||||||
.composer-input:empty:before,
|
.composer-input:empty:before,
|
||||||
.composer-input[data-empty="true"]::before {
|
.composer-input[data-empty='true']::before {
|
||||||
content: attr(data-placeholder);
|
content: attr(data-placeholder);
|
||||||
color: var(--app-input-placeholder-foreground);
|
color: var(--app-input-placeholder-foreground);
|
||||||
pointer-events: none;
|
pointer-events: none;
|
||||||
@@ -80,7 +81,7 @@
|
|||||||
outline: none;
|
outline: none;
|
||||||
}
|
}
|
||||||
.composer-input:disabled,
|
.composer-input:disabled,
|
||||||
.composer-input[contenteditable="false"] {
|
.composer-input[contenteditable='false'] {
|
||||||
color: #999;
|
color: #999;
|
||||||
cursor: not-allowed;
|
cursor: not-allowed;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -67,7 +67,7 @@ const env = {
|
|||||||
if (process.env.DEBUG) {
|
if (process.env.DEBUG) {
|
||||||
// If this is not set, the debugger will pause on the outer process rather
|
// If this is not set, the debugger will pause on the outer process rather
|
||||||
// than the relaunched process making it harder to debug.
|
// than the relaunched process making it harder to debug.
|
||||||
env.GEMINI_CLI_NO_RELAUNCH = 'true';
|
env.QWEN_CODE_NO_RELAUNCH = 'true';
|
||||||
}
|
}
|
||||||
// Use process.cwd() to inherit the working directory from launch.json cwd setting
|
// Use process.cwd() to inherit the working directory from launch.json cwd setting
|
||||||
// This allows debugging from a specific directory (e.g., .todo)
|
// This allows debugging from a specific directory (e.g., .todo)
|
||||||
|
|||||||
Reference in New Issue
Block a user