mirror of
https://github.com/QwenLM/qwen-code.git
synced 2025-12-24 18:49:13 +00:00
Compare commits
101 Commits
mingholy/f
...
feat/javas
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
422998d7f0 | ||
|
|
68628bf952 | ||
|
|
e5efad89e0 | ||
|
|
e09bb5f5c0 | ||
|
|
24d11179d8 | ||
|
|
2ef8b6f350 | ||
|
|
5779f7ab1d | ||
|
|
642dda0315 | ||
|
|
bbbdeb280d | ||
|
|
0d43ddee2a | ||
|
|
50e03f2dd6 | ||
|
|
f440ff2f7f | ||
|
|
9a6b0abc37 | ||
|
|
00547ba439 | ||
|
|
fc1dac9dc7 | ||
|
|
338eb9038d | ||
|
|
e0b9044833 | ||
|
|
f33f43e2f7 | ||
|
|
4e7929850c | ||
|
|
9cc5c3ed8f | ||
|
|
a92be72e88 | ||
|
|
52cd1da4a7 | ||
|
|
c5c556a326 | ||
|
|
a8a863581b | ||
|
|
e4468cfcbc | ||
|
|
3bf30ead67 | ||
|
|
a786f61e49 | ||
|
|
fa7d857945 | ||
|
|
90489933fd | ||
|
|
3354b56a05 | ||
|
|
d40447cee4 | ||
|
|
ba87cf63f6 | ||
|
|
00a8c6a924 | ||
|
|
156134d3d4 | ||
|
|
b720209888 | ||
|
|
dfe667c364 | ||
|
|
1386fba278 | ||
|
|
d942250905 | ||
|
|
ec32a24508 | ||
|
|
80bb2890df | ||
|
|
abd9ee2a7b | ||
|
|
b8df689e31 | ||
|
|
c2b59038ae | ||
|
|
27bf42b4f5 | ||
|
|
e610578ecc | ||
|
|
d07ae35c90 | ||
|
|
cb59b5a9dc | ||
|
|
235159216e | ||
|
|
93b30cca29 | ||
|
|
01e62a2120 | ||
|
|
d464f61b72 | ||
|
|
f866f7f071 | ||
|
|
7eabf543b4 | ||
|
|
8106a6b0f4 | ||
|
|
c0839dceac | ||
|
|
12f84fb730 | ||
|
|
f9a1ee2442 | ||
|
|
f824004f99 | ||
|
|
e274b4469a | ||
|
|
a4e3d764d3 | ||
|
|
0a39c91264 | ||
|
|
8fd7490d8f | ||
|
|
4f1766e19a | ||
|
|
bf52c6db0f | ||
|
|
9267677d38 | ||
|
|
fb8412a96a | ||
|
|
2837aa6b7c | ||
|
|
49b3e0dc92 | ||
|
|
d1a6b3207e | ||
|
|
1c62499977 | ||
|
|
4b8b4e2fe8 | ||
|
|
9942b2b877 | ||
|
|
f9da1b819e | ||
|
|
36fb6b8291 | ||
|
|
f47c762620 | ||
|
|
573c33f68a | ||
|
|
32c085cf7d | ||
|
|
725843f9b3 | ||
|
|
54fd63f04b | ||
|
|
59c3d3d0f9 | ||
|
|
4930a24d07 | ||
|
|
7a97fcd5f1 | ||
|
|
4504c7a0ac | ||
|
|
56a62bcb2a | ||
|
|
1098c23b26 | ||
|
|
e76f47512c | ||
|
|
f5c868702b | ||
|
|
0d40cf2213 | ||
|
|
12877ac849 | ||
|
|
2de50ae436 | ||
|
|
a761be80a5 | ||
|
|
6c77303172 | ||
|
|
4f2b2d0a3e | ||
|
|
44794121a8 | ||
|
|
84cccfe99a | ||
|
|
b6a3ab11e0 | ||
|
|
2f0fa267c8 | ||
|
|
fa6ae0a324 | ||
|
|
387be44866 | ||
|
|
51b82771da | ||
|
|
629cd14fad |
9
.github/workflows/e2e.yml
vendored
9
.github/workflows/e2e.yml
vendored
@@ -18,8 +18,6 @@ jobs:
|
||||
- 'sandbox:docker'
|
||||
node-version:
|
||||
- '20.x'
|
||||
- '22.x'
|
||||
- '24.x'
|
||||
steps:
|
||||
- name: 'Checkout'
|
||||
uses: 'actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8' # ratchet:actions/checkout@v5
|
||||
@@ -67,10 +65,13 @@ jobs:
|
||||
OPENAI_BASE_URL: '${{ secrets.OPENAI_BASE_URL }}'
|
||||
OPENAI_MODEL: '${{ secrets.OPENAI_MODEL }}'
|
||||
KEEP_OUTPUT: 'true'
|
||||
SANDBOX: '${{ matrix.sandbox }}'
|
||||
VERBOSE: 'true'
|
||||
run: |-
|
||||
npm run "test:integration:${SANDBOX}"
|
||||
if [[ "${{ matrix.sandbox }}" == "sandbox:docker" ]]; then
|
||||
npm run test:integration:sandbox:docker
|
||||
else
|
||||
npm run test:integration:sandbox:none
|
||||
fi
|
||||
|
||||
e2e-test-macos:
|
||||
name: 'E2E Test - macOS'
|
||||
|
||||
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 }}'
|
||||
MANUAL_VERSION: '${{ inputs.version }}'
|
||||
|
||||
- name: 'Build CLI Bundle'
|
||||
run: |
|
||||
npm run build
|
||||
npm run bundle
|
||||
|
||||
- name: 'Run Tests'
|
||||
if: |-
|
||||
${{ github.event.inputs.force_skip_tests != 'true' }}
|
||||
@@ -132,13 +137,6 @@ jobs:
|
||||
OPENAI_BASE_URL: '${{ secrets.OPENAI_BASE_URL }}'
|
||||
OPENAI_MODEL: '${{ secrets.OPENAI_MODEL }}'
|
||||
|
||||
- name: 'Build CLI for Integration Tests'
|
||||
if: |-
|
||||
${{ github.event.inputs.force_skip_tests != 'true' }}
|
||||
run: |
|
||||
npm run build
|
||||
npm run bundle
|
||||
|
||||
- name: 'Run SDK Integration Tests'
|
||||
if: |-
|
||||
${{ github.event.inputs.force_skip_tests != 'true' }}
|
||||
|
||||
4
.github/workflows/release.yml
vendored
4
.github/workflows/release.yml
vendored
@@ -133,8 +133,8 @@ jobs:
|
||||
${{ github.event.inputs.force_skip_tests != 'true' }}
|
||||
run: |
|
||||
npm run preflight
|
||||
npm run test:integration:sandbox:none
|
||||
npm run test:integration:sandbox:docker
|
||||
npm run test:integration:cli:sandbox:none
|
||||
npm run test:integration:cli:sandbox:docker
|
||||
env:
|
||||
OPENAI_API_KEY: '${{ secrets.OPENAI_API_KEY }}'
|
||||
OPENAI_BASE_URL: '${{ secrets.OPENAI_BASE_URL }}'
|
||||
|
||||
110
CONTRIBUTING.md
110
CONTRIBUTING.md
@@ -2,27 +2,6 @@
|
||||
|
||||
We would love to accept your patches and contributions to this project.
|
||||
|
||||
## Before you begin
|
||||
|
||||
### Sign our Contributor License Agreement
|
||||
|
||||
Contributions to this project must be accompanied by a
|
||||
[Contributor License Agreement](https://cla.developers.google.com/about) (CLA).
|
||||
You (or your employer) retain the copyright to your contribution; this simply
|
||||
gives us permission to use and redistribute your contributions as part of the
|
||||
project.
|
||||
|
||||
If you or your current employer have already signed the Google CLA (even if it
|
||||
was for a different project), you probably don't need to do it again.
|
||||
|
||||
Visit <https://cla.developers.google.com/> to see your current agreements or to
|
||||
sign a new one.
|
||||
|
||||
### Review our Community Guidelines
|
||||
|
||||
This project follows [Google's Open Source Community
|
||||
Guidelines](https://opensource.google/conduct/).
|
||||
|
||||
## Contribution Process
|
||||
|
||||
### Code Reviews
|
||||
@@ -74,12 +53,6 @@ Your PR should have a clear, descriptive title and a detailed description of the
|
||||
|
||||
In the PR description, explain the "why" behind your changes and link to the relevant issue (e.g., `Fixes #123`).
|
||||
|
||||
## Forking
|
||||
|
||||
If you are forking the repository you will be able to run the Build, Test and Integration test workflows. However in order to make the integration tests run you'll need to add a [GitHub Repository Secret](https://docs.github.com/en/actions/security-for-github-actions/security-guides/using-secrets-in-github-actions#creating-secrets-for-a-repository) with a value of `GEMINI_API_KEY` and set that to a valid API key that you have available. Your key and secret are private to your repo; no one without access can see your key and you cannot see any secrets related to this repo.
|
||||
|
||||
Additionally you will need to click on the `Actions` tab and enable workflows for your repository, you'll find it's the large blue button in the center of the screen.
|
||||
|
||||
## Development Setup and Workflow
|
||||
|
||||
This section guides contributors on how to build, modify, and understand the development setup of this project.
|
||||
@@ -98,8 +71,8 @@ This section guides contributors on how to build, modify, and understand the dev
|
||||
To clone the repository:
|
||||
|
||||
```bash
|
||||
git clone https://github.com/google-gemini/gemini-cli.git # Or your fork's URL
|
||||
cd gemini-cli
|
||||
git clone https://github.com/QwenLM/qwen-code.git # Or your fork's URL
|
||||
cd qwen-code
|
||||
```
|
||||
|
||||
To install dependencies defined in `package.json` as well as root dependencies:
|
||||
@@ -118,9 +91,9 @@ This command typically compiles TypeScript to JavaScript, bundles assets, and pr
|
||||
|
||||
### Enabling Sandboxing
|
||||
|
||||
[Sandboxing](#sandboxing) is highly recommended and requires, at a minimum, setting `GEMINI_SANDBOX=true` in your `~/.env` and ensuring a sandboxing provider (e.g. `macOS Seatbelt`, `docker`, or `podman`) is available. See [Sandboxing](#sandboxing) for details.
|
||||
[Sandboxing](#sandboxing) is highly recommended and requires, at a minimum, setting `QWEN_SANDBOX=true` in your `~/.env` and ensuring a sandboxing provider (e.g. `macOS Seatbelt`, `docker`, or `podman`) is available. See [Sandboxing](#sandboxing) for details.
|
||||
|
||||
To build both the `gemini` CLI utility and the sandbox container, run `build:all` from the root directory:
|
||||
To build both the `qwen-code` CLI utility and the sandbox container, run `build:all` from the root directory:
|
||||
|
||||
```bash
|
||||
npm run build:all
|
||||
@@ -130,13 +103,13 @@ To skip building the sandbox container, you can use `npm run build` instead.
|
||||
|
||||
### Running
|
||||
|
||||
To start the Gemini CLI from the source code (after building), run the following command from the root directory:
|
||||
To start the Qwen Code application from the source code (after building), run the following command from the root directory:
|
||||
|
||||
```bash
|
||||
npm start
|
||||
```
|
||||
|
||||
If you'd like to run the source build outside of the gemini-cli folder, you can utilize `npm link path/to/gemini-cli/packages/cli` (see: [docs](https://docs.npmjs.com/cli/v9/commands/npm-link)) or `alias gemini="node path/to/gemini-cli/packages/cli"` to run with `gemini`
|
||||
If you'd like to run the source build outside of the qwen-code folder, you can utilize `npm link path/to/qwen-code/packages/cli` (see: [docs](https://docs.npmjs.com/cli/v9/commands/npm-link)) to run with `qwen-code`
|
||||
|
||||
### Running Tests
|
||||
|
||||
@@ -154,7 +127,7 @@ This will run tests located in the `packages/core` and `packages/cli` directorie
|
||||
|
||||
#### Integration Tests
|
||||
|
||||
The integration tests are designed to validate the end-to-end functionality of the Gemini CLI. They are not run as part of the default `npm run test` command.
|
||||
The integration tests are designed to validate the end-to-end functionality of Qwen Code. They are not run as part of the default `npm run test` command.
|
||||
|
||||
To run the integration tests, use the following command:
|
||||
|
||||
@@ -209,19 +182,61 @@ npm run lint
|
||||
### Coding Conventions
|
||||
|
||||
- Please adhere to the coding style, patterns, and conventions used throughout the existing codebase.
|
||||
- Consult [QWEN.md](https://github.com/QwenLM/qwen-code/blob/main/QWEN.md) (typically found in the project root) for specific instructions related to AI-assisted development, including conventions for React, comments, and Git usage.
|
||||
- **Imports:** Pay special attention to import paths. The project uses ESLint to enforce restrictions on relative imports between packages.
|
||||
|
||||
### Project Structure
|
||||
|
||||
- `packages/`: Contains the individual sub-packages of the project.
|
||||
- `cli/`: The command-line interface.
|
||||
- `core/`: The core backend logic for the Gemini CLI.
|
||||
- `core/`: The core backend logic for Qwen Code.
|
||||
- `docs/`: Contains all project documentation.
|
||||
- `scripts/`: Utility scripts for building, testing, and development tasks.
|
||||
|
||||
For more detailed architecture, see `docs/architecture.md`.
|
||||
|
||||
## Documentation Development
|
||||
|
||||
This section describes how to develop and preview the documentation locally.
|
||||
|
||||
### Prerequisites
|
||||
|
||||
1. Ensure you have Node.js (version 18+) installed
|
||||
2. Have npm or yarn available
|
||||
|
||||
### Setup Documentation Site Locally
|
||||
|
||||
To work on the documentation and preview changes locally:
|
||||
|
||||
1. Navigate to the `docs-site` directory:
|
||||
|
||||
```bash
|
||||
cd docs-site
|
||||
```
|
||||
|
||||
2. Install dependencies:
|
||||
|
||||
```bash
|
||||
npm install
|
||||
```
|
||||
|
||||
3. Link the documentation content from the main `docs` directory:
|
||||
|
||||
```bash
|
||||
npm run link
|
||||
```
|
||||
|
||||
This creates a symbolic link from `../docs` to `content` in the docs-site project, allowing the documentation content to be served by the Next.js site.
|
||||
|
||||
4. Start the development server:
|
||||
|
||||
```bash
|
||||
npm run dev
|
||||
```
|
||||
|
||||
5. Open [http://localhost:3000](http://localhost:3000) in your browser to see the documentation site with live updates as you make changes.
|
||||
|
||||
Any changes made to the documentation files in the main `docs` directory will be reflected immediately in the documentation site.
|
||||
|
||||
## Debugging
|
||||
|
||||
### VS Code:
|
||||
@@ -231,7 +246,7 @@ For more detailed architecture, see `docs/architecture.md`.
|
||||
```bash
|
||||
npm run debug
|
||||
```
|
||||
This command runs `node --inspect-brk dist/gemini.js` within the `packages/cli` directory, pausing execution until a debugger attaches. You can then open `chrome://inspect` in your Chrome browser to connect to the debugger.
|
||||
This command runs `node --inspect-brk dist/index.js` within the `packages/cli` directory, pausing execution until a debugger attaches. You can then open `chrome://inspect` in your Chrome browser to connect to the debugger.
|
||||
2. In VS Code, use the "Attach" launch configuration (found in `.vscode/launch.json`).
|
||||
|
||||
Alternatively, you can use the "Launch Program" configuration in VS Code if you prefer to launch the currently open file directly, but 'F5' is generally recommended.
|
||||
@@ -239,16 +254,16 @@ Alternatively, you can use the "Launch Program" configuration in VS Code if you
|
||||
To hit a breakpoint inside the sandbox container run:
|
||||
|
||||
```bash
|
||||
DEBUG=1 gemini
|
||||
DEBUG=1 qwen-code
|
||||
```
|
||||
|
||||
**Note:** If you have `DEBUG=true` in a project's `.env` file, it won't affect gemini-cli due to automatic exclusion. Use `.gemini/.env` files for gemini-cli specific debug settings.
|
||||
**Note:** If you have `DEBUG=true` in a project's `.env` file, it won't affect qwen-code due to automatic exclusion. Use `.qwen-code/.env` files for qwen-code specific debug settings.
|
||||
|
||||
### React DevTools
|
||||
|
||||
To debug the CLI's React-based UI, you can use React DevTools. Ink, the library used for the CLI's interface, is compatible with React DevTools version 4.x.
|
||||
|
||||
1. **Start the Gemini CLI in development mode:**
|
||||
1. **Start the Qwen Code application in development mode:**
|
||||
|
||||
```bash
|
||||
DEV=true npm start
|
||||
@@ -270,23 +285,10 @@ To debug the CLI's React-based UI, you can use React DevTools. Ink, the library
|
||||
```
|
||||
|
||||
Your running CLI application should then connect to React DevTools.
|
||||

|
||||
|
||||
## Sandboxing
|
||||
|
||||
### macOS Seatbelt
|
||||
|
||||
On macOS, `qwen` uses Seatbelt (`sandbox-exec`) under a `permissive-open` profile (see `packages/cli/src/utils/sandbox-macos-permissive-open.sb`) that restricts writes to the project folder but otherwise allows all other operations and outbound network traffic ("open") by default. You can switch to a `restrictive-closed` profile (see `packages/cli/src/utils/sandbox-macos-restrictive-closed.sb`) that declines all operations and outbound network traffic ("closed") by default by setting `SEATBELT_PROFILE=restrictive-closed` in your environment or `.env` file. Available built-in profiles are `{permissive,restrictive}-{open,closed,proxied}` (see below for proxied networking). You can also switch to a custom profile `SEATBELT_PROFILE=<profile>` if you also create a file `.qwen/sandbox-macos-<profile>.sb` under your project settings directory `.qwen`.
|
||||
|
||||
### Container-based Sandboxing (All Platforms)
|
||||
|
||||
For stronger container-based sandboxing on macOS or other platforms, you can set `GEMINI_SANDBOX=true|docker|podman|<command>` in your environment or `.env` file. The specified command (or if `true` then either `docker` or `podman`) must be installed on the host machine. Once enabled, `npm run build:all` will build a minimal container ("sandbox") image and `npm start` will launch inside a fresh instance of that container. The first build can take 20-30s (mostly due to downloading of the base image) but after that both build and start overhead should be minimal. Default builds (`npm run build`) will not rebuild the sandbox.
|
||||
|
||||
Container-based sandboxing mounts the project directory (and system temp directory) with read-write access and is started/stopped/removed automatically as you start/stop Gemini CLI. Files created within the sandbox should be automatically mapped to your user/group on host machine. You can easily specify additional mounts, ports, or environment variables by setting `SANDBOX_{MOUNTS,PORTS,ENV}` as needed. You can also fully customize the sandbox for your projects by creating the files `.qwen/sandbox.Dockerfile` and/or `.qwen/sandbox.bashrc` under your project settings directory (`.qwen`) and running `qwen` with `BUILD_SANDBOX=1` to trigger building of your custom sandbox.
|
||||
|
||||
#### Proxied Networking
|
||||
|
||||
All sandboxing methods, including macOS Seatbelt using `*-proxied` profiles, support restricting outbound network traffic through a custom proxy server that can be specified as `GEMINI_SANDBOX_PROXY_COMMAND=<command>`, where `<command>` must start a proxy server that listens on `:::8877` for relevant requests. See `docs/examples/proxy-script.md` for a minimal proxy that only allows `HTTPS` connections to `example.com:443` (e.g. `curl https://example.com`) and declines all other requests. The proxy is started and stopped automatically alongside the sandbox.
|
||||
> TBD
|
||||
|
||||
## Manual Publish
|
||||
|
||||
|
||||
10
Makefile
10
Makefile
@@ -1,9 +1,9 @@
|
||||
# Makefile for gemini-cli
|
||||
# Makefile for qwen-code
|
||||
|
||||
.PHONY: help install build build-sandbox build-all test lint format preflight clean start debug release run-npx create-alias
|
||||
|
||||
help:
|
||||
@echo "Makefile for gemini-cli"
|
||||
@echo "Makefile for qwen-code"
|
||||
@echo ""
|
||||
@echo "Usage:"
|
||||
@echo " make install - Install npm dependencies"
|
||||
@@ -14,11 +14,11 @@ help:
|
||||
@echo " make format - Format the code"
|
||||
@echo " make preflight - Run formatting, linting, and tests"
|
||||
@echo " make clean - Remove generated files"
|
||||
@echo " make start - Start the Gemini CLI"
|
||||
@echo " make debug - Start the Gemini CLI in debug mode"
|
||||
@echo " make start - Start the Qwen Code CLI"
|
||||
@echo " make debug - Start the Qwen Code CLI in debug mode"
|
||||
@echo ""
|
||||
@echo " make run-npx - Run the CLI using npx (for testing the published package)"
|
||||
@echo " make create-alias - Create a 'gemini' alias for your shell"
|
||||
@echo " make create-alias - Create a 'qwen' alias for your shell"
|
||||
|
||||
install:
|
||||
npm install
|
||||
|
||||
410
README.md
410
README.md
@@ -1,382 +1,152 @@
|
||||
# Qwen Code
|
||||
|
||||
<div align="center">
|
||||
|
||||

|
||||
|
||||
[](https://www.npmjs.com/package/@qwen-code/qwen-code)
|
||||
[](./LICENSE)
|
||||
[](https://nodejs.org/)
|
||||
[](https://www.npmjs.com/package/@qwen-code/qwen-code)
|
||||
|
||||
**AI-powered command-line workflow tool for developers**
|
||||
**An open-source AI agent that lives in your terminal.**
|
||||
|
||||
[Installation](#installation) • [Quick Start](#quick-start) • [Features](#key-features) • [Documentation](./docs/) • [Contributing](./CONTRIBUTING.md)
|
||||
<a href="https://qwenlm.github.io/qwen-code-docs/zh/users/overview">中文</a> |
|
||||
<a href="https://qwenlm.github.io/qwen-code-docs/de/users/overview">Deutsch</a> |
|
||||
<a href="https://qwenlm.github.io/qwen-code-docs/fr/users/overview">français</a> |
|
||||
<a href="https://qwenlm.github.io/qwen-code-docs/ja/users/overview">日本語</a> |
|
||||
<a href="https://qwenlm.github.io/qwen-code-docs/ru/users/overview">Русский</a> |
|
||||
<a href="https://qwenlm.github.io/qwen-code-docs/pt-BR/users/overview">Português (Brasil)</a>
|
||||
|
||||
</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/fr">français</a> |
|
||||
<a href="https://qwenlm.github.io/qwen-code-docs/ja/">日本語</a> |
|
||||
<a href="https://qwenlm.github.io/qwen-code-docs/ru">Русский</a> |
|
||||
<a href="https://qwenlm.github.io/qwen-code-docs/zh/">中文</a>
|
||||
|
||||
</div>
|
||||
Qwen Code is an open-source AI agent for the terminal, optimized for [Qwen3-Coder](https://github.com/QwenLM/Qwen3-Coder). It helps you understand large codebases, automate tedious work, and ship faster.
|
||||
|
||||
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
|
||||
## Why Qwen Code?
|
||||
|
||||
Get started with Qwen Code at no cost using any of these free options:
|
||||
|
||||
### 🔥 Qwen OAuth (Recommended)
|
||||
|
||||
- **2,000 requests per day** with no token limits
|
||||
- **60 requests per minute** rate limit
|
||||
- Simply run `qwen` and authenticate with your qwen.ai account
|
||||
- Automatic credential management and refresh
|
||||
- Use `/auth` command to switch to Qwen OAuth if you have initialized with OpenAI compatible mode
|
||||
|
||||
### 🌏 Regional Free Tiers
|
||||
|
||||
- **Mainland China**: ModelScope offers **2,000 free API calls per day**
|
||||
- **International**: OpenRouter provides **up to 1,000 free API calls per day** worldwide
|
||||
|
||||
For detailed setup instructions, see [Authorization](#authorization).
|
||||
|
||||
> [!WARNING]
|
||||
> **Token Usage Notice**: Qwen Code may issue multiple API calls per cycle, resulting in higher token usage (similar to Claude Code). We're actively optimizing API efficiency.
|
||||
|
||||
## Key Features
|
||||
|
||||
- **Code Understanding & Editing** - Query and edit large codebases beyond traditional context window limits
|
||||
- **Workflow Automation** - Automate operational tasks like handling pull requests and complex rebases
|
||||
- **Enhanced Parser** - Adapted parser specifically optimized for Qwen-Coder models
|
||||
- **Vision Model Support** - Automatically detect images in your input and seamlessly switch to vision-capable models for multimodal analysis
|
||||
- **OpenAI-compatible, OAuth free tier**: use an OpenAI-compatible API, or sign in with Qwen OAuth to get 2,000 free requests/day.
|
||||
- **Open-source, co-evolving**: both the framework and the Qwen3-Coder model are open-source—and they ship and evolve together.
|
||||
- **Agentic workflow, feature-rich**: rich built-in tools (Skills, SubAgents, Plan Mode) for a full agentic workflow and a Claude Code-like experience.
|
||||
- **Terminal-first, IDE-friendly**: built for developers who live in the command line, with optional integration for VS Code and Zed.
|
||||
|
||||
## Installation
|
||||
|
||||
### Prerequisites
|
||||
|
||||
Ensure you have [Node.js version 20](https://nodejs.org/en/download) or higher installed.
|
||||
#### Prerequisites
|
||||
|
||||
```bash
|
||||
# Node.js 20+
|
||||
curl -qL https://www.npmjs.com/install.sh | sh
|
||||
```
|
||||
|
||||
### Install from npm
|
||||
#### NPM (recommended)
|
||||
|
||||
```bash
|
||||
npm install -g @qwen-code/qwen-code@latest
|
||||
qwen --version
|
||||
```
|
||||
|
||||
### Install from source
|
||||
|
||||
```bash
|
||||
git clone https://github.com/QwenLM/qwen-code.git
|
||||
cd qwen-code
|
||||
npm install
|
||||
npm install -g .
|
||||
```
|
||||
|
||||
### Install globally with Homebrew (macOS/Linux)
|
||||
#### 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
|
||||
# Start Qwen Code
|
||||
# Start Qwen Code (interactive)
|
||||
qwen
|
||||
|
||||
# Example commands
|
||||
> Explain this codebase structure
|
||||
> Help me refactor this function
|
||||
> Generate unit tests for this module
|
||||
# Then, in the session:
|
||||
/help
|
||||
/auth
|
||||
```
|
||||
|
||||
### Session Management
|
||||
On first use, you'll be prompted to sign in. You can run `/auth` anytime to switch authentication methods.
|
||||
|
||||
Control your token usage with configurable session limits to optimize costs and performance.
|
||||
Example prompts:
|
||||
|
||||
#### Configure Session Token Limit
|
||||
|
||||
Create or edit `.qwen/settings.json` in your home directory:
|
||||
|
||||
```json
|
||||
{
|
||||
"sessionTokenLimit": 32000
|
||||
}
|
||||
```text
|
||||
What does this project do?
|
||||
Explain the codebase structure.
|
||||
Help me refactor this function.
|
||||
Generate unit tests for this module.
|
||||
```
|
||||
|
||||
#### 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>
|
||||
<summary>Click to watch a demo video</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"
|
||||
```
|
||||
<video src="https://cloud.video.taobao.com/vod/HLfyppnCHplRV9Qhz2xSqeazHeRzYtG-EYJnHAqtzkQ.mp4" controls>
|
||||
Your browser does not support the video tag.
|
||||
</video>
|
||||
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary><b>🌍 For International Users</b></summary>
|
||||
## Authentication
|
||||
|
||||
**Option 1: Alibaba Cloud ModelStudio** ([Apply for API Key](https://modelstudio.console.alibabacloud.com/))
|
||||
Qwen Code supports two authentication methods:
|
||||
|
||||
- **Qwen OAuth (recommended & free)**: sign in with your `qwen.ai` account in a browser.
|
||||
- **OpenAI-compatible API**: use `OPENAI_API_KEY` (and optionally a custom base URL / model).
|
||||
|
||||
#### Qwen OAuth (recommended)
|
||||
|
||||
Start `qwen`, then run:
|
||||
|
||||
```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"
|
||||
/auth
|
||||
```
|
||||
|
||||
**Option 2: OpenRouter (Free Tier Available)** ([Apply for API Key](https://openrouter.ai/))
|
||||
Choose **Qwen OAuth** and complete the browser flow. Your credentials are cached locally so you usually won't need to log in again.
|
||||
|
||||
#### OpenAI-compatible API (API key)
|
||||
|
||||
Environment variables (recommended for CI / headless environments):
|
||||
|
||||
```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"
|
||||
export OPENAI_API_KEY="your-api-key-here"
|
||||
export OPENAI_BASE_URL="https://api.openai.com/v1" # optional
|
||||
export OPENAI_MODEL="gpt-4o" # optional
|
||||
```
|
||||
|
||||
</details>
|
||||
For details (including `.qwen/.env` loading and security notes), see the [authentication guide](https://qwenlm.github.io/qwen-code-docs/en/users/configuration/auth/).
|
||||
|
||||
## Usage Examples
|
||||
## Usage
|
||||
|
||||
### 🔍 Explore Codebases
|
||||
As an open-source terminal agent, you can use Qwen Code in four primary ways:
|
||||
|
||||
1. Interactive mode (terminal UI)
|
||||
2. Headless mode (scripts, CI)
|
||||
3. IDE integration (VS Code, Zed)
|
||||
4. TypeScript SDK
|
||||
|
||||
#### Interactive mode
|
||||
|
||||
```bash
|
||||
cd your-project/
|
||||
qwen
|
||||
|
||||
# Architecture analysis
|
||||
> Describe the main pieces of this system's architecture
|
||||
> What are the key dependencies and how do they interact?
|
||||
> Find all API endpoints and their authentication methods
|
||||
```
|
||||
|
||||
### 💻 Code Development
|
||||
Run `qwen` in your project folder to launch the interactive terminal UI. Use `@` to reference local files (for example `@src/main.ts`).
|
||||
|
||||
#### Headless mode
|
||||
|
||||
```bash
|
||||
# Refactoring
|
||||
> Refactor this function to improve readability and performance
|
||||
> Convert this class to use dependency injection
|
||||
> Split this large module into smaller, focused components
|
||||
|
||||
# Code generation
|
||||
> Create a REST API endpoint for user management
|
||||
> Generate unit tests for the authentication module
|
||||
> Add error handling to all database operations
|
||||
cd your-project/
|
||||
qwen -p "your question"
|
||||
```
|
||||
|
||||
### 🔄 Automate Workflows
|
||||
Use `-p` to run Qwen Code without the interactive UI—ideal for scripts, automation, and CI/CD. Learn more: [Headless mode](https://qwenlm.github.io/qwen-code-docs/en/users/features/headless).
|
||||
|
||||
```bash
|
||||
# Git automation
|
||||
> Analyze git commits from the last 7 days, grouped by feature
|
||||
> Create a changelog from recent commits
|
||||
> Find all TODO comments and create GitHub issues
|
||||
#### IDE integration
|
||||
|
||||
# File operations
|
||||
> Convert all images in this directory to PNG format
|
||||
> Rename all test files to follow the *.test.ts pattern
|
||||
> Find and remove all console.log statements
|
||||
```
|
||||
Use Qwen Code inside your editor (VS Code and Zed):
|
||||
|
||||
### 🐛 Debugging & Analysis
|
||||
- [Use in VS Code](https://qwenlm.github.io/qwen-code-docs/en/users/integration-vscode/)
|
||||
- [Use in Zed](https://qwenlm.github.io/qwen-code-docs/en/users/integration-zed/)
|
||||
|
||||
```bash
|
||||
# Performance analysis
|
||||
> Identify performance bottlenecks in this React component
|
||||
> Find all N+1 query problems in the codebase
|
||||
#### TypeScript SDK
|
||||
|
||||
# Security audit
|
||||
> Check for potential SQL injection vulnerabilities
|
||||
> Find all hardcoded credentials or API keys
|
||||
```
|
||||
Build on top of Qwen Code with the TypeScript SDK:
|
||||
|
||||
## Popular Tasks
|
||||
|
||||
### 📚 Understand New Codebases
|
||||
|
||||
```text
|
||||
> What are the core business logic components?
|
||||
> What security mechanisms are in place?
|
||||
> How does the data flow through the system?
|
||||
> What are the main design patterns used?
|
||||
> Generate a dependency graph for this module
|
||||
```
|
||||
|
||||
### 🔨 Code Refactoring & Optimization
|
||||
|
||||
```text
|
||||
> What parts of this module can be optimized?
|
||||
> Help me refactor this class to follow SOLID principles
|
||||
> Add proper error handling and logging
|
||||
> Convert callbacks to async/await pattern
|
||||
> Implement caching for expensive operations
|
||||
```
|
||||
|
||||
### 📝 Documentation & Testing
|
||||
|
||||
```text
|
||||
> Generate comprehensive JSDoc comments for all public APIs
|
||||
> Write unit tests with edge cases for this component
|
||||
> Create API documentation in OpenAPI format
|
||||
> Add inline comments explaining complex algorithms
|
||||
> Generate a README for this module
|
||||
```
|
||||
|
||||
### 🚀 Development Acceleration
|
||||
|
||||
```text
|
||||
> Set up a new Express server with authentication
|
||||
> Create a React component with TypeScript and tests
|
||||
> Implement a rate limiter middleware
|
||||
> Add database migrations for new schema
|
||||
> Configure CI/CD pipeline for this project
|
||||
```
|
||||
- [Use the Qwen Code SDK](./packages/sdk-typescript/README.md)
|
||||
|
||||
## Commands & Shortcuts
|
||||
|
||||
@@ -386,6 +156,7 @@ qwen
|
||||
- `/clear` - Clear conversation history
|
||||
- `/compress` - Compress history to save tokens
|
||||
- `/stats` - Show current session information
|
||||
- `/bug` - Submit a bug report
|
||||
- `/exit` or `/quit` - Exit Qwen Code
|
||||
|
||||
### Keyboard Shortcuts
|
||||
@@ -394,6 +165,19 @@ qwen
|
||||
- `Ctrl+D` - Exit (on empty line)
|
||||
- `Up/Down` - Navigate command history
|
||||
|
||||
> Learn 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. Learn more about [Approval Mode](https://qwenlm.github.io/qwen-code-docs/en/users/features/approval-mode/)
|
||||
|
||||
## Configuration
|
||||
|
||||
Qwen Code can be configured via `settings.json`, environment variables, and CLI flags.
|
||||
|
||||
- **User settings**: `~/.qwen/settings.json`
|
||||
- **Project settings**: `.qwen/settings.json`
|
||||
|
||||
See [settings](https://qwenlm.github.io/qwen-code-docs/en/users/configuration/settings/) for available options and precedence.
|
||||
|
||||
## Benchmark Results
|
||||
|
||||
### Terminal-Bench Performance
|
||||
@@ -403,24 +187,18 @@ qwen
|
||||
| Qwen Code | Qwen3-Coder-480A35 | 37.5% |
|
||||
| Qwen Code | Qwen3-Coder-30BA3B | 31.3% |
|
||||
|
||||
## Development & Contributing
|
||||
## Ecosystem
|
||||
|
||||
See [CONTRIBUTING.md](./CONTRIBUTING.md) to learn how to contribute to the project.
|
||||
Looking for a graphical interface?
|
||||
|
||||
For detailed authentication setup, see the [authentication guide](./docs/cli/authentication.md).
|
||||
- [**Gemini CLI Desktop**](https://github.com/Piebald-AI/gemini-cli-desktop) A cross-platform desktop/web/mobile UI for Qwen Code
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
If you encounter issues, check the [troubleshooting guide](docs/troubleshooting.md).
|
||||
If you encounter issues, check the [troubleshooting guide](https://qwenlm.github.io/qwen-code-docs/en/users/support/troubleshooting/).
|
||||
|
||||
To report a bug from within the CLI, run `/bug` and include a short title and repro steps.
|
||||
|
||||
## Acknowledgments
|
||||
|
||||
This project is based on [Google Gemini CLI](https://github.com/google-gemini/gemini-cli). We acknowledge and appreciate the excellent work of the Gemini CLI team. Our main contribution focuses on parser-level adaptations to better support Qwen-Coder models.
|
||||
|
||||
## License
|
||||
|
||||
[LICENSE](./LICENSE)
|
||||
|
||||
## Star History
|
||||
|
||||
[](https://www.star-history.com/#QwenLM/qwen-code&Date)
|
||||
|
||||
@@ -627,7 +627,12 @@ The MCP integration tracks several states:
|
||||
|
||||
### 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
|
||||
- **Conflict resolution:** Tool name conflicts between servers are resolved through automatic prefixing
|
||||
|
||||
|
||||
@@ -14,7 +14,7 @@ Learn how to use Qwen Code as an end user. This section covers:
|
||||
- Configuration options
|
||||
- Troubleshooting
|
||||
|
||||
### [Developer Guide](./developers/contributing)
|
||||
### [Developer Guide](./developers/architecture)
|
||||
|
||||
Learn how to contribute to and develop Qwen Code. This section covers:
|
||||
|
||||
|
||||
@@ -189,8 +189,8 @@ Then select "create" and follow the prompts to define:
|
||||
> - Create project-specific subagents in `.qwen/agents/` for team sharing
|
||||
> - Use descriptive `description` fields to enable automatic delegation
|
||||
> - Limit tool access to what each subagent actually needs
|
||||
> - Know more about [Sub Agents](/users/features/sub-agents)
|
||||
> - Know more about [Approval Mode](/users/features/approval-mode)
|
||||
> - Know more about [Sub Agents](./features/sub-agents)
|
||||
> - Know more about [Approval Mode](./features/approval-mode)
|
||||
|
||||
## Work with tests
|
||||
|
||||
@@ -318,7 +318,7 @@ This provides a directory listing with file information.
|
||||
Show me the data from @github: repos/owner/repo/issues
|
||||
```
|
||||
|
||||
This fetches data from connected MCP servers using the format @server: resource. See [MCP](/users/features/mcp) for details.
|
||||
This fetches data from connected MCP servers using the format @server: resource. See [MCP](./features/mcp) for details.
|
||||
|
||||
> [!tip]
|
||||
>
|
||||
|
||||
@@ -6,7 +6,7 @@ Qwen Code includes the ability to automatically ignore files, similar to `.gitig
|
||||
|
||||
## How it works
|
||||
|
||||
When you add a path to your `.qwenignore` file, tools that respect this file will exclude matching files and directories from their operations. For example, when you use the [`read_many_files`](/developers/tools/multi-file) command, any paths in your `.qwenignore` file will be automatically excluded.
|
||||
When you add a path to your `.qwenignore` file, tools that respect this file will exclude matching files and directories from their operations. For example, when you use the [`read_many_files`](../../developers/tools/multi-file) command, any paths in your `.qwenignore` file will be automatically excluded.
|
||||
|
||||
For the most part, `.qwenignore` follows the conventions of `.gitignore` files:
|
||||
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
> [!tip]
|
||||
>
|
||||
> **Authentication / API keys:** Authentication (Qwen OAuth vs OpenAI-compatible API) and auth-related environment variables (like `OPENAI_API_KEY`) are documented in **[Authentication](/users/configuration/auth)**.
|
||||
> **Authentication / API keys:** Authentication (Qwen OAuth vs OpenAI-compatible API) and auth-related environment variables (like `OPENAI_API_KEY`) are documented in **[Authentication](../configuration/auth)**.
|
||||
|
||||
> [!note]
|
||||
>
|
||||
@@ -42,7 +42,7 @@ Qwen Code uses JSON settings files for persistent configuration. There are four
|
||||
|
||||
In addition to a project settings file, a project's `.qwen` directory can contain other project-specific files related to Qwen Code's operation, such as:
|
||||
|
||||
- [Custom sandbox profiles](/users/features/sandbox) (e.g. `.qwen/sandbox-macos-custom.sb`, `.qwen/sandbox.Dockerfile`).
|
||||
- [Custom sandbox profiles](../features/sandbox) (e.g. `.qwen/sandbox-macos-custom.sb`, `.qwen/sandbox.Dockerfile`).
|
||||
|
||||
### Available settings in `settings.json`
|
||||
|
||||
@@ -69,7 +69,7 @@ Settings are organized into categories. All settings should be placed within the
|
||||
|
||||
| Setting | Type | Description | Default |
|
||||
| ---------------------------------------- | ---------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ----------- |
|
||||
| `ui.theme` | string | The color theme for the UI. See [Themes](/users/configuration/themes) for available options. | `undefined` |
|
||||
| `ui.theme` | string | The color theme for the UI. See [Themes](../configuration/themes) for available options. | `undefined` |
|
||||
| `ui.customThemes` | object | Custom theme definitions. | `{}` |
|
||||
| `ui.hideWindowTitle` | boolean | Hide the window title bar. | `false` |
|
||||
| `ui.hideTips` | boolean | Hide helpful tips in the UI. | `false` |
|
||||
@@ -326,7 +326,7 @@ The CLI keeps a history of shell commands you run. To avoid conflicts between di
|
||||
Environment variables are a common way to configure applications, especially for sensitive information (like tokens) or for settings that might change between environments.
|
||||
|
||||
Qwen Code can automatically load environment variables from `.env` files.
|
||||
For authentication-related variables (like `OPENAI_*`) and the recommended `.qwen/.env` approach, see **[Authentication](/users/configuration/auth)**.
|
||||
For authentication-related variables (like `OPENAI_*`) and the recommended `.qwen/.env` approach, see **[Authentication](../configuration/auth)**.
|
||||
|
||||
> [!tip]
|
||||
>
|
||||
@@ -357,38 +357,38 @@ Arguments passed directly when running the CLI can override other configurations
|
||||
|
||||
### Command-Line Arguments Table
|
||||
|
||||
| Argument | Alias | Description | Possible Values | Notes |
|
||||
| ---------------------------- | ----- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| `--model` | `-m` | Specifies the Qwen model to use for this session. | Model name | Example: `npm start -- --model qwen3-coder-plus` |
|
||||
| `--prompt` | `-p` | Used to pass a prompt directly to the command. This invokes Qwen Code in a non-interactive mode. | Your prompt text | For scripting examples, use the `--output-format json` flag to get structured output. |
|
||||
| `--prompt-interactive` | `-i` | Starts an interactive session with the provided prompt as the initial input. | Your prompt text | The prompt is processed within the interactive session, not before it. Cannot be used when piping input from stdin. Example: `qwen -i "explain this code"` |
|
||||
| `--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](/users/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](/users/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](/users/features/headless) for detailed information about stream events. |
|
||||
| `--sandbox` | `-s` | Enables sandbox mode for this session. | | |
|
||||
| `--sandbox-image` | | Sets the sandbox image URI. | | |
|
||||
| `--debug` | `-d` | Enables debug mode for this session, providing more verbose output. | | |
|
||||
| `--all-files` | `-a` | If set, recursively includes all files within the current directory as context for the prompt. | | |
|
||||
| `--help` | `-h` | Displays help information about command-line arguments. | | |
|
||||
| `--show-memory-usage` | | Displays the current memory usage. | | |
|
||||
| `--yolo` | | Enables YOLO mode, which automatically approves all tool calls. | | |
|
||||
| `--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](/users/features/approval-mode). |
|
||||
| `--allowed-tools` | | A comma-separated list of tool names that will bypass the confirmation dialog. | Tool names | Example: `qwen --allowed-tools "Shell(git status)"` |
|
||||
| `--telemetry` | | Enables [telemetry](/developers/development/telemetry). | | |
|
||||
| `--telemetry-target` | | Sets the telemetry target. | | See [telemetry](/developers/development/telemetry) for more information. |
|
||||
| `--telemetry-otlp-endpoint` | | Sets the OTLP endpoint for telemetry. | | See [telemetry](/developers/development/telemetry) for more information. |
|
||||
| `--telemetry-otlp-protocol` | | Sets the OTLP protocol for telemetry (`grpc` or `http`). | | Defaults to `grpc`. See [telemetry](/developers/development/telemetry) for more information. |
|
||||
| `--telemetry-log-prompts` | | Enables logging of prompts for telemetry. | | See [telemetry](/developers/development/telemetry) for more information. |
|
||||
| `--checkpointing` | | Enables [checkpointing](/users/features/checkpointing). | | |
|
||||
| `--extensions` | `-e` | Specifies a list of extensions to use for the session. | Extension names | If not provided, all available extensions are used. Use the special term `qwen -e none` to disable all extensions. Example: `qwen -e my-extension -e my-other-extension` |
|
||||
| `--list-extensions` | `-l` | Lists all available extensions and exits. | | |
|
||||
| `--proxy` | | Sets the proxy for the CLI. | Proxy URL | Example: `--proxy http://localhost:7890`. |
|
||||
| `--include-directories` | | Includes additional directories in the workspace for multi-directory support. | Directory paths | Can be specified multiple times or as comma-separated values. 5 directories can be added at maximum. Example: `--include-directories /path/to/project1,/path/to/project2` or `--include-directories /path/to/project1 --include-directories /path/to/project2` |
|
||||
| `--screen-reader` | | Enables screen reader mode, which adjusts the TUI for better compatibility with screen readers. | | |
|
||||
| `--version` | | Displays the version of the CLI. | | |
|
||||
| `--openai-logging` | | Enables logging of OpenAI API calls for debugging and analysis. | | This flag overrides the `enableOpenAILogging` setting in `settings.json`. |
|
||||
| `--openai-logging-dir` | | Sets a custom directory path for OpenAI API logs. | Directory path | This flag overrides the `openAILoggingDir` setting in `settings.json`. Supports absolute paths, relative paths, and `~` expansion. Example: `qwen --openai-logging-dir "~/qwen-logs" --openai-logging` |
|
||||
| `--tavily-api-key` | | Sets the Tavily API key for web search functionality for this session. | API key | Example: `qwen --tavily-api-key tvly-your-api-key-here` |
|
||||
| Argument | Alias | Description | Possible Values | Notes |
|
||||
| ---------------------------- | ----- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| `--model` | `-m` | Specifies the Qwen model to use for this session. | Model name | Example: `npm start -- --model qwen3-coder-plus` |
|
||||
| `--prompt` | `-p` | Used to pass a prompt directly to the command. This invokes Qwen Code in a non-interactive mode. | Your prompt text | For scripting examples, use the `--output-format json` flag to get structured output. |
|
||||
| `--prompt-interactive` | `-i` | Starts an interactive session with the provided prompt as the initial input. | Your prompt text | The prompt is processed within the interactive session, not before it. Cannot be used when piping input from stdin. Example: `qwen -i "explain this code"` |
|
||||
| `--output-format` | `-o` | Specifies the format of the CLI output for non-interactive mode. | `text`, `json`, `stream-json` | `text`: (Default) The standard human-readable output. `json`: A machine-readable JSON output emitted at the end of execution. `stream-json`: Streaming JSON messages emitted as they occur during execution. For structured output and scripting, use the `--output-format json` or `--output-format stream-json` flag. See [Headless Mode](../features/headless) for detailed information. |
|
||||
| `--input-format` | | Specifies the format consumed from standard input. | `text`, `stream-json` | `text`: (Default) Standard text input from stdin or command-line arguments. `stream-json`: JSON message protocol via stdin for bidirectional communication. Requirement: `--input-format stream-json` requires `--output-format stream-json` to be set. When using `stream-json`, stdin is reserved for protocol messages. See [Headless Mode](../features/headless) for detailed information. |
|
||||
| `--include-partial-messages` | | Include partial assistant messages when using `stream-json` output format. When enabled, emits stream events (message_start, content_block_delta, etc.) as they occur during streaming. | | Default: `false`. Requirement: Requires `--output-format stream-json` to be set. See [Headless Mode](../features/headless) for detailed information about stream events. |
|
||||
| `--sandbox` | `-s` | Enables sandbox mode for this session. | | |
|
||||
| `--sandbox-image` | | Sets the sandbox image URI. | | |
|
||||
| `--debug` | `-d` | Enables debug mode for this session, providing more verbose output. | | |
|
||||
| `--all-files` | `-a` | If set, recursively includes all files within the current directory as context for the prompt. | | |
|
||||
| `--help` | `-h` | Displays help information about command-line arguments. | | |
|
||||
| `--show-memory-usage` | | Displays the current memory usage. | | |
|
||||
| `--yolo` | | Enables YOLO mode, which automatically approves all tool calls. | | |
|
||||
| `--approval-mode` | | Sets the approval mode for tool calls. | `plan`, `default`, `auto-edit`, `yolo` | Supported modes: `plan`: Analyze only—do not modify files or execute commands. `default`: Require approval for file edits or shell commands (default behavior). `auto-edit`: Automatically approve edit tools (edit, write_file) while prompting for others. `yolo`: Automatically approve all tool calls (equivalent to `--yolo`). Cannot be used together with `--yolo`. Use `--approval-mode=yolo` instead of `--yolo` for the new unified approach. Example: `qwen --approval-mode auto-edit`<br>See more about [Approval Mode](../features/approval-mode). |
|
||||
| `--allowed-tools` | | A comma-separated list of tool names that will bypass the confirmation dialog. | Tool names | Example: `qwen --allowed-tools "Shell(git status)"` |
|
||||
| `--telemetry` | | Enables [telemetry](/developers/development/telemetry). | | |
|
||||
| `--telemetry-target` | | Sets the telemetry target. | | See [telemetry](/developers/development/telemetry) for more information. |
|
||||
| `--telemetry-otlp-endpoint` | | Sets the OTLP endpoint for telemetry. | | See [telemetry](../../developers/development/telemetry) for more information. |
|
||||
| `--telemetry-otlp-protocol` | | Sets the OTLP protocol for telemetry (`grpc` or `http`). | | Defaults to `grpc`. See [telemetry](../../developers/development/telemetry) for more information. |
|
||||
| `--telemetry-log-prompts` | | Enables logging of prompts for telemetry. | | See [telemetry](../../developers/development/telemetry) for more information. |
|
||||
| `--checkpointing` | | Enables [checkpointing](../features/checkpointing). | | |
|
||||
| `--extensions` | `-e` | Specifies a list of extensions to use for the session. | Extension names | If not provided, all available extensions are used. Use the special term `qwen -e none` to disable all extensions. Example: `qwen -e my-extension -e my-other-extension` |
|
||||
| `--list-extensions` | `-l` | Lists all available extensions and exits. | | |
|
||||
| `--proxy` | | Sets the proxy for the CLI. | Proxy URL | Example: `--proxy http://localhost:7890`. |
|
||||
| `--include-directories` | | Includes additional directories in the workspace for multi-directory support. | Directory paths | Can be specified multiple times or as comma-separated values. 5 directories can be added at maximum. Example: `--include-directories /path/to/project1,/path/to/project2` or `--include-directories /path/to/project1 --include-directories /path/to/project2` |
|
||||
| `--screen-reader` | | Enables screen reader mode, which adjusts the TUI for better compatibility with screen readers. | | |
|
||||
| `--version` | | Displays the version of the CLI. | | |
|
||||
| `--openai-logging` | | Enables logging of OpenAI API calls for debugging and analysis. | | This flag overrides the `enableOpenAILogging` setting in `settings.json`. |
|
||||
| `--openai-logging-dir` | | Sets a custom directory path for OpenAI API logs. | Directory path | This flag overrides the `openAILoggingDir` setting in `settings.json`. Supports absolute paths, relative paths, and `~` expansion. Example: `qwen --openai-logging-dir "~/qwen-logs" --openai-logging` |
|
||||
| `--tavily-api-key` | | Sets the Tavily API key for web search functionality for this session. | API key | Example: `qwen --tavily-api-key tvly-your-api-key-here` |
|
||||
|
||||
## Context Files (Hierarchical Instructional Context)
|
||||
|
||||
@@ -438,11 +438,11 @@ This example demonstrates how you can provide general project context, specific
|
||||
- Location: The CLI also scans for the configured context file in subdirectories _below_ the current working directory (respecting common ignore patterns like `node_modules`, `.git`, etc.). The breadth of this search is limited to 200 directories by default, but can be configured with the `context.discoveryMaxDirs` setting in your `settings.json` file.
|
||||
- Scope: Allows for highly specific instructions relevant to a particular component, module, or subsection of your project.
|
||||
- **Concatenation & UI Indication:** The contents of all found context files are concatenated (with separators indicating their origin and path) and provided as part of the system prompt. The CLI footer displays the count of loaded context files, giving you a quick visual cue about the active instructional context.
|
||||
- **Importing Content:** You can modularize your context files by importing other Markdown files using the `@path/to/file.md` syntax. For more details, see the [Memory Import Processor documentation](/users/configuration/memory).
|
||||
- **Importing Content:** You can modularize your context files by importing other Markdown files using the `@path/to/file.md` syntax. For more details, see the [Memory Import Processor documentation](../configuration/memory).
|
||||
- **Commands for Memory Management:**
|
||||
- Use `/memory refresh` to force a re-scan and reload of all context files from all configured locations. This updates the AI's instructional context.
|
||||
- Use `/memory show` to display the combined instructional context currently loaded, allowing you to verify the hierarchy and content being used by the AI.
|
||||
- See the [Commands documentation](/users/reference/cli-reference) for full details on the `/memory` command and its sub-commands (`show` and `refresh`).
|
||||
- See the [Commands documentation](../features/commands) for full details on the `/memory` command and its sub-commands (`show` and `refresh`).
|
||||
|
||||
By understanding and utilizing these configuration layers and the hierarchical nature of context files, you can effectively manage the AI's memory and tailor Qwen Code's responses to your specific needs and projects.
|
||||
|
||||
@@ -450,7 +450,7 @@ By understanding and utilizing these configuration layers and the hierarchical n
|
||||
|
||||
Qwen Code can execute potentially unsafe operations (like shell commands and file modifications) within a sandboxed environment to protect your system.
|
||||
|
||||
[Sandbox](/users/features/sandbox) is disabled by default, but you can enable it in a few ways:
|
||||
[Sandbox](../features/sandbox) is disabled by default, but you can enable it in a few ways:
|
||||
|
||||
- Using `--sandbox` or `-s` flag.
|
||||
- Setting `GEMINI_SANDBOX` environment variable.
|
||||
|
||||
@@ -32,7 +32,7 @@ Qwen Code comes with a selection of pre-defined themes, which you can list using
|
||||
|
||||
### Theme Persistence
|
||||
|
||||
Selected themes are saved in Qwen Code's [configuration](./configuration.md) so your preference is remembered across sessions.
|
||||
Selected themes are saved in Qwen Code's [configuration](../configuration/settings) so your preference is remembered across sessions.
|
||||
|
||||
---
|
||||
|
||||
@@ -146,7 +146,7 @@ The theme file must be a valid JSON file that follows the same structure as a cu
|
||||
|
||||
- Select your custom theme using the `/theme` command in Qwen Code. Your custom theme will appear in the theme selection dialog.
|
||||
- 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.md) 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
|
||||
|
||||
|
||||
@@ -56,6 +56,6 @@ If you need to change a decision or see all your settings, you have a couple of
|
||||
|
||||
For advanced users, it's helpful to know the exact order of operations for how trust is determined:
|
||||
|
||||
1. **IDE Trust Signal**: If you are using the [IDE Integration](/users/ide-integration/ide-integration), the CLI first asks the IDE if the workspace is trusted. The IDE's response takes highest priority.
|
||||
1. **IDE Trust Signal**: If you are using the [IDE Integration](../ide-integration/ide-integration), the CLI first asks the IDE if the workspace is trusted. The IDE's response takes highest priority.
|
||||
|
||||
2. **Local Trust File**: If the IDE is not connected, the CLI checks the central `~/.qwen/trustedFolders.json` file.
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
# Approval Mode
|
||||
|
||||
Qwen Code offers three distinct permission modes that allow you to flexibly control how AI interacts with your code and system based on task complexity and risk level.
|
||||
|
||||
## Permission Modes Comparison
|
||||
|
||||
@@ -20,10 +20,11 @@ These commands help you save, restore, and summarize work progress.
|
||||
|
||||
| Command | Description | Usage Examples |
|
||||
| ----------- | --------------------------------------------------------- | ------------------------------------ |
|
||||
| `/init` | Analyze current directory and create initial context file | `/init` |
|
||||
| `/summary` | Generate project summary based on conversation history | `/summary` |
|
||||
| `/compress` | Replace chat history with summary to save Tokens | `/compress` |
|
||||
| `/resume` | Resume a previous conversation session | `/resume` |
|
||||
| `/restore` | Restore files to state before tool execution | `/restore` (list) or `/restore <ID>` |
|
||||
| `/init` | Analyze current directory and create initial context file | `/init` |
|
||||
|
||||
### 1.2 Interface and Workspace Control
|
||||
|
||||
|
||||
@@ -203,7 +203,7 @@ Key command-line options for headless usage:
|
||||
| `--continue` | Resume the most recent session for this project | `qwen --continue -p "Pick up where we left off"` |
|
||||
| `--resume [sessionId]` | Resume a specific session (or choose interactively) | `qwen --resume 123e... -p "Finish the refactor"` |
|
||||
|
||||
For complete details on all available configuration options, settings files, and environment variables, see the [Configuration Guide](/users/configuration/settings).
|
||||
For complete details on all available configuration options, settings files, and environment variables, see the [Configuration Guide](../configuration/settings).
|
||||
|
||||
## Examples
|
||||
|
||||
@@ -276,7 +276,7 @@ tail -5 usage.log
|
||||
|
||||
## Resources
|
||||
|
||||
- [CLI Configuration](/users/configuration/settings#command-line-arguments) - Complete configuration guide
|
||||
- [Authentication](/users/configuration/settings#environment-variables-for-api-access) - Setup authentication
|
||||
- [Commands](/users/reference/cli-reference) - Interactive commands reference
|
||||
- [Tutorials](/users/quickstart) - Step-by-step automation guides
|
||||
- [CLI Configuration](../configuration/settings#command-line-arguments) - Complete configuration guide
|
||||
- [Authentication](../configuration/settings#environment-variables-for-api-access) - Setup authentication
|
||||
- [Commands](../features/commands) - Interactive commands reference
|
||||
- [Tutorials](../quickstart) - Step-by-step automation guides
|
||||
|
||||
@@ -12,6 +12,7 @@ With MCP servers connected, you can ask Qwen Code to:
|
||||
- Automate workflows (repeatable tasks exposed as tools/prompts)
|
||||
|
||||
> [!tip]
|
||||
>
|
||||
> If you’re looking for the “one command to get started”, jump to [Quick start](#quick-start).
|
||||
|
||||
## Quick start
|
||||
@@ -51,7 +52,8 @@ qwen mcp add --scope user --transport http my-server http://localhost:3000/mcp
|
||||
```
|
||||
|
||||
> [!tip]
|
||||
> For advanced configuration layers (system defaults/system settings and precedence rules), see [Settings](/users/configuration/settings).
|
||||
>
|
||||
> For advanced configuration layers (system defaults/system settings and precedence rules), see [Settings](../configuration/settings).
|
||||
|
||||
## Configure servers
|
||||
|
||||
@@ -64,6 +66,7 @@ qwen mcp add --scope user --transport http my-server http://localhost:3000/mcp
|
||||
| `stdio` | Local process (scripts, CLIs, Docker) on your machine | `command`, `args` (+ optional `cwd`, `env`) |
|
||||
|
||||
> [!note]
|
||||
>
|
||||
> If a server supports both, prefer **HTTP** over **SSE**.
|
||||
|
||||
### Configure via `settings.json` vs `qwen mcp add`
|
||||
|
||||
@@ -220,6 +220,6 @@ qwen -s -p "run shell command: mount | grep workspace"
|
||||
|
||||
## Related documentation
|
||||
|
||||
- [Configuration](/users/configuration/settings): Full configuration options.
|
||||
- [Commands](/users/reference/cli-reference): Available commands.
|
||||
- [Troubleshooting](/users/support/troubleshooting): General troubleshooting.
|
||||
- [Configuration](../configuration/settings): Full configuration options.
|
||||
- [Commands](../features/commands): Available commands.
|
||||
- [Troubleshooting](../support/troubleshooting): General troubleshooting.
|
||||
|
||||
@@ -16,16 +16,15 @@ The plugin **MUST** run a local HTTP server that implements the **Model Context
|
||||
- **Endpoint:** The server should expose a single endpoint (e.g., `/mcp`) for all MCP communication.
|
||||
- **Port:** The server **MUST** listen on a dynamically assigned port (i.e., listen on port `0`).
|
||||
|
||||
### 2. Discovery Mechanism: The Port File
|
||||
### 2. Discovery Mechanism: The Lock File
|
||||
|
||||
For Qwen Code to connect, it needs to discover which IDE instance it's running in and what port your server is using. The plugin **MUST** facilitate this by creating a "discovery file."
|
||||
For Qwen Code to connect, it needs to discover what port your server is using. The plugin **MUST** facilitate this by creating a "lock file" and setting the port environment variable.
|
||||
|
||||
- **How the CLI Finds the File:** The CLI determines the Process ID (PID) of the IDE it's running in by traversing the process tree. It then looks for a discovery file that contains this PID in its name.
|
||||
- **File Location:** The file must be created in a specific directory: `os.tmpdir()/qwen/ide/`. Your plugin must create this directory if it doesn't exist.
|
||||
- **How the CLI Finds the File:** The CLI reads the port from `QWEN_CODE_IDE_SERVER_PORT`, then reads `~/.qwen/ide/<PORT>.lock`. (Legacy fallbacks exist for older extensions; see note below.)
|
||||
- **File Location:** The file must be created in a specific directory: `~/.qwen/ide/`. Your plugin must create this directory if it doesn't exist.
|
||||
- **File Naming Convention:** The filename is critical and **MUST** follow the pattern:
|
||||
`qwen-code-ide-server-${PID}-${PORT}.json`
|
||||
- `${PID}`: The process ID of the parent IDE process. Your plugin must determine this PID and include it in the filename.
|
||||
- `${PORT}`: The port your MCP server is listening on.
|
||||
`<PORT>.lock`
|
||||
- `<PORT>`: The port your MCP server is listening on.
|
||||
- **File Content & Workspace Validation:** The file **MUST** contain a JSON object with the following structure:
|
||||
|
||||
```json
|
||||
@@ -33,21 +32,20 @@ For Qwen Code to connect, it needs to discover which IDE instance it's running i
|
||||
"port": 12345,
|
||||
"workspacePath": "/path/to/project1:/path/to/project2",
|
||||
"authToken": "a-very-secret-token",
|
||||
"ideInfo": {
|
||||
"name": "vscode",
|
||||
"displayName": "VS Code"
|
||||
}
|
||||
"ppid": 1234,
|
||||
"ideName": "VS Code"
|
||||
}
|
||||
```
|
||||
- `port` (number, required): The port of the MCP server.
|
||||
- `workspacePath` (string, required): A list of all open workspace root paths, delimited by the OS-specific path separator (`:` for Linux/macOS, `;` for Windows). The CLI uses this path to ensure it's running in the same project folder that's open in the IDE. If the CLI's current working directory is not a sub-directory of `workspacePath`, the connection will be rejected. Your plugin **MUST** provide the correct, absolute path(s) to the root of the open workspace(s).
|
||||
- `authToken` (string, required): A secret token for securing the connection. The CLI will include this token in an `Authorization: Bearer <token>` header on all requests.
|
||||
- `ideInfo` (object, required): Information about the IDE.
|
||||
- `name` (string, required): A short, lowercase identifier for the IDE (e.g., `vscode`, `jetbrains`).
|
||||
- `displayName` (string, required): A user-friendly name for the IDE (e.g., `VS Code`, `JetBrains IDE`).
|
||||
- `ppid` (number, required): The parent process ID of the IDE process.
|
||||
- `ideName` (string, required): A user-friendly name for the IDE (e.g., `VS Code`, `JetBrains IDE`).
|
||||
|
||||
- **Authentication:** To secure the connection, the plugin **MUST** generate a unique, secret token and include it in the discovery file. The CLI will then include this token in the `Authorization` header for all requests to the MCP server (e.g., `Authorization: Bearer a-very-secret-token`). Your server **MUST** validate this token on every request and reject any that are unauthorized.
|
||||
- **Tie-Breaking with Environment Variables (Recommended):** For the most reliable experience, your plugin **SHOULD** both create the discovery file and set the `QWEN_CODE_IDE_SERVER_PORT` environment variable in the integrated terminal. The file serves as the primary discovery mechanism, but the environment variable is crucial for tie-breaking. If a user has multiple IDE windows open for the same workspace, the CLI uses the `QWEN_CODE_IDE_SERVER_PORT` variable to identify and connect to the correct window's server.
|
||||
- **Environment Variables (Required):** Your plugin **MUST** set `QWEN_CODE_IDE_SERVER_PORT` in the integrated terminal so the CLI can locate the correct `<PORT>.lock` file.
|
||||
|
||||
**Legacy note:** For extensions older than v0.5.1, Qwen Code may fall back to reading JSON files in the system temp directory named `qwen-code-ide-server-<PID>.json` or `qwen-code-ide-server-<PORT>.json`. New integrations should not rely on these legacy files.
|
||||
|
||||
## II. The Context Interface
|
||||
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
Qwen Code can integrate with your IDE to provide a more seamless and context-aware experience. This integration allows the CLI to understand your workspace better and enables powerful features like native in-editor diffing.
|
||||
|
||||
Currently, the only supported IDE is [Visual Studio Code](https://code.visualstudio.com/) and other editors that support VS Code extensions. To build support for other editors, see the [IDE Companion Extension Spec](/users/ide-integration/ide-companion-spec).
|
||||
Currently, the only supported IDE is [Visual Studio Code](https://code.visualstudio.com/) and other editors that support VS Code extensions. To build support for other editors, see the [IDE Companion Extension Spec](../ide-integration/ide-companion-spec).
|
||||
|
||||
## Features
|
||||
|
||||
|
||||
@@ -6,41 +6,14 @@
|
||||
|
||||
Use it to perform GitHub pull request reviews, triage issues, perform code analysis and modification, and more using [Qwen Code] conversationally (e.g., `@qwencoder fix this issue`) directly inside your GitHub repositories.
|
||||
|
||||
- [qwen-code-action](#qwen-code-action)
|
||||
- [Overview](#overview)
|
||||
- [Features](#features)
|
||||
- [Quick Start](#quick-start)
|
||||
- [1. Get a Qwen API Key](#1-get-a-qwen-api-key)
|
||||
- [2. Add it as a GitHub Secret](#2-add-it-as-a-github-secret)
|
||||
- [3. Update your .gitignore](#3-update-your-gitignore)
|
||||
- [4. Choose a Workflow](#4-choose-a-workflow)
|
||||
- [5. Try it out](#5-try-it-out)
|
||||
- [Workflows](#workflows)
|
||||
- [Qwen Code Dispatch](#qwen-code-dispatch)
|
||||
- [Issue Triage](#issue-triage)
|
||||
- [Pull Request Review](#pull-request-review)
|
||||
- [Qwen Code CLI Assistant](#qwen-code-cli-assistant)
|
||||
- [Configuration](#configuration)
|
||||
- [Inputs](#inputs)
|
||||
- [Outputs](#outputs)
|
||||
- [Repository Variables](#repository-variables)
|
||||
- [Secrets](#secrets)
|
||||
- [Authentication](#authentication)
|
||||
- [GitHub Authentication](#github-authentication)
|
||||
- [Extensions](#extensions)
|
||||
- [Best Practices](#best-practices)
|
||||
- [Customization](#customization)
|
||||
- [Contributing](#contributing)
|
||||
|
||||
## Features
|
||||
|
||||
- **Automation**: Trigger workflows based on events (e.g. issue opening) or schedules (e.g. nightly).
|
||||
- **On-demand Collaboration**: Trigger workflows in issue and pull request
|
||||
comments by mentioning the [Qwen Code CLI] (e.g., `@qwencoder /review`).
|
||||
- **Extensible with Tools**: Leverage [Qwen Code] models' tool-calling capabilities to
|
||||
interact with other CLIs like the [GitHub CLI] (`gh`).
|
||||
comments by mentioning the [Qwen Code CLI](./features/commands) (e.g., `@qwencoder /review`).
|
||||
- **Extensible with Tools**: Leverage [Qwen Code](../developers/tools/introduction.md) models' tool-calling capabilities to interact with other CLIs like the [GitHub CLI] (`gh`).
|
||||
- **Customizable**: Use a `QWEN.md` file in your repository to provide
|
||||
project-specific instructions and context to [Qwen Code CLI].
|
||||
project-specific instructions and context to [Qwen Code CLI](./features/commands).
|
||||
|
||||
## Quick Start
|
||||
|
||||
@@ -48,7 +21,7 @@ Get started with Qwen Code CLI in your repository in just a few minutes:
|
||||
|
||||
### 1. Get a Qwen API Key
|
||||
|
||||
Obtain your API key from [DashScope] (Alibaba Cloud's AI platform)
|
||||
Obtain your API key from [DashScope](https://help.aliyun.com/zh/model-studio/qwen-code) (Alibaba Cloud's AI platform)
|
||||
|
||||
### 2. Add it as a GitHub Secret
|
||||
|
||||
@@ -90,7 +63,7 @@ You have two options to set up a workflow:
|
||||
|
||||
**Option B: Manually copy workflows**
|
||||
|
||||
1. Copy the pre-built workflows from the [`examples/workflows`](./examples/workflows) directory to your repository's `.github/workflows` directory. Note: the `qwen-dispatch.yml` workflow must also be copied, which triggers the workflows to run.
|
||||
1. Copy the pre-built workflows from the [`examples/workflows`](./common-workflow) directory to your repository's `.github/workflows` directory. Note: the `qwen-dispatch.yml` workflow must also be copied, which triggers the workflows to run.
|
||||
|
||||
### 5. Try it out
|
||||
|
||||
@@ -119,30 +92,19 @@ This action provides several pre-built workflows for different use cases. Each w
|
||||
|
||||
### Qwen Code Dispatch
|
||||
|
||||
This workflow acts as a central dispatcher for Qwen Code CLI, routing requests to
|
||||
the appropriate workflow based on the triggering event and the command provided
|
||||
in the comment. For a detailed guide on how to set up the dispatch workflow, go
|
||||
to the
|
||||
[Qwen Code Dispatch workflow documentation](./examples/workflows/qwen-dispatch).
|
||||
This workflow acts as a central dispatcher for Qwen Code CLI, routing requests to the appropriate workflow based on the triggering event and the command provided in the comment. For a detailed guide on how to set up the dispatch workflow, go to the [Qwen Code Dispatch workflow documentation](./common-workflow).
|
||||
|
||||
### Issue Triage
|
||||
|
||||
This action can be used to triage GitHub Issues automatically or on a schedule.
|
||||
For a detailed guide on how to set up the issue triage system, go to the
|
||||
[GitHub Issue Triage workflow documentation](./examples/workflows/issue-triage).
|
||||
This action can be used to triage GitHub Issues automatically or on a schedule. For a detailed guide on how to set up the issue triage system, go to the [GitHub Issue Triage workflow documentation](./examples/workflows/issue-triage).
|
||||
|
||||
### Pull Request Review
|
||||
|
||||
This action can be used to automatically review pull requests when they are
|
||||
opened. For a detailed guide on how to set up the pull request review system,
|
||||
go to the [GitHub PR Review workflow documentation](./examples/workflows/pr-review).
|
||||
This action can be used to automatically review pull requests when they are opened. For a detailed guide on how to set up the pull request review system, go to the [GitHub PR Review workflow documentation](./common-workflow).
|
||||
|
||||
### Qwen Code CLI Assistant
|
||||
|
||||
This type of action can be used to invoke a general-purpose, conversational Qwen Code
|
||||
AI assistant within the pull requests and issues to perform a wide range of
|
||||
tasks. For a detailed guide on how to set up the general-purpose Qwen Code CLI workflow,
|
||||
go to the [Qwen Code Assistant workflow documentation](./examples/workflows/qwen-assistant).
|
||||
This type of action can be used to invoke a general-purpose, conversational Qwen Code AI assistant within the pull requests and issues to perform a wide range of tasks. For a detailed guide on how to set up the general-purpose Qwen Code CLI workflow, go to the [Qwen Code Assistant workflow documentation](./common-workflow).
|
||||
|
||||
## Configuration
|
||||
|
||||
@@ -222,8 +184,7 @@ To add a secret:
|
||||
2. Enter the secret name and value.
|
||||
3. Save.
|
||||
|
||||
For more information, refer to the
|
||||
[official GitHub documentation on creating and using encrypted secrets][secrets].
|
||||
For more information, refer to the [official GitHub documentation on creating and using encrypted secrets][secrets].
|
||||
|
||||
## Authentication
|
||||
|
||||
@@ -239,7 +200,7 @@ You can authenticate with GitHub in two ways:
|
||||
authentication, we recommend creating a custom GitHub App.
|
||||
|
||||
For detailed setup instructions for both Qwen and GitHub authentication, go to the
|
||||
[**Authentication documentation**](./docs/authentication.md).
|
||||
[**Authentication documentation**](./configuration/auth).
|
||||
|
||||
## Extensions
|
||||
|
||||
@@ -247,7 +208,7 @@ The Qwen Code CLI can be extended with additional functionality through extensio
|
||||
These extensions are installed from source from their GitHub repositories.
|
||||
|
||||
For detailed instructions on how to set up and configure extensions, go to the
|
||||
[Extensions documentation](./docs/extensions.md).
|
||||
[Extensions documentation](../developers/extensions/extension).
|
||||
|
||||
## Best Practices
|
||||
|
||||
@@ -258,20 +219,18 @@ Key recommendations include:
|
||||
- **Securing Your Repository:** Implementing branch and tag protection, and restricting pull request approvers.
|
||||
- **Monitoring and Auditing:** Regularly reviewing action logs and enabling OpenTelemetry for deeper insights into performance and behavior.
|
||||
|
||||
For a comprehensive guide on securing your repository and workflows, please refer to our [**Best Practices documentation**](./docs/best-practices.md).
|
||||
For a comprehensive guide on securing your repository and workflows, please refer to our [**Best Practices documentation**](./common-workflow).
|
||||
|
||||
## Customization
|
||||
|
||||
Create a [QWEN.md] file in the root of your repository to provide
|
||||
project-specific context and instructions to [Qwen Code CLI]. This is useful for defining
|
||||
Create a QWEN.md file in the root of your repository to provide
|
||||
project-specific context and instructions to [Qwen Code CLI](./common-workflow). This is useful for defining
|
||||
coding conventions, architectural patterns, or other guidelines the model should
|
||||
follow for a given repository.
|
||||
|
||||
## Contributing
|
||||
|
||||
Contributions are welcome! Check out the Qwen Code CLI
|
||||
[**Contributing Guide**](./CONTRIBUTING.md) for more details on how to get
|
||||
started.
|
||||
Contributions are welcome! Check out the Qwen Code CLI **Contributing Guide** for more details on how to get started.
|
||||
|
||||
[secrets]: https://docs.github.com/en/actions/security-guides/using-secrets-in-github-actions
|
||||
[Qwen Code]: https://github.com/QwenLM/qwen-code
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
|
||||
<br/>
|
||||
|
||||
<video src="https://cloud.video.taobao.com/vod/JnvYMhUia2EKFAaiuErqNpzWE9mz3odG76vArAHNg94.mp4" controls width="800">
|
||||
<video src="https://cloud.video.taobao.com/vod/IKKwfM-kqNI3OJjM_U8uMCSMAoeEcJhs6VNCQmZxUfk.mp4" controls width="800">
|
||||
Your browser does not support the video tag.
|
||||
</video>
|
||||
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
### Features
|
||||
|
||||
- **Native agent experience**: Integrated AI assistant panel within Zed's interface
|
||||
- **Agent Control Protocol**: Full support for ACP enabling advanced IDE interactions
|
||||
- **Agent Client Protocol**: Full support for ACP enabling advanced IDE interactions
|
||||
- **File management**: @-mention files to add them to the conversation context
|
||||
- **Conversation history**: Access to past conversations within Zed
|
||||
|
||||
|
||||
@@ -36,13 +36,13 @@ Select **Qwen OAuth (Free)** authentication and follow the prompts to log in. Th
|
||||
what does this project do?
|
||||
```
|
||||
|
||||

|
||||

|
||||
|
||||
You'll be prompted to log in on first use. That's it! [Continue with Quickstart (5 mins) →](/users/quickstart)
|
||||
You'll be prompted to log in on first use. That's it! [Continue with Quickstart (5 mins) →](./quickstart)
|
||||
|
||||
> [!tip]
|
||||
>
|
||||
> See [troubleshooting](/users/support/troubleshooting) if you hit issues.
|
||||
> See [troubleshooting](./support/troubleshooting) if you hit issues.
|
||||
|
||||
> [!note]
|
||||
>
|
||||
@@ -52,11 +52,11 @@ You'll be prompted to log in on first use. That's it! [Continue with Quickstart
|
||||
|
||||
- **Build features from descriptions**: Tell Qwen Code what you want to build in plain language. It will make a plan, write the code, and ensure it works.
|
||||
- **Debug and fix issues**: Describe a bug or paste an error message. Qwen Code will analyze your codebase, identify the problem, and implement a fix.
|
||||
- **Navigate any codebase**: Ask anything about your team's codebase, and get a thoughtful answer back. Qwen Code maintains awareness of your entire project structure, can find up-to-date information from the web, and with [MCP](/users/features/mcp) can pull from external datasources like Google Drive, Figma, and Slack.
|
||||
- **Navigate any codebase**: Ask anything about your team's codebase, and get a thoughtful answer back. Qwen Code maintains awareness of your entire project structure, can find up-to-date information from the web, and with [MCP](./features/mcp) can pull from external datasources like Google Drive, Figma, and Slack.
|
||||
- **Automate tedious tasks**: Fix fiddly lint issues, resolve merge conflicts, and write release notes. Do all this in a single command from your developer machines, or automatically in CI.
|
||||
|
||||
## Why developers love Qwen Code
|
||||
|
||||
- **Works in your terminal**: Not another chat window. Not another IDE. Qwen Code meets you where you already work, with the tools you already love.
|
||||
- **Takes action**: Qwen Code can directly edit files, run commands, and create commits. Need more? [MCP](/users/features/mcp) lets Qwen Code read your design docs in Google Drive, update your tickets in Jira, or use _your_ custom developer tooling.
|
||||
- **Takes action**: Qwen Code can directly edit files, run commands, and create commits. Need more? [MCP](./features/mcp) lets Qwen Code read your design docs in Google Drive, update your tickets in Jira, or use _your_ custom developer tooling.
|
||||
- **Unix philosophy**: Qwen Code is composable and scriptable. `tail -f app.log | qwen -p "Slack me if you see any anomalies appear in this log stream"` _works_. Your CI can run `qwen -p "If there are new text strings, translate them into French and raise a PR for @lang-fr-team to review"`.
|
||||
|
||||
@@ -206,7 +206,7 @@ Here are the most important commands for daily use:
|
||||
| → `output [language]` | Set LLM output language | `/language output Chinese` |
|
||||
| `/quit` | Exit Qwen Code immediately | `/quit` or `/exit` |
|
||||
|
||||
See the [CLI reference](/users/reference/cli-reference) for a complete list of commands.
|
||||
See the [CLI reference](./features/commands) for a complete list of commands.
|
||||
|
||||
## Pro tips for beginners
|
||||
|
||||
@@ -225,9 +225,9 @@ See the [CLI reference](/users/reference/cli-reference) for a complete list of c
|
||||
3. build a webpage that allows users to see and edit their information
|
||||
```
|
||||
|
||||
**Let Claude explore first**
|
||||
**Let Qwen Code explore first**
|
||||
|
||||
- Before making changes, let Claude understand your code:
|
||||
- Before making changes, let Qwen Code understand your code:
|
||||
|
||||
```
|
||||
analyze the database schema
|
||||
|
||||
@@ -23,7 +23,7 @@ When you authenticate using your qwen.ai account, these Terms of Service and Pri
|
||||
- **Terms of Service:** Your use is governed by the [Qwen Terms of Service](https://qwen.ai/termsservice).
|
||||
- **Privacy Notice:** The collection and use of your data is described in the [Qwen Privacy Policy](https://qwen.ai/privacypolicy).
|
||||
|
||||
For details about authentication setup, quotas, and supported features, see [Authentication Setup](/users/configuration/settings).
|
||||
For details about authentication setup, quotas, and supported features, see [Authentication Setup](../configuration/settings).
|
||||
|
||||
## 2. If you are using OpenAI-Compatible API Authentication
|
||||
|
||||
@@ -37,7 +37,7 @@ Qwen Code supports various OpenAI-compatible providers. Please refer to your spe
|
||||
|
||||
## Usage Statistics and Telemetry
|
||||
|
||||
Qwen Code may collect anonymous usage statistics and [telemetry](/developers/development/telemetry) data to improve the user experience and product quality. This data collection is optional and can be controlled through configuration settings.
|
||||
Qwen Code may collect anonymous usage statistics and [telemetry](../../developers/development/telemetry) data to improve the user experience and product quality. This data collection is optional and can be controlled through configuration settings.
|
||||
|
||||
### What Data is Collected
|
||||
|
||||
@@ -91,4 +91,4 @@ You can switch between Qwen OAuth and OpenAI-compatible API authentication at an
|
||||
2. **Within the CLI**: Use the `/auth` command to reconfigure your authentication method
|
||||
3. **Environment variables**: Set up `.env` files for automatic OpenAI-compatible API authentication
|
||||
|
||||
For detailed instructions, see the [Authentication Setup](/users/configuration/settings#environment-variables-for-api-access) documentation.
|
||||
For detailed instructions, see the [Authentication Setup](../configuration/settings#environment-variables-for-api-access) documentation.
|
||||
|
||||
@@ -31,7 +31,7 @@ This guide provides solutions to common issues and debugging tips, including top
|
||||
1. In your home directory: `~/.qwen/settings.json`.
|
||||
2. In your project's root directory: `./.qwen/settings.json`.
|
||||
|
||||
Refer to [Qwen Code Configuration](/users/configuration/settings) for more details.
|
||||
Refer to [Qwen Code Configuration](../configuration/settings) for more details.
|
||||
|
||||
- **Q: Why don't I see cached token counts in my stats output?**
|
||||
- A: Cached token information is only displayed when cached tokens are being used. This feature is available for API key users (Qwen API key or Google Cloud Vertex AI) but not for OAuth users (such as Google Personal/Enterprise accounts like Google Gmail or Google Workspace, respectively). This is because the Qwen Code Assist API does not support cached content creation. You can still view your total token usage using the `/stats` command.
|
||||
@@ -59,7 +59,7 @@ This guide provides solutions to common issues and debugging tips, including top
|
||||
|
||||
- **Error: "Operation not permitted", "Permission denied", or similar.**
|
||||
- **Cause:** When sandboxing is enabled, Qwen Code may attempt operations that are restricted by your sandbox configuration, such as writing outside the project directory or system temp directory.
|
||||
- **Solution:** Refer to the [Configuration: Sandboxing](/users/features/sandbox) documentation for more information, including how to customize your sandbox configuration.
|
||||
- **Solution:** Refer to the [Configuration: Sandboxing](../features/sandbox) documentation for more information, including how to customize your sandbox configuration.
|
||||
|
||||
- **Qwen Code is not running in interactive mode in "CI" environments**
|
||||
- **Issue:** Qwen Code does not enter interactive mode (no prompt appears) if an environment variable starting with `CI_` (e.g. `CI_TOKEN`) is set. This is because the `is-in-ci` package, used by the underlying UI framework, detects these variables and assumes a non-interactive CI environment.
|
||||
|
||||
@@ -5,8 +5,8 @@
|
||||
*/
|
||||
|
||||
import { describe, it, expect, beforeAll, afterAll } from 'vitest';
|
||||
import { writeFileSync, readFileSync } from 'node:fs';
|
||||
import { join, resolve } from 'node:path';
|
||||
import { writeFileSync } from 'node:fs';
|
||||
import { join } from 'node:path';
|
||||
import { TestRig } from './test-helper.js';
|
||||
|
||||
// Windows skip (Option A: avoid infra scope)
|
||||
@@ -121,21 +121,4 @@ d('BOM end-to-end integration', () => {
|
||||
'BOM_OK UTF-32BE',
|
||||
);
|
||||
});
|
||||
|
||||
it('Can describe a PNG file', async () => {
|
||||
const imagePath = resolve(
|
||||
process.cwd(),
|
||||
'docs/assets/gemini-screenshot.png',
|
||||
);
|
||||
const imageContent = readFileSync(imagePath);
|
||||
const filename = 'gemini-screenshot.png';
|
||||
writeFileSync(join(dir, filename), imageContent);
|
||||
const prompt = `What is shown in the image ${filename}?`;
|
||||
const output = await rig.run(prompt);
|
||||
await rig.waitForToolCall('read_file');
|
||||
const lower = output.toLowerCase();
|
||||
// The response is non-deterministic, so we just check for some
|
||||
// keywords that are very likely to be in the response.
|
||||
expect(lower.includes('gemini')).toBeTruthy();
|
||||
});
|
||||
});
|
||||
|
||||
87
package-lock.json
generated
87
package-lock.json
generated
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "@qwen-code/qwen-code",
|
||||
"version": "0.5.1",
|
||||
"version": "0.6.0",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "@qwen-code/qwen-code",
|
||||
"version": "0.5.1",
|
||||
"version": "0.6.0",
|
||||
"workspaces": [
|
||||
"packages/*"
|
||||
],
|
||||
@@ -568,7 +568,6 @@
|
||||
}
|
||||
],
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
},
|
||||
@@ -592,7 +591,6 @@
|
||||
}
|
||||
],
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
@@ -2157,7 +2155,6 @@
|
||||
"resolved": "https://registry.npmjs.org/@opentelemetry/api/-/api-1.9.0.tgz",
|
||||
"integrity": "sha512-3giAOQvZiH5F9bMlMiv8+GSPMeqg0dbaeo58/0SlA9sxSqZhnUtxzX9/2FzyhS9sWQf5S0GJE0AKBrFqjpeYcg==",
|
||||
"license": "Apache-2.0",
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=8.0.0"
|
||||
}
|
||||
@@ -3671,7 +3668,6 @@
|
||||
"resolved": "https://registry.npmjs.org/@testing-library/dom/-/dom-10.4.1.tgz",
|
||||
"integrity": "sha512-o4PXJQidqJl82ckFaXUeoAW+XysPLauYI43Abki5hABd853iMhitooc6znOnczgbTYmEP6U6/y1ZyKAIsvMKGg==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@babel/code-frame": "^7.10.4",
|
||||
"@babel/runtime": "^7.12.5",
|
||||
@@ -4142,7 +4138,6 @@
|
||||
"integrity": "sha512-AwAfQ2Wa5bCx9WP8nZL2uMZWod7J7/JSplxbTmBQ5ms6QpqNYm672H0Vu9ZVKVngQ+ii4R/byguVEUZQyeg44g==",
|
||||
"devOptional": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"csstype": "^3.0.2"
|
||||
}
|
||||
@@ -4153,7 +4148,6 @@
|
||||
"integrity": "sha512-4hOiT/dwO8Ko0gV1m/TJZYk3y0KBnY9vzDh7W+DH17b2HFSOGgdj33dhihPeuy3l0q23+4e+hoXHV6hCC4dCXw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"peerDependencies": {
|
||||
"@types/react": "^19.0.0"
|
||||
}
|
||||
@@ -4359,7 +4353,6 @@
|
||||
"integrity": "sha512-6sMvZePQrnZH2/cJkwRpkT7DxoAWh+g6+GFRK6bV3YQo7ogi3SX5rgF6099r5Q53Ma5qeT7LGmOmuIutF4t3lA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@typescript-eslint/scope-manager": "8.35.0",
|
||||
"@typescript-eslint/types": "8.35.0",
|
||||
@@ -5135,7 +5128,6 @@
|
||||
"resolved": "https://registry.npmjs.org/acorn/-/acorn-8.15.0.tgz",
|
||||
"integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"bin": {
|
||||
"acorn": "bin/acorn"
|
||||
},
|
||||
@@ -5530,7 +5522,8 @@
|
||||
"version": "1.1.1",
|
||||
"resolved": "https://registry.npmjs.org/array-flatten/-/array-flatten-1.1.1.tgz",
|
||||
"integrity": "sha512-PCVAQswWemu6UdxsDFFX/+gVeYqKAod3D3UVm91jHwynguOwAvYPhx8nNlM++NqRcK6CxxpUafjmhIdKiHibqg==",
|
||||
"license": "MIT"
|
||||
"license": "MIT",
|
||||
"peer": true
|
||||
},
|
||||
"node_modules/array-includes": {
|
||||
"version": "3.1.9",
|
||||
@@ -6865,6 +6858,7 @@
|
||||
"resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-0.5.4.tgz",
|
||||
"integrity": "sha512-FveZTNuGw04cxlAiWbzi6zTAL/lhehaWbTtgluJh4/E95DqMwTmha3KZN1aAWA8cFIhHzMZUvLevkw5Rqk+tSQ==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"safe-buffer": "5.2.1"
|
||||
},
|
||||
@@ -7982,7 +7976,6 @@
|
||||
"integrity": "sha512-GsGizj2Y1rCWDu6XoEekL3RLilp0voSePurjZIkxL3wlm5o5EC9VpgaP7lrCvjnkuLvzFBQWB3vWB3K5KQTveQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@eslint-community/eslint-utils": "^4.2.0",
|
||||
"@eslint-community/regexpp": "^4.12.1",
|
||||
@@ -8518,6 +8511,7 @@
|
||||
"resolved": "https://registry.npmjs.org/express/-/express-4.21.2.tgz",
|
||||
"integrity": "sha512-28HqgMZAmih1Czt9ny7qr6ek2qddF4FclbMzwhCREB6OFfH+rXAnuNCwo1/wFvrtbgsQDb4kSbX9de9lFbrXnA==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"accepts": "~1.3.8",
|
||||
"array-flatten": "1.1.1",
|
||||
@@ -8579,6 +8573,7 @@
|
||||
"resolved": "https://registry.npmjs.org/cookie/-/cookie-0.7.1.tgz",
|
||||
"integrity": "sha512-6DnInpx7SJ2AK3+CTUE/ZM0vWTUboZCegxhC2xiIydHR9jNuTAASBrfEpHhiGOZw/nX51bHt6YQl8jsGo4y/0w==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">= 0.6"
|
||||
}
|
||||
@@ -8588,6 +8583,7 @@
|
||||
"resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz",
|
||||
"integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"ms": "2.0.0"
|
||||
}
|
||||
@@ -8597,6 +8593,7 @@
|
||||
"resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.1.tgz",
|
||||
"integrity": "sha512-RwNA9Z/7PrK06rYLIzFMlaF+l73iwpzsqRIFgbMLbTcLD6cOao82TaWefPXQvB2fOC4AjuYSEndS7N/mTCbkdQ==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">= 0.8"
|
||||
}
|
||||
@@ -8763,6 +8760,7 @@
|
||||
"resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-1.3.1.tgz",
|
||||
"integrity": "sha512-6BN9trH7bp3qvnrRyzsBz+g3lZxTNZTbVO2EV1CS0WIcDbawYVdYvGflME/9QP0h0pYlCDBCTjYa9nZzMDpyxQ==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"debug": "2.6.9",
|
||||
"encodeurl": "~2.0.0",
|
||||
@@ -8781,6 +8779,7 @@
|
||||
"resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz",
|
||||
"integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"ms": "2.0.0"
|
||||
}
|
||||
@@ -8789,13 +8788,15 @@
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz",
|
||||
"integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==",
|
||||
"license": "MIT"
|
||||
"license": "MIT",
|
||||
"peer": true
|
||||
},
|
||||
"node_modules/finalhandler/node_modules/statuses": {
|
||||
"version": "2.0.1",
|
||||
"resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.1.tgz",
|
||||
"integrity": "sha512-RwNA9Z/7PrK06rYLIzFMlaF+l73iwpzsqRIFgbMLbTcLD6cOao82TaWefPXQvB2fOC4AjuYSEndS7N/mTCbkdQ==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">= 0.8"
|
||||
}
|
||||
@@ -9909,7 +9910,6 @@
|
||||
"resolved": "https://registry.npmjs.org/ink/-/ink-6.2.3.tgz",
|
||||
"integrity": "sha512-fQkfEJjKbLXIcVWEE3MvpYSnwtbbmRsmeNDNz1pIuOFlwE+UF2gsy228J36OXKZGWJWZJKUigphBSqCNMcARtg==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@alcalzone/ansi-tokenize": "^0.2.0",
|
||||
"ansi-escapes": "^7.0.0",
|
||||
@@ -11864,6 +11864,7 @@
|
||||
"resolved": "https://registry.npmjs.org/methods/-/methods-1.1.2.tgz",
|
||||
"integrity": "sha512-iclAHeNqNm68zFtnZ0e+1L2yUIdvzNoauKU4WBA3VvH/vPFieF7qfRlwUZU+DA9P9bPXIS90ulxoUoCH23sV2w==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">= 0.6"
|
||||
}
|
||||
@@ -13162,7 +13163,8 @@
|
||||
"version": "0.1.12",
|
||||
"resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-0.1.12.tgz",
|
||||
"integrity": "sha512-RA1GjUVMnvYFxuqovrEqZoxxW5NUZqbwKtYz/Tt7nXerk0LbLblQmrsgdeOxV5SFHf0UDggjS/bSeOZwt1pmEQ==",
|
||||
"license": "MIT"
|
||||
"license": "MIT",
|
||||
"peer": true
|
||||
},
|
||||
"node_modules/path-type": {
|
||||
"version": "3.0.0",
|
||||
@@ -13821,7 +13823,6 @@
|
||||
"resolved": "https://registry.npmjs.org/react/-/react-19.1.0.tgz",
|
||||
"integrity": "sha512-FS+XFBNvn3GTAWq26joslQgWNoFu08F4kl0J4CgdNKADkdSGXQyTCnKteIAJy96Br6YbpEU1LSzV5dYtjMkMDg==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
@@ -13832,7 +13833,6 @@
|
||||
"integrity": "sha512-cq/o30z9W2Wb4rzBefjv5fBalHU0rJGZCHAkf/RHSBWSSYwh8PlQTqqOJmgIIbBtpj27T6FIPXeomIjZtCNVqA==",
|
||||
"devOptional": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"shell-quote": "^1.6.1",
|
||||
"ws": "^7"
|
||||
@@ -13866,7 +13866,6 @@
|
||||
"integrity": "sha512-Xs1hdnE+DyKgeHJeJznQmYMIBG3TKIHJJT95Q58nHLSrElKlGQqDTR2HQ9fx5CN/Gk6Vh/kupBTDLU11/nDk/g==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"scheduler": "^0.26.0"
|
||||
},
|
||||
@@ -15932,7 +15931,6 @@
|
||||
"integrity": "sha512-M7BAV6Rlcy5u+m6oPhAPFgJTzAioX/6B0DxyvDlo9l8+T3nLKbrczg2WLUyzd45L8RqfUMyGPzekbMvX2Ldkwg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
},
|
||||
@@ -16112,8 +16110,7 @@
|
||||
"version": "2.8.1",
|
||||
"resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz",
|
||||
"integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==",
|
||||
"license": "0BSD",
|
||||
"peer": true
|
||||
"license": "0BSD"
|
||||
},
|
||||
"node_modules/tsx": {
|
||||
"version": "4.20.3",
|
||||
@@ -16121,7 +16118,6 @@
|
||||
"integrity": "sha512-qjbnuR9Tr+FJOMBqJCW5ehvIo/buZq7vH7qD7JziU98h6l3qGy0a/yPFjwO+y0/T7GFpNgNAvEcPPVfyT8rrPQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"esbuild": "~0.25.0",
|
||||
"get-tsconfig": "^4.7.5"
|
||||
@@ -16316,7 +16312,6 @@
|
||||
"integrity": "sha512-p1diW6TqL9L07nNxvRMM7hMMw4c5XOo/1ibL4aAIGmSAt9slTE1Xgw5KWuof2uTOvCg9BY7ZRi+GaF+7sfgPeQ==",
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"peer": true,
|
||||
"bin": {
|
||||
"tsc": "bin/tsc",
|
||||
"tsserver": "bin/tsserver"
|
||||
@@ -16391,6 +16386,7 @@
|
||||
"version": "7.15.0",
|
||||
"resolved": "https://registry.npmjs.org/undici/-/undici-7.15.0.tgz",
|
||||
"integrity": "sha512-7oZJCPvvMvTd0OlqWsIxTuItTpJBpU1tcbVl24FMn3xt3+VSunwUasmfPJRE57oNO1KsZ4PgA1xTdAX4hq8NyQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=20.18.1"
|
||||
@@ -16623,6 +16619,7 @@
|
||||
"resolved": "https://registry.npmjs.org/utils-merge/-/utils-merge-1.0.1.tgz",
|
||||
"integrity": "sha512-pMZTvIkT1d+TFGvDOqodOclx0QWkkgi6Tdoa8gC8ffGAAqz9pzPTZWAybbsHHoED/ztMtkv/VoYTYyShUn81hA==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">= 0.4.0"
|
||||
}
|
||||
@@ -16678,7 +16675,6 @@
|
||||
"integrity": "sha512-ixXJB1YRgDIw2OszKQS9WxGHKwLdCsbQNkpJN171udl6szi/rIySHL6/Os3s2+oE4P/FLD4dxg4mD7Wust+u5g==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"esbuild": "^0.25.0",
|
||||
"fdir": "^6.4.6",
|
||||
@@ -16792,7 +16788,6 @@
|
||||
"integrity": "sha512-M7BAV6Rlcy5u+m6oPhAPFgJTzAioX/6B0DxyvDlo9l8+T3nLKbrczg2WLUyzd45L8RqfUMyGPzekbMvX2Ldkwg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
},
|
||||
@@ -16806,7 +16801,6 @@
|
||||
"integrity": "sha512-LUCP5ev3GURDysTWiP47wRRUpLKMOfPh+yKTx3kVIEiu5KOMeqzpnYNsKyOoVrULivR8tLcks4+lga33Whn90A==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@types/chai": "^5.2.2",
|
||||
"@vitest/expect": "3.2.4",
|
||||
@@ -17485,7 +17479,6 @@
|
||||
"resolved": "https://registry.npmjs.org/zod/-/zod-3.25.76.tgz",
|
||||
"integrity": "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/colinhacks"
|
||||
}
|
||||
@@ -17501,7 +17494,7 @@
|
||||
},
|
||||
"packages/cli": {
|
||||
"name": "@qwen-code/qwen-code",
|
||||
"version": "0.5.1",
|
||||
"version": "0.6.0",
|
||||
"dependencies": {
|
||||
"@google/genai": "1.16.0",
|
||||
"@iarna/toml": "^2.2.5",
|
||||
@@ -17532,7 +17525,7 @@
|
||||
"strip-ansi": "^7.1.0",
|
||||
"strip-json-comments": "^3.1.1",
|
||||
"tar": "^7.5.2",
|
||||
"undici": "^7.10.0",
|
||||
"undici": "^6.22.0",
|
||||
"update-notifier": "^7.3.1",
|
||||
"wrap-ansi": "9.0.2",
|
||||
"yargs": "^17.7.2",
|
||||
@@ -17614,9 +17607,18 @@
|
||||
"url": "https://github.com/sponsors/sindresorhus"
|
||||
}
|
||||
},
|
||||
"packages/cli/node_modules/undici": {
|
||||
"version": "6.22.0",
|
||||
"resolved": "https://registry.npmjs.org/undici/-/undici-6.22.0.tgz",
|
||||
"integrity": "sha512-hU/10obOIu62MGYjdskASR3CUAiYaFTtC9Pa6vHyf//mAipSvSQg6od2CnJswq7fvzNS3zJhxoRkgNVaHurWKw==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=18.17"
|
||||
}
|
||||
},
|
||||
"packages/core": {
|
||||
"name": "@qwen-code/qwen-code-core",
|
||||
"version": "0.5.1",
|
||||
"version": "0.6.0",
|
||||
"hasInstallScript": true,
|
||||
"dependencies": {
|
||||
"@google/genai": "1.16.0",
|
||||
@@ -17659,7 +17661,7 @@
|
||||
"simple-git": "^3.28.0",
|
||||
"strip-ansi": "^7.1.0",
|
||||
"tiktoken": "^1.0.21",
|
||||
"undici": "^7.10.0",
|
||||
"undici": "^6.22.0",
|
||||
"uuid": "^9.0.1",
|
||||
"ws": "^8.18.0"
|
||||
},
|
||||
@@ -17747,7 +17749,6 @@
|
||||
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz",
|
||||
"integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
},
|
||||
@@ -17755,12 +17756,22 @@
|
||||
"url": "https://github.com/sponsors/jonschlinkert"
|
||||
}
|
||||
},
|
||||
"packages/core/node_modules/undici": {
|
||||
"version": "6.22.0",
|
||||
"resolved": "https://registry.npmjs.org/undici/-/undici-6.22.0.tgz",
|
||||
"integrity": "sha512-hU/10obOIu62MGYjdskASR3CUAiYaFTtC9Pa6vHyf//mAipSvSQg6od2CnJswq7fvzNS3zJhxoRkgNVaHurWKw==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=18.17"
|
||||
}
|
||||
},
|
||||
"packages/sdk-typescript": {
|
||||
"name": "@qwen-code/sdk",
|
||||
"version": "0.5.1",
|
||||
"version": "0.6.0",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"@modelcontextprotocol/sdk": "^1.0.4"
|
||||
"@modelcontextprotocol/sdk": "^1.0.4",
|
||||
"tiktoken": "^1.0.21"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^20.14.0",
|
||||
@@ -20186,7 +20197,7 @@
|
||||
},
|
||||
"packages/test-utils": {
|
||||
"name": "@qwen-code/qwen-code-test-utils",
|
||||
"version": "0.5.1",
|
||||
"version": "0.6.0",
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"devDependencies": {
|
||||
@@ -20198,7 +20209,7 @@
|
||||
},
|
||||
"packages/vscode-ide-companion": {
|
||||
"name": "qwen-code-vscode-ide-companion",
|
||||
"version": "0.5.1",
|
||||
"version": "0.6.0",
|
||||
"license": "LICENSE",
|
||||
"dependencies": {
|
||||
"@modelcontextprotocol/sdk": "^1.15.1",
|
||||
@@ -20218,7 +20229,7 @@
|
||||
"@types/react": "^19.1.8",
|
||||
"@types/react-dom": "^19.1.6",
|
||||
"@types/semver": "^7.7.1",
|
||||
"@types/vscode": "^1.99.0",
|
||||
"@types/vscode": "^1.85.0",
|
||||
"@typescript-eslint/eslint-plugin": "^8.31.1",
|
||||
"@typescript-eslint/parser": "^8.31.1",
|
||||
"@vscode/vsce": "^3.6.0",
|
||||
@@ -20233,7 +20244,7 @@
|
||||
"vitest": "^3.2.4"
|
||||
},
|
||||
"engines": {
|
||||
"vscode": "^1.99.0"
|
||||
"vscode": "^1.85.0"
|
||||
}
|
||||
},
|
||||
"packages/vscode-ide-companion/node_modules/@types/react": {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@qwen-code/qwen-code",
|
||||
"version": "0.5.1",
|
||||
"version": "0.6.0",
|
||||
"engines": {
|
||||
"node": ">=20.0.0"
|
||||
},
|
||||
@@ -13,7 +13,7 @@
|
||||
"url": "git+https://github.com/QwenLM/qwen-code.git"
|
||||
},
|
||||
"config": {
|
||||
"sandboxImageUri": "ghcr.io/qwenlm/qwen-code:0.5.1"
|
||||
"sandboxImageUri": "ghcr.io/qwenlm/qwen-code:0.6.0"
|
||||
},
|
||||
"scripts": {
|
||||
"start": "cross-env node scripts/start.js",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@qwen-code/qwen-code",
|
||||
"version": "0.5.1",
|
||||
"version": "0.6.0",
|
||||
"description": "Qwen Code",
|
||||
"repository": {
|
||||
"type": "git",
|
||||
@@ -33,7 +33,7 @@
|
||||
"dist"
|
||||
],
|
||||
"config": {
|
||||
"sandboxImageUri": "ghcr.io/qwenlm/qwen-code:0.5.1"
|
||||
"sandboxImageUri": "ghcr.io/qwenlm/qwen-code:0.6.0"
|
||||
},
|
||||
"dependencies": {
|
||||
"@google/genai": "1.16.0",
|
||||
@@ -64,7 +64,7 @@
|
||||
"strip-ansi": "^7.1.0",
|
||||
"strip-json-comments": "^3.1.1",
|
||||
"tar": "^7.5.2",
|
||||
"undici": "^7.10.0",
|
||||
"undici": "^6.22.0",
|
||||
"extract-zip": "^2.0.1",
|
||||
"update-notifier": "^7.3.1",
|
||||
"wrap-ansi": "9.0.2",
|
||||
|
||||
@@ -206,6 +206,18 @@ describe('parseArguments', () => {
|
||||
expect(argv.prompt).toBeUndefined();
|
||||
});
|
||||
|
||||
it('should allow -r flag as alias for --resume', async () => {
|
||||
process.argv = ['node', 'script.js', '-r', 'session-123'];
|
||||
const argv = await parseArguments({} as Settings);
|
||||
expect(argv.resume).toBe('session-123');
|
||||
});
|
||||
|
||||
it('should allow -c flag as alias for --continue', async () => {
|
||||
process.argv = ['node', 'script.js', '-c'];
|
||||
const argv = await parseArguments({} as Settings);
|
||||
expect(argv.continue).toBe(true);
|
||||
});
|
||||
|
||||
it('should convert positional query argument to prompt by default', async () => {
|
||||
process.argv = ['node', 'script.js', 'Hi Gemini'];
|
||||
const argv = await parseArguments({} as Settings);
|
||||
|
||||
@@ -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)',
|
||||
})
|
||||
.option('checkpointing', {
|
||||
alias: 'c',
|
||||
type: 'boolean',
|
||||
description: 'Enables checkpointing of file edits',
|
||||
default: false,
|
||||
@@ -422,12 +421,14 @@ export async function parseArguments(settings: Settings): Promise<CliArgs> {
|
||||
default: false,
|
||||
})
|
||||
.option('continue', {
|
||||
alias: 'c',
|
||||
type: 'boolean',
|
||||
description:
|
||||
'Resume the most recent session for the current project.',
|
||||
default: false,
|
||||
})
|
||||
.option('resume', {
|
||||
alias: 'r',
|
||||
type: 'string',
|
||||
description:
|
||||
'Resume a specific session by its ID. Use without an ID to show session picker.',
|
||||
|
||||
@@ -659,6 +659,22 @@ const SETTINGS_SCHEMA = {
|
||||
childKey: 'disableCacheControl',
|
||||
showInDialog: true,
|
||||
},
|
||||
schemaCompliance: {
|
||||
type: 'enum',
|
||||
label: 'Tool Schema Compliance',
|
||||
category: 'Generation Configuration',
|
||||
requiresRestart: false,
|
||||
default: 'auto',
|
||||
description:
|
||||
'The compliance mode for tool schemas sent to the model. Use "openapi_30" for strict OpenAPI 3.0 compatibility (e.g., for Gemini).',
|
||||
parentKey: 'generationConfig',
|
||||
childKey: 'schemaCompliance',
|
||||
showInDialog: true,
|
||||
options: [
|
||||
{ value: 'auto', label: 'Auto (Default)' },
|
||||
{ value: 'openapi_30', label: 'OpenAPI 3.0 Strict' },
|
||||
],
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
@@ -58,7 +58,7 @@ import { getUserStartupWarnings } from './utils/userStartupWarnings.js';
|
||||
import { getCliVersion } from './utils/version.js';
|
||||
import { computeWindowTitle } from './utils/windowTitle.js';
|
||||
import { validateNonInteractiveAuth } from './validateNonInterActiveAuth.js';
|
||||
import { showResumeSessionPicker } from './ui/components/ResumeSessionPicker.js';
|
||||
import { showResumeSessionPicker } from './ui/components/StandaloneSessionPicker.js';
|
||||
|
||||
export function validateDnsResolutionOrder(
|
||||
order: string | undefined,
|
||||
|
||||
@@ -310,6 +310,7 @@ export default {
|
||||
'Tool Output Truncation Lines': 'Tool Output Truncation Lines',
|
||||
'Folder Trust': 'Folder Trust',
|
||||
'Vision Model Preview': 'Vision Model Preview',
|
||||
'Tool Schema Compliance': 'Tool Schema Compliance',
|
||||
// Settings enum options
|
||||
'Auto (detect from system)': 'Auto (detect from system)',
|
||||
Text: 'Text',
|
||||
|
||||
@@ -300,6 +300,7 @@ export default {
|
||||
'Tool Output Truncation Lines': '工具输出截断行数',
|
||||
'Folder Trust': '文件夹信任',
|
||||
'Vision Model Preview': '视觉模型预览',
|
||||
'Tool Schema Compliance': '工具 Schema 兼容性',
|
||||
// Settings enum options
|
||||
'Auto (detect from system)': '自动(从系统检测)',
|
||||
Text: '文本',
|
||||
|
||||
@@ -29,6 +29,7 @@ import { modelCommand } from '../ui/commands/modelCommand.js';
|
||||
import { permissionsCommand } from '../ui/commands/permissionsCommand.js';
|
||||
import { quitCommand } from '../ui/commands/quitCommand.js';
|
||||
import { restoreCommand } from '../ui/commands/restoreCommand.js';
|
||||
import { resumeCommand } from '../ui/commands/resumeCommand.js';
|
||||
import { settingsCommand } from '../ui/commands/settingsCommand.js';
|
||||
import { statsCommand } from '../ui/commands/statsCommand.js';
|
||||
import { summaryCommand } from '../ui/commands/summaryCommand.js';
|
||||
@@ -76,6 +77,7 @@ export class BuiltinCommandLoader implements ICommandLoader {
|
||||
...(this.config?.getFolderTrust() ? [permissionsCommand] : []),
|
||||
quitCommand,
|
||||
restoreCommand(this.config),
|
||||
resumeCommand,
|
||||
statsCommand,
|
||||
summaryCommand,
|
||||
themeCommand,
|
||||
|
||||
@@ -53,6 +53,7 @@ import { useEditorSettings } from './hooks/useEditorSettings.js';
|
||||
import { useSettingsCommand } from './hooks/useSettingsCommand.js';
|
||||
import { useModelCommand } from './hooks/useModelCommand.js';
|
||||
import { useApprovalModeCommand } from './hooks/useApprovalModeCommand.js';
|
||||
import { useResumeCommand } from './hooks/useResumeCommand.js';
|
||||
import { useSlashCommandProcessor } from './hooks/slashCommandProcessor.js';
|
||||
import { useVimMode } from './contexts/VimModeContext.js';
|
||||
import { useConsoleMessages } from './hooks/useConsoleMessages.js';
|
||||
@@ -203,7 +204,7 @@ export const AppContainer = (props: AppContainerProps) => {
|
||||
const { stdout } = useStdout();
|
||||
|
||||
// Additional hooks moved from App.tsx
|
||||
const { stats: sessionStats } = useSessionStats();
|
||||
const { stats: sessionStats, startNewSession } = useSessionStats();
|
||||
const logger = useLogger(config.storage, sessionStats.sessionId);
|
||||
const branchName = useGitBranchName(config.getTargetDir());
|
||||
|
||||
@@ -435,6 +436,18 @@ export const AppContainer = (props: AppContainerProps) => {
|
||||
const { isModelDialogOpen, openModelDialog, closeModelDialog } =
|
||||
useModelCommand();
|
||||
|
||||
const {
|
||||
isResumeDialogOpen,
|
||||
openResumeDialog,
|
||||
closeResumeDialog,
|
||||
handleResume,
|
||||
} = useResumeCommand({
|
||||
config,
|
||||
historyManager,
|
||||
startNewSession,
|
||||
remount: refreshStatic,
|
||||
});
|
||||
|
||||
const {
|
||||
showWorkspaceMigrationDialog,
|
||||
workspaceExtensions,
|
||||
@@ -488,6 +501,7 @@ export const AppContainer = (props: AppContainerProps) => {
|
||||
addConfirmUpdateExtensionRequest,
|
||||
openSubagentCreateDialog,
|
||||
openAgentsManagerDialog,
|
||||
openResumeDialog,
|
||||
}),
|
||||
[
|
||||
openAuthDialog,
|
||||
@@ -502,6 +516,7 @@ export const AppContainer = (props: AppContainerProps) => {
|
||||
addConfirmUpdateExtensionRequest,
|
||||
openSubagentCreateDialog,
|
||||
openAgentsManagerDialog,
|
||||
openResumeDialog,
|
||||
],
|
||||
);
|
||||
|
||||
@@ -1194,7 +1209,8 @@ export const AppContainer = (props: AppContainerProps) => {
|
||||
!!proQuotaRequest ||
|
||||
isSubagentCreateDialogOpen ||
|
||||
isAgentsManagerDialogOpen ||
|
||||
isApprovalModeDialogOpen;
|
||||
isApprovalModeDialogOpen ||
|
||||
isResumeDialogOpen;
|
||||
|
||||
const pendingHistoryItems = useMemo(
|
||||
() => [...pendingSlashCommandHistoryItems, ...pendingGeminiHistoryItems],
|
||||
@@ -1222,6 +1238,7 @@ export const AppContainer = (props: AppContainerProps) => {
|
||||
isModelDialogOpen,
|
||||
isPermissionsDialogOpen,
|
||||
isApprovalModeDialogOpen,
|
||||
isResumeDialogOpen,
|
||||
slashCommands,
|
||||
pendingSlashCommandHistoryItems,
|
||||
commandContext,
|
||||
@@ -1312,6 +1329,7 @@ export const AppContainer = (props: AppContainerProps) => {
|
||||
isModelDialogOpen,
|
||||
isPermissionsDialogOpen,
|
||||
isApprovalModeDialogOpen,
|
||||
isResumeDialogOpen,
|
||||
slashCommands,
|
||||
pendingSlashCommandHistoryItems,
|
||||
commandContext,
|
||||
@@ -1421,6 +1439,10 @@ export const AppContainer = (props: AppContainerProps) => {
|
||||
// Subagent dialogs
|
||||
closeSubagentCreateDialog,
|
||||
closeAgentsManagerDialog,
|
||||
// Resume session dialog
|
||||
openResumeDialog,
|
||||
closeResumeDialog,
|
||||
handleResume,
|
||||
}),
|
||||
[
|
||||
handleThemeSelect,
|
||||
@@ -1453,6 +1475,10 @@ export const AppContainer = (props: AppContainerProps) => {
|
||||
// Subagent dialogs
|
||||
closeSubagentCreateDialog,
|
||||
closeAgentsManagerDialog,
|
||||
// Resume session dialog
|
||||
openResumeDialog,
|
||||
closeResumeDialog,
|
||||
handleResume,
|
||||
],
|
||||
);
|
||||
|
||||
|
||||
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_list'
|
||||
| 'permissions'
|
||||
| 'approval-mode';
|
||||
| 'approval-mode'
|
||||
| 'resume';
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -36,6 +36,7 @@ import { WelcomeBackDialog } from './WelcomeBackDialog.js';
|
||||
import { ModelSwitchDialog } from './ModelSwitchDialog.js';
|
||||
import { AgentCreationWizard } from './subagents/create/AgentCreationWizard.js';
|
||||
import { AgentsManagerDialog } from './subagents/manage/AgentsManagerDialog.js';
|
||||
import { SessionPicker } from './SessionPicker.js';
|
||||
|
||||
interface DialogManagerProps {
|
||||
addItem: UseHistoryManagerReturn['addItem'];
|
||||
@@ -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;
|
||||
};
|
||||
|
||||
@@ -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
|
||||
closeSubagentCreateDialog: () => void;
|
||||
closeAgentsManagerDialog: () => void;
|
||||
// Resume session dialog
|
||||
openResumeDialog: () => void;
|
||||
closeResumeDialog: () => void;
|
||||
handleResume: (sessionId: string) => void;
|
||||
}
|
||||
|
||||
export const UIActionsContext = createContext<UIActions | null>(null);
|
||||
|
||||
@@ -60,6 +60,7 @@ export interface UIState {
|
||||
isModelDialogOpen: boolean;
|
||||
isPermissionsDialogOpen: boolean;
|
||||
isApprovalModeDialogOpen: boolean;
|
||||
isResumeDialogOpen: boolean;
|
||||
slashCommands: readonly SlashCommand[];
|
||||
pendingSlashCommandHistoryItems: HistoryItemWithoutId[];
|
||||
commandContext: CommandContext;
|
||||
|
||||
@@ -56,6 +56,7 @@ const SLASH_COMMANDS_SKIP_RECORDING = new Set([
|
||||
'clear',
|
||||
'reset',
|
||||
'new',
|
||||
'resume',
|
||||
]);
|
||||
|
||||
interface SlashCommandProcessorActions {
|
||||
@@ -66,6 +67,7 @@ interface SlashCommandProcessorActions {
|
||||
openModelDialog: () => void;
|
||||
openPermissionsDialog: () => void;
|
||||
openApprovalModeDialog: () => void;
|
||||
openResumeDialog: () => void;
|
||||
quit: (messages: HistoryItem[]) => void;
|
||||
setDebugMessage: (message: string) => void;
|
||||
dispatchExtensionStateUpdate: (action: ExtensionUpdateAction) => void;
|
||||
@@ -417,6 +419,9 @@ export const useSlashCommandProcessor = (
|
||||
case 'approval-mode':
|
||||
actions.openApprovalModeDialog();
|
||||
return { type: 'handled' };
|
||||
case 'resume':
|
||||
actions.openResumeDialog();
|
||||
return { type: 'handled' };
|
||||
case 'help':
|
||||
return { type: 'handled' };
|
||||
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' });
|
||||
});
|
||||
|
||||
// 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 () => {
|
||||
|
||||
@@ -103,17 +103,38 @@ export function getGitHubRepoInfo(): { owner: string; repo: string } {
|
||||
encoding: 'utf-8',
|
||||
}).trim();
|
||||
|
||||
// Matches either https://github.com/owner/repo.git or git@github.com:owner/repo.git
|
||||
const match = remoteUrl.match(
|
||||
/(?:https?:\/\/|git@)github\.com(?::|\/)([^/]+)\/([^/]+?)(?:\.git)?$/,
|
||||
);
|
||||
|
||||
// If the regex fails match, throw an error.
|
||||
if (!match || !match[1] || !match[2]) {
|
||||
// Handle SCP-style SSH URLs (git@github.com:owner/repo.git)
|
||||
let urlToParse = remoteUrl;
|
||||
if (remoteUrl.startsWith('git@github.com:')) {
|
||||
urlToParse = remoteUrl.replace('git@github.com:', '');
|
||||
} else if (remoteUrl.startsWith('git@')) {
|
||||
// SSH URL for a different provider (GitLab, Bitbucket, etc.)
|
||||
throw new Error(
|
||||
`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$/, '') };
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@qwen-code/qwen-code-core",
|
||||
"version": "0.5.1",
|
||||
"version": "0.6.0",
|
||||
"description": "Qwen Code Core",
|
||||
"repository": {
|
||||
"type": "git",
|
||||
@@ -63,7 +63,7 @@
|
||||
"simple-git": "^3.28.0",
|
||||
"strip-ansi": "^7.1.0",
|
||||
"tiktoken": "^1.0.21",
|
||||
"undici": "^7.10.0",
|
||||
"undici": "^6.22.0",
|
||||
"uuid": "^9.0.1",
|
||||
"ws": "^8.18.0"
|
||||
},
|
||||
|
||||
@@ -741,9 +741,12 @@ export class Config {
|
||||
/**
|
||||
* Starts a new session and resets session-scoped services.
|
||||
*/
|
||||
startNewSession(sessionId?: string): string {
|
||||
startNewSession(
|
||||
sessionId?: string,
|
||||
sessionData?: ResumedSessionData,
|
||||
): string {
|
||||
this.sessionId = sessionId ?? randomUUID();
|
||||
this.sessionData = undefined;
|
||||
this.sessionData = sessionData;
|
||||
this.chatRecordingService = this.chatRecordingEnabled
|
||||
? new ChatRecordingService(this)
|
||||
: undefined;
|
||||
|
||||
@@ -15,6 +15,7 @@ export const OAUTH_FILE = 'oauth_creds.json';
|
||||
const TMP_DIR_NAME = 'tmp';
|
||||
const BIN_DIR_NAME = 'bin';
|
||||
const PROJECT_DIR_NAME = 'projects';
|
||||
const IDE_DIR_NAME = 'ide';
|
||||
|
||||
export class Storage {
|
||||
private readonly targetDir: string;
|
||||
@@ -59,6 +60,10 @@ export class Storage {
|
||||
return path.join(Storage.getGlobalQwenDir(), TMP_DIR_NAME);
|
||||
}
|
||||
|
||||
static getGlobalIdeDir(): string {
|
||||
return path.join(Storage.getGlobalQwenDir(), IDE_DIR_NAME);
|
||||
}
|
||||
|
||||
static getGlobalBinDir(): string {
|
||||
return path.join(Storage.getGlobalQwenDir(), BIN_DIR_NAME);
|
||||
}
|
||||
|
||||
@@ -76,6 +76,8 @@ export type ContentGeneratorConfig = {
|
||||
};
|
||||
proxy?: string | undefined;
|
||||
userAgent?: string;
|
||||
// Schema compliance mode for tool definitions
|
||||
schemaCompliance?: 'auto' | 'openapi_30';
|
||||
};
|
||||
|
||||
export function createContentGeneratorConfig(
|
||||
|
||||
@@ -22,6 +22,10 @@ import { GenerateContentResponse, FinishReason } from '@google/genai';
|
||||
import type OpenAI from 'openai';
|
||||
import { safeJsonParse } from '../../utils/safeJsonParse.js';
|
||||
import { StreamingToolCallParser } from './streamingToolCallParser.js';
|
||||
import {
|
||||
convertSchema,
|
||||
type SchemaComplianceMode,
|
||||
} from '../../utils/schemaConverter.js';
|
||||
|
||||
/**
|
||||
* Extended usage type that supports both OpenAI standard format and alternative formats
|
||||
@@ -80,11 +84,13 @@ interface ParsedParts {
|
||||
*/
|
||||
export class OpenAIContentConverter {
|
||||
private model: string;
|
||||
private schemaCompliance: SchemaComplianceMode;
|
||||
private streamingToolCallParser: StreamingToolCallParser =
|
||||
new StreamingToolCallParser();
|
||||
|
||||
constructor(model: string) {
|
||||
constructor(model: string, schemaCompliance: SchemaComplianceMode = 'auto') {
|
||||
this.model = model;
|
||||
this.schemaCompliance = schemaCompliance;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -205,6 +211,10 @@ export class OpenAIContentConverter {
|
||||
);
|
||||
}
|
||||
|
||||
if (parameters) {
|
||||
parameters = convertSchema(parameters, this.schemaCompliance);
|
||||
}
|
||||
|
||||
openAITools.push({
|
||||
type: 'function',
|
||||
function: {
|
||||
|
||||
@@ -108,7 +108,10 @@ describe('ContentGenerationPipeline', () => {
|
||||
describe('constructor', () => {
|
||||
it('should initialize with correct configuration', () => {
|
||||
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.converter = new OpenAIContentConverter(
|
||||
this.contentGeneratorConfig.model,
|
||||
this.contentGeneratorConfig.schemaCompliance,
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -32,6 +32,7 @@ vi.mock('node:fs', async (importOriginal) => {
|
||||
...actual.promises,
|
||||
readFile: vi.fn(),
|
||||
readdir: vi.fn(),
|
||||
stat: vi.fn(),
|
||||
},
|
||||
realpathSync: (p: string) => p,
|
||||
existsSync: () => false,
|
||||
@@ -68,6 +69,7 @@ describe('IdeClient', () => {
|
||||
command: 'test-ide',
|
||||
});
|
||||
vi.mocked(os.tmpdir).mockReturnValue('/tmp');
|
||||
vi.mocked(os.homedir).mockReturnValue('/home/test');
|
||||
|
||||
// Mock MCP client and transports
|
||||
mockClient = {
|
||||
@@ -97,19 +99,15 @@ describe('IdeClient', () => {
|
||||
|
||||
describe('connect', () => {
|
||||
it('should connect using HTTP when port is provided in config file', async () => {
|
||||
process.env['QWEN_CODE_IDE_SERVER_PORT'] = '8080';
|
||||
const config = { port: '8080' };
|
||||
vi.mocked(fs.promises.readFile).mockResolvedValue(JSON.stringify(config));
|
||||
(
|
||||
vi.mocked(fs.promises.readdir) as Mock<
|
||||
(path: fs.PathLike) => Promise<string[]>
|
||||
>
|
||||
).mockResolvedValue([]);
|
||||
|
||||
const ideClient = await IdeClient.getInstance();
|
||||
await ideClient.connect();
|
||||
|
||||
expect(fs.promises.readFile).toHaveBeenCalledWith(
|
||||
path.join('/tmp', 'qwen-code-ide-server-12345.json'),
|
||||
path.join('/home/test', '.qwen', 'ide', '8080.lock'),
|
||||
'utf8',
|
||||
);
|
||||
expect(StreamableHTTPClientTransport).toHaveBeenCalledWith(
|
||||
@@ -120,16 +118,13 @@ describe('IdeClient', () => {
|
||||
expect(ideClient.getConnectionStatus().status).toBe(
|
||||
IDEConnectionStatus.Connected,
|
||||
);
|
||||
delete process.env['QWEN_CODE_IDE_SERVER_PORT'];
|
||||
});
|
||||
|
||||
it('should connect using stdio when stdio config is provided in file', async () => {
|
||||
process.env['QWEN_CODE_IDE_SERVER_PORT'] = '8080';
|
||||
const config = { stdio: { command: 'test-cmd', args: ['--foo'] } };
|
||||
vi.mocked(fs.promises.readFile).mockResolvedValue(JSON.stringify(config));
|
||||
(
|
||||
vi.mocked(fs.promises.readdir) as Mock<
|
||||
(path: fs.PathLike) => Promise<string[]>
|
||||
>
|
||||
).mockResolvedValue([]);
|
||||
|
||||
const ideClient = await IdeClient.getInstance();
|
||||
await ideClient.connect();
|
||||
@@ -142,19 +137,16 @@ describe('IdeClient', () => {
|
||||
expect(ideClient.getConnectionStatus().status).toBe(
|
||||
IDEConnectionStatus.Connected,
|
||||
);
|
||||
delete process.env['QWEN_CODE_IDE_SERVER_PORT'];
|
||||
});
|
||||
|
||||
it('should prioritize port over stdio when both are in config file', async () => {
|
||||
process.env['QWEN_CODE_IDE_SERVER_PORT'] = '8080';
|
||||
const config = {
|
||||
port: '8080',
|
||||
stdio: { command: 'test-cmd', args: ['--foo'] },
|
||||
};
|
||||
vi.mocked(fs.promises.readFile).mockResolvedValue(JSON.stringify(config));
|
||||
(
|
||||
vi.mocked(fs.promises.readdir) as Mock<
|
||||
(path: fs.PathLike) => Promise<string[]>
|
||||
>
|
||||
).mockResolvedValue([]);
|
||||
|
||||
const ideClient = await IdeClient.getInstance();
|
||||
await ideClient.connect();
|
||||
@@ -164,6 +156,7 @@ describe('IdeClient', () => {
|
||||
expect(ideClient.getConnectionStatus().status).toBe(
|
||||
IDEConnectionStatus.Connected,
|
||||
);
|
||||
delete process.env['QWEN_CODE_IDE_SERVER_PORT'];
|
||||
});
|
||||
|
||||
it('should connect using HTTP when port is provided in environment variables', async () => {
|
||||
@@ -263,7 +256,8 @@ describe('IdeClient', () => {
|
||||
});
|
||||
|
||||
describe('getConnectionConfigFromFile', () => {
|
||||
it('should return config from the specific pid file if it exists', async () => {
|
||||
it('should return config from the env port lock file if it exists', async () => {
|
||||
process.env['QWEN_CODE_IDE_SERVER_PORT'] = '1234';
|
||||
const config = { port: '1234', workspacePath: '/test/workspace' };
|
||||
vi.mocked(fs.promises.readFile).mockResolvedValue(JSON.stringify(config));
|
||||
|
||||
@@ -277,18 +271,14 @@ describe('IdeClient', () => {
|
||||
|
||||
expect(result).toEqual(config);
|
||||
expect(fs.promises.readFile).toHaveBeenCalledWith(
|
||||
path.join('/tmp', 'qwen-code-ide-server-12345.json'),
|
||||
path.join('/home/test', '.qwen', 'ide', '1234.lock'),
|
||||
'utf8',
|
||||
);
|
||||
delete process.env['QWEN_CODE_IDE_SERVER_PORT'];
|
||||
});
|
||||
|
||||
it('should return undefined if no config files are found', async () => {
|
||||
vi.mocked(fs.promises.readFile).mockRejectedValue(new Error('not found'));
|
||||
(
|
||||
vi.mocked(fs.promises.readdir) as Mock<
|
||||
(path: fs.PathLike) => Promise<string[]>
|
||||
>
|
||||
).mockResolvedValue([]);
|
||||
|
||||
const ideClient = await IdeClient.getInstance();
|
||||
const result = await (
|
||||
@@ -300,20 +290,15 @@ describe('IdeClient', () => {
|
||||
expect(result).toBeUndefined();
|
||||
});
|
||||
|
||||
it('should find and parse a single config file with the new naming scheme', async () => {
|
||||
const config = { port: '5678', workspacePath: '/test/workspace' };
|
||||
vi.mocked(fs.promises.readFile).mockRejectedValueOnce(
|
||||
new Error('not found'),
|
||||
); // For old path
|
||||
(
|
||||
vi.mocked(fs.promises.readdir) as Mock<
|
||||
(path: fs.PathLike) => Promise<string[]>
|
||||
>
|
||||
).mockResolvedValue(['qwen-code-ide-server-12345-123.json']);
|
||||
vi.mocked(fs.promises.readFile).mockResolvedValue(JSON.stringify(config));
|
||||
vi.spyOn(IdeClient, 'validateWorkspacePath').mockReturnValue({
|
||||
isValid: true,
|
||||
});
|
||||
it('should read legacy pid config when available', async () => {
|
||||
const config = {
|
||||
port: '5678',
|
||||
workspacePath: '/test/workspace',
|
||||
ppid: 12345,
|
||||
};
|
||||
vi.mocked(fs.promises.readFile).mockResolvedValueOnce(
|
||||
JSON.stringify(config),
|
||||
);
|
||||
|
||||
const ideClient = await IdeClient.getInstance();
|
||||
const result = await (
|
||||
@@ -324,110 +309,18 @@ describe('IdeClient', () => {
|
||||
|
||||
expect(result).toEqual(config);
|
||||
expect(fs.promises.readFile).toHaveBeenCalledWith(
|
||||
path.join('/tmp/gemini/ide', 'qwen-code-ide-server-12345-123.json'),
|
||||
path.join('/tmp', 'qwen-code-ide-server-12345.json'),
|
||||
'utf8',
|
||||
);
|
||||
});
|
||||
|
||||
it('should filter out configs with invalid workspace paths', async () => {
|
||||
const validConfig = {
|
||||
port: '5678',
|
||||
workspacePath: '/test/workspace',
|
||||
};
|
||||
const invalidConfig = {
|
||||
port: '1111',
|
||||
workspacePath: '/invalid/workspace',
|
||||
};
|
||||
vi.mocked(fs.promises.readFile).mockRejectedValueOnce(
|
||||
new Error('not found'),
|
||||
);
|
||||
(
|
||||
vi.mocked(fs.promises.readdir) as Mock<
|
||||
(path: fs.PathLike) => Promise<string[]>
|
||||
>
|
||||
).mockResolvedValue([
|
||||
'qwen-code-ide-server-12345-111.json',
|
||||
'qwen-code-ide-server-12345-222.json',
|
||||
]);
|
||||
vi.mocked(fs.promises.readFile)
|
||||
.mockResolvedValueOnce(JSON.stringify(invalidConfig))
|
||||
.mockResolvedValueOnce(JSON.stringify(validConfig));
|
||||
|
||||
const validateSpy = vi
|
||||
.spyOn(IdeClient, 'validateWorkspacePath')
|
||||
.mockReturnValueOnce({ isValid: false })
|
||||
.mockReturnValueOnce({ isValid: true });
|
||||
|
||||
const ideClient = await IdeClient.getInstance();
|
||||
const result = await (
|
||||
ideClient as unknown as {
|
||||
getConnectionConfigFromFile: () => Promise<unknown>;
|
||||
}
|
||||
).getConnectionConfigFromFile();
|
||||
|
||||
expect(result).toEqual(validConfig);
|
||||
expect(validateSpy).toHaveBeenCalledWith(
|
||||
'/invalid/workspace',
|
||||
'/test/workspace/sub-dir',
|
||||
);
|
||||
expect(validateSpy).toHaveBeenCalledWith(
|
||||
'/test/workspace',
|
||||
'/test/workspace/sub-dir',
|
||||
);
|
||||
});
|
||||
|
||||
it('should return the first valid config when multiple workspaces are valid', async () => {
|
||||
const config1 = { port: '1111', workspacePath: '/test/workspace' };
|
||||
const config2 = { port: '2222', workspacePath: '/test/workspace2' };
|
||||
vi.mocked(fs.promises.readFile).mockRejectedValueOnce(
|
||||
new Error('not found'),
|
||||
);
|
||||
(
|
||||
vi.mocked(fs.promises.readdir) as Mock<
|
||||
(path: fs.PathLike) => Promise<string[]>
|
||||
>
|
||||
).mockResolvedValue([
|
||||
'qwen-code-ide-server-12345-111.json',
|
||||
'qwen-code-ide-server-12345-222.json',
|
||||
]);
|
||||
vi.mocked(fs.promises.readFile)
|
||||
.mockResolvedValueOnce(JSON.stringify(config1))
|
||||
.mockResolvedValueOnce(JSON.stringify(config2));
|
||||
vi.spyOn(IdeClient, 'validateWorkspacePath').mockReturnValue({
|
||||
isValid: true,
|
||||
});
|
||||
|
||||
const ideClient = await IdeClient.getInstance();
|
||||
const result = await (
|
||||
ideClient as unknown as {
|
||||
getConnectionConfigFromFile: () => Promise<unknown>;
|
||||
}
|
||||
).getConnectionConfigFromFile();
|
||||
|
||||
expect(result).toEqual(config1);
|
||||
});
|
||||
|
||||
it('should prioritize the config matching the port from the environment variable', async () => {
|
||||
it('should fall back to legacy port file when pid file is missing', async () => {
|
||||
process.env['QWEN_CODE_IDE_SERVER_PORT'] = '2222';
|
||||
const config1 = { port: '1111', workspacePath: '/test/workspace' };
|
||||
const config2 = { port: '2222', workspacePath: '/test/workspace2' };
|
||||
vi.mocked(fs.promises.readFile).mockRejectedValueOnce(
|
||||
new Error('not found'),
|
||||
);
|
||||
(
|
||||
vi.mocked(fs.promises.readdir) as Mock<
|
||||
(path: fs.PathLike) => Promise<string[]>
|
||||
>
|
||||
).mockResolvedValue([
|
||||
'qwen-code-ide-server-12345-111.json',
|
||||
'qwen-code-ide-server-12345-222.json',
|
||||
]);
|
||||
vi.mocked(fs.promises.readFile)
|
||||
.mockResolvedValueOnce(JSON.stringify(config1))
|
||||
.mockRejectedValueOnce(new Error('not found')) // ~/.qwen/ide/<port>.lock
|
||||
.mockRejectedValueOnce(new Error('not found')) // legacy pid file
|
||||
.mockResolvedValueOnce(JSON.stringify(config2));
|
||||
vi.spyOn(IdeClient, 'validateWorkspacePath').mockReturnValue({
|
||||
isValid: true,
|
||||
});
|
||||
|
||||
const ideClient = await IdeClient.getInstance();
|
||||
const result = await (
|
||||
@@ -437,28 +330,23 @@ describe('IdeClient', () => {
|
||||
).getConnectionConfigFromFile();
|
||||
|
||||
expect(result).toEqual(config2);
|
||||
expect(fs.promises.readFile).toHaveBeenCalledWith(
|
||||
path.join('/tmp', 'qwen-code-ide-server-12345.json'),
|
||||
'utf8',
|
||||
);
|
||||
expect(fs.promises.readFile).toHaveBeenCalledWith(
|
||||
path.join('/tmp', 'qwen-code-ide-server-2222.json'),
|
||||
'utf8',
|
||||
);
|
||||
delete process.env['QWEN_CODE_IDE_SERVER_PORT'];
|
||||
});
|
||||
|
||||
it('should handle invalid JSON in one of the config files', async () => {
|
||||
const validConfig = { port: '2222', workspacePath: '/test/workspace' };
|
||||
vi.mocked(fs.promises.readFile).mockRejectedValueOnce(
|
||||
new Error('not found'),
|
||||
);
|
||||
(
|
||||
vi.mocked(fs.promises.readdir) as Mock<
|
||||
(path: fs.PathLike) => Promise<string[]>
|
||||
>
|
||||
).mockResolvedValue([
|
||||
'qwen-code-ide-server-12345-111.json',
|
||||
'qwen-code-ide-server-12345-222.json',
|
||||
]);
|
||||
it('should fall back to legacy config when env lock file has invalid JSON', async () => {
|
||||
process.env['QWEN_CODE_IDE_SERVER_PORT'] = '3333';
|
||||
const config = { port: '1111', workspacePath: '/test/workspace' };
|
||||
vi.mocked(fs.promises.readFile)
|
||||
.mockResolvedValueOnce('invalid json')
|
||||
.mockResolvedValueOnce(JSON.stringify(validConfig));
|
||||
vi.spyOn(IdeClient, 'validateWorkspacePath').mockReturnValue({
|
||||
isValid: true,
|
||||
});
|
||||
.mockResolvedValueOnce(JSON.stringify(config));
|
||||
|
||||
const ideClient = await IdeClient.getInstance();
|
||||
const result = await (
|
||||
@@ -467,96 +355,7 @@ describe('IdeClient', () => {
|
||||
}
|
||||
).getConnectionConfigFromFile();
|
||||
|
||||
expect(result).toEqual(validConfig);
|
||||
});
|
||||
|
||||
it('should return undefined if readdir throws an error', async () => {
|
||||
vi.mocked(fs.promises.readFile).mockRejectedValueOnce(
|
||||
new Error('not found'),
|
||||
);
|
||||
vi.mocked(fs.promises.readdir).mockRejectedValue(
|
||||
new Error('readdir failed'),
|
||||
);
|
||||
|
||||
const ideClient = await IdeClient.getInstance();
|
||||
const result = await (
|
||||
ideClient as unknown as {
|
||||
getConnectionConfigFromFile: () => Promise<unknown>;
|
||||
}
|
||||
).getConnectionConfigFromFile();
|
||||
|
||||
expect(result).toBeUndefined();
|
||||
});
|
||||
|
||||
it('should ignore files with invalid names', async () => {
|
||||
const validConfig = { port: '3333', workspacePath: '/test/workspace' };
|
||||
vi.mocked(fs.promises.readFile).mockRejectedValueOnce(
|
||||
new Error('not found'),
|
||||
);
|
||||
(
|
||||
vi.mocked(fs.promises.readdir) as Mock<
|
||||
(path: fs.PathLike) => Promise<string[]>
|
||||
>
|
||||
).mockResolvedValue([
|
||||
'qwen-code-ide-server-12345-111.json', // valid
|
||||
'not-a-config-file.txt', // invalid
|
||||
'qwen-code-ide-server-asdf.json', // invalid
|
||||
]);
|
||||
vi.mocked(fs.promises.readFile).mockResolvedValueOnce(
|
||||
JSON.stringify(validConfig),
|
||||
);
|
||||
vi.spyOn(IdeClient, 'validateWorkspacePath').mockReturnValue({
|
||||
isValid: true,
|
||||
});
|
||||
|
||||
const ideClient = await IdeClient.getInstance();
|
||||
const result = await (
|
||||
ideClient as unknown as {
|
||||
getConnectionConfigFromFile: () => Promise<unknown>;
|
||||
}
|
||||
).getConnectionConfigFromFile();
|
||||
|
||||
expect(result).toEqual(validConfig);
|
||||
expect(fs.promises.readFile).toHaveBeenCalledWith(
|
||||
path.join('/tmp/gemini/ide', 'qwen-code-ide-server-12345-111.json'),
|
||||
'utf8',
|
||||
);
|
||||
expect(fs.promises.readFile).not.toHaveBeenCalledWith(
|
||||
path.join('/tmp/gemini/ide', 'not-a-config-file.txt'),
|
||||
'utf8',
|
||||
);
|
||||
});
|
||||
|
||||
it('should match env port string to a number port in the config', async () => {
|
||||
process.env['QWEN_CODE_IDE_SERVER_PORT'] = '3333';
|
||||
const config1 = { port: 1111, workspacePath: '/test/workspace' };
|
||||
const config2 = { port: 3333, workspacePath: '/test/workspace2' };
|
||||
vi.mocked(fs.promises.readFile).mockRejectedValueOnce(
|
||||
new Error('not found'),
|
||||
);
|
||||
(
|
||||
vi.mocked(fs.promises.readdir) as Mock<
|
||||
(path: fs.PathLike) => Promise<string[]>
|
||||
>
|
||||
).mockResolvedValue([
|
||||
'qwen-code-ide-server-12345-111.json',
|
||||
'qwen-code-ide-server-12345-222.json',
|
||||
]);
|
||||
vi.mocked(fs.promises.readFile)
|
||||
.mockResolvedValueOnce(JSON.stringify(config1))
|
||||
.mockResolvedValueOnce(JSON.stringify(config2));
|
||||
vi.spyOn(IdeClient, 'validateWorkspacePath').mockReturnValue({
|
||||
isValid: true,
|
||||
});
|
||||
|
||||
const ideClient = await IdeClient.getInstance();
|
||||
const result = await (
|
||||
ideClient as unknown as {
|
||||
getConnectionConfigFromFile: () => Promise<unknown>;
|
||||
}
|
||||
).getConnectionConfigFromFile();
|
||||
|
||||
expect(result).toEqual(config2);
|
||||
expect(result).toEqual(config);
|
||||
delete process.env['QWEN_CODE_IDE_SERVER_PORT'];
|
||||
});
|
||||
});
|
||||
|
||||
@@ -8,6 +8,7 @@ import * as fs from 'node:fs';
|
||||
import { isSubpath } from '../utils/paths.js';
|
||||
import { detectIde, type IdeInfo } from '../ide/detect-ide.js';
|
||||
import { ideContextStore } from './ideContext.js';
|
||||
import { Storage } from '../config/storage.js';
|
||||
import {
|
||||
IdeContextNotificationSchema,
|
||||
IdeDiffAcceptedNotificationSchema,
|
||||
@@ -572,98 +573,103 @@ export class IdeClient {
|
||||
| (ConnectionConfig & { workspacePath?: string; ideInfo?: IdeInfo })
|
||||
| undefined
|
||||
> {
|
||||
if (!this.ideProcessInfo) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
// For backwards compatability
|
||||
try {
|
||||
const portFile = path.join(
|
||||
os.tmpdir(),
|
||||
`qwen-code-ide-server-${this.ideProcessInfo.pid}.json`,
|
||||
);
|
||||
const portFileContents = await fs.promises.readFile(portFile, 'utf8');
|
||||
return JSON.parse(portFileContents);
|
||||
} catch (_) {
|
||||
// For newer extension versions, the file name matches the pattern
|
||||
// /^qwen-code-ide-server-${pid}-\d+\.json$/. If multiple IDE
|
||||
// windows are open, multiple files matching the pattern are expected to
|
||||
// exist.
|
||||
}
|
||||
|
||||
const portFileDir = path.join(os.tmpdir(), 'gemini', 'ide');
|
||||
let portFiles;
|
||||
try {
|
||||
portFiles = await fs.promises.readdir(portFileDir);
|
||||
} catch (e) {
|
||||
logger.debug('Failed to read IDE connection directory:', e);
|
||||
return undefined;
|
||||
}
|
||||
|
||||
if (!portFiles) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const fileRegex = new RegExp(
|
||||
`^qwen-code-ide-server-${this.ideProcessInfo.pid}-\\d+\\.json$`,
|
||||
);
|
||||
const matchingFiles = portFiles
|
||||
.filter((file) => fileRegex.test(file))
|
||||
.sort();
|
||||
if (matchingFiles.length === 0) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
let fileContents: string[];
|
||||
try {
|
||||
fileContents = await Promise.all(
|
||||
matchingFiles.map((file) =>
|
||||
fs.promises.readFile(path.join(portFileDir, file), 'utf8'),
|
||||
),
|
||||
);
|
||||
} catch (e) {
|
||||
logger.debug('Failed to read IDE connection config file(s):', e);
|
||||
return undefined;
|
||||
}
|
||||
const parsedContents = fileContents.map((content) => {
|
||||
try {
|
||||
return JSON.parse(content);
|
||||
} catch (e) {
|
||||
logger.debug('Failed to parse JSON from config file: ', e);
|
||||
return undefined;
|
||||
}
|
||||
});
|
||||
|
||||
const validWorkspaces = parsedContents.filter((content) => {
|
||||
if (!content) {
|
||||
return false;
|
||||
}
|
||||
const { isValid } = IdeClient.validateWorkspacePath(
|
||||
content.workspacePath,
|
||||
process.cwd(),
|
||||
);
|
||||
return isValid;
|
||||
});
|
||||
|
||||
if (validWorkspaces.length === 0) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
if (validWorkspaces.length === 1) {
|
||||
return validWorkspaces[0];
|
||||
}
|
||||
|
||||
const portFromEnv = this.getPortFromEnv();
|
||||
if (portFromEnv) {
|
||||
const matchingPort = validWorkspaces.find(
|
||||
(content) => String(content.port) === portFromEnv,
|
||||
);
|
||||
if (matchingPort) {
|
||||
return matchingPort;
|
||||
try {
|
||||
const ideDir = Storage.getGlobalIdeDir();
|
||||
const lockFile = path.join(ideDir, `${portFromEnv}.lock`);
|
||||
const lockFileContents = await fs.promises.readFile(lockFile, 'utf8');
|
||||
return JSON.parse(lockFileContents);
|
||||
} catch (_) {
|
||||
// Fall through to legacy discovery.
|
||||
}
|
||||
}
|
||||
|
||||
return validWorkspaces[0];
|
||||
// Legacy discovery for VSCode extension < v0.5.1.
|
||||
return this.getLegacyConnectionConfig(portFromEnv);
|
||||
}
|
||||
|
||||
// Legacy connection files were written in the global temp directory.
|
||||
private async getLegacyConnectionConfig(
|
||||
portFromEnv?: string,
|
||||
): Promise<
|
||||
| (ConnectionConfig & { workspacePath?: string; ideInfo?: IdeInfo })
|
||||
| undefined
|
||||
> {
|
||||
if (this.ideProcessInfo) {
|
||||
try {
|
||||
const portFile = path.join(
|
||||
os.tmpdir(),
|
||||
`qwen-code-ide-server-${this.ideProcessInfo.pid}.json`,
|
||||
);
|
||||
const portFileContents = await fs.promises.readFile(portFile, 'utf8');
|
||||
return JSON.parse(portFileContents);
|
||||
} catch (_) {
|
||||
// For older/newer extension versions, the file name matches the pattern
|
||||
// /^qwen-code-ide-server-${pid}-\d+\.json$/. If multiple IDE
|
||||
// windows are open, multiple files matching the pattern are expected to
|
||||
// exist.
|
||||
}
|
||||
}
|
||||
|
||||
if (portFromEnv) {
|
||||
try {
|
||||
const portFile = path.join(
|
||||
os.tmpdir(),
|
||||
`qwen-code-ide-server-${portFromEnv}.json`,
|
||||
);
|
||||
const portFileContents = await fs.promises.readFile(portFile, 'utf8');
|
||||
return JSON.parse(portFileContents);
|
||||
} catch (_) {
|
||||
// Ignore and fall through.
|
||||
}
|
||||
}
|
||||
|
||||
return undefined;
|
||||
}
|
||||
|
||||
protected async getAllConnectionConfigs(
|
||||
ideDir: string,
|
||||
): Promise<
|
||||
ConnectionConfig & Array<{ workspacePath?: string; ideInfo?: IdeInfo }>
|
||||
> {
|
||||
const fileRegex = new RegExp('^\\d+\\.lock$');
|
||||
let lockFiles: string[];
|
||||
try {
|
||||
lockFiles = (await fs.promises.readdir(ideDir)).filter((file) =>
|
||||
fileRegex.test(file),
|
||||
);
|
||||
} catch (e) {
|
||||
logger.debug('Failed to read IDE connection directory:', e);
|
||||
return [];
|
||||
}
|
||||
|
||||
const fileContents = await Promise.all(
|
||||
lockFiles.map(async (file) => {
|
||||
const fullPath = path.join(ideDir, file);
|
||||
try {
|
||||
const stat = await fs.promises.stat(fullPath);
|
||||
const content = await fs.promises.readFile(fullPath, 'utf8');
|
||||
try {
|
||||
const parsed = JSON.parse(content);
|
||||
return { file, mtimeMs: stat.mtimeMs, parsed };
|
||||
} catch (e) {
|
||||
logger.debug('Failed to parse JSON from lock file: ', e);
|
||||
return { file, mtimeMs: stat.mtimeMs, parsed: undefined };
|
||||
}
|
||||
} catch (e) {
|
||||
// If we can't stat/read the file, treat it as very old so it doesn't
|
||||
// win ties, and skip parsing by returning undefined content.
|
||||
logger.debug('Failed to read/stat IDE lock file:', e);
|
||||
return { file, mtimeMs: -Infinity, parsed: undefined };
|
||||
}
|
||||
}),
|
||||
);
|
||||
|
||||
return fileContents
|
||||
.filter(({ parsed }) => parsed !== undefined)
|
||||
.sort((a, b) => b.mtimeMs - a.mtimeMs)
|
||||
.map(({ parsed }) => parsed);
|
||||
}
|
||||
|
||||
private createProxyAwareFetch() {
|
||||
|
||||
@@ -50,7 +50,7 @@ describe('getIdeProcessInfo', () => {
|
||||
expect(result).toEqual({ pid: 700, command: '/usr/lib/vscode/code' });
|
||||
});
|
||||
|
||||
it('should return parent process info if grandparent lookup fails', async () => {
|
||||
it('should return shell process info if grandparent lookup fails', async () => {
|
||||
(os.platform as Mock).mockReturnValue('linux');
|
||||
mockedExec
|
||||
.mockResolvedValueOnce({ stdout: '800 /bin/bash' }) // pid 1000 -> ppid 800 (shell)
|
||||
@@ -63,134 +63,96 @@ describe('getIdeProcessInfo', () => {
|
||||
});
|
||||
|
||||
describe('on Windows', () => {
|
||||
it('should traverse up and find the great-grandchild of the root process', async () => {
|
||||
it('should return great-grandparent process using heuristic', async () => {
|
||||
(os.platform as Mock).mockReturnValue('win32');
|
||||
const processInfoMap = new Map([
|
||||
[
|
||||
1000,
|
||||
{
|
||||
stdout:
|
||||
'{"Name":"node.exe","ParentProcessId":900,"CommandLine":"node.exe"}',
|
||||
},
|
||||
],
|
||||
[
|
||||
900,
|
||||
{
|
||||
stdout:
|
||||
'{"Name":"powershell.exe","ParentProcessId":800,"CommandLine":"powershell.exe"}',
|
||||
},
|
||||
],
|
||||
[
|
||||
800,
|
||||
{
|
||||
stdout:
|
||||
'{"Name":"code.exe","ParentProcessId":700,"CommandLine":"code.exe"}',
|
||||
},
|
||||
],
|
||||
[
|
||||
700,
|
||||
{
|
||||
stdout:
|
||||
'{"Name":"wininit.exe","ParentProcessId":0,"CommandLine":"wininit.exe"}',
|
||||
},
|
||||
],
|
||||
]);
|
||||
mockedExec.mockImplementation((command: string) => {
|
||||
const pidMatch = command.match(/ProcessId=(\d+)/);
|
||||
if (pidMatch) {
|
||||
const pid = parseInt(pidMatch[1], 10);
|
||||
return Promise.resolve(processInfoMap.get(pid));
|
||||
|
||||
const processes = [
|
||||
{
|
||||
ProcessId: 1000,
|
||||
ParentProcessId: 900,
|
||||
Name: 'node.exe',
|
||||
CommandLine: 'node.exe',
|
||||
},
|
||||
{
|
||||
ProcessId: 900,
|
||||
ParentProcessId: 800,
|
||||
Name: 'powershell.exe',
|
||||
CommandLine: 'powershell.exe',
|
||||
},
|
||||
{
|
||||
ProcessId: 800,
|
||||
ParentProcessId: 700,
|
||||
Name: 'code.exe',
|
||||
CommandLine: 'code.exe',
|
||||
},
|
||||
{
|
||||
ProcessId: 700,
|
||||
ParentProcessId: 0,
|
||||
Name: 'wininit.exe',
|
||||
CommandLine: 'wininit.exe',
|
||||
},
|
||||
];
|
||||
|
||||
mockedExec.mockImplementation((file: string, _args: string[]) => {
|
||||
if (file === 'powershell') {
|
||||
return Promise.resolve({ stdout: JSON.stringify(processes) });
|
||||
}
|
||||
return Promise.reject(new Error('Invalid command for mock'));
|
||||
return Promise.resolve({ stdout: '' });
|
||||
});
|
||||
|
||||
const result = await getIdeProcessInfo();
|
||||
// Process chain: 1000 (node.exe) -> 900 (powershell.exe) -> 800 (code.exe) -> 700 (wininit.exe)
|
||||
// ancestors = [1000, 900, 800, 700], length = 4
|
||||
// Heuristic: return ancestors[length-3] = ancestors[1] = 900 (powershell.exe)
|
||||
expect(result).toEqual({ pid: 900, command: 'powershell.exe' });
|
||||
});
|
||||
|
||||
it('should handle non-existent process gracefully', async () => {
|
||||
it('should handle empty process list gracefully', async () => {
|
||||
(os.platform as Mock).mockReturnValue('win32');
|
||||
mockedExec
|
||||
.mockResolvedValueOnce({ stdout: '' }) // Non-existent PID returns empty due to -ErrorAction SilentlyContinue
|
||||
.mockResolvedValueOnce({
|
||||
stdout:
|
||||
'{"Name":"fallback.exe","ParentProcessId":0,"CommandLine":"fallback.exe"}',
|
||||
}); // Fallback call
|
||||
mockedExec.mockResolvedValue({ stdout: '[]' });
|
||||
|
||||
const result = await getIdeProcessInfo();
|
||||
expect(result).toEqual({ pid: 1000, command: 'fallback.exe' });
|
||||
// Should return current pid and empty command because process not found in map
|
||||
expect(result).toEqual({ pid: 1000, command: '' });
|
||||
});
|
||||
|
||||
it('should handle malformed JSON output gracefully', async () => {
|
||||
(os.platform as Mock).mockReturnValue('win32');
|
||||
mockedExec
|
||||
.mockResolvedValueOnce({ stdout: '{"invalid":json}' }) // Malformed JSON
|
||||
.mockResolvedValueOnce({
|
||||
stdout:
|
||||
'{"Name":"fallback.exe","ParentProcessId":0,"CommandLine":"fallback.exe"}',
|
||||
}); // Fallback call
|
||||
mockedExec.mockResolvedValue({ stdout: '{"invalid":json}' });
|
||||
|
||||
const result = await getIdeProcessInfo();
|
||||
expect(result).toEqual({ pid: 1000, command: 'fallback.exe' });
|
||||
expect(result).toEqual({ pid: 1000, command: '' });
|
||||
});
|
||||
|
||||
it('should handle PowerShell errors without crashing the process chain', async () => {
|
||||
it('should return last ancestor if chain is too short', async () => {
|
||||
(os.platform as Mock).mockReturnValue('win32');
|
||||
const processInfoMap = new Map([
|
||||
[1000, { stdout: '' }], // First process doesn't exist (empty due to -ErrorAction)
|
||||
[
|
||||
1001,
|
||||
{
|
||||
stdout:
|
||||
'{"Name":"parent.exe","ParentProcessId":800,"CommandLine":"parent.exe"}',
|
||||
},
|
||||
],
|
||||
[
|
||||
800,
|
||||
{
|
||||
stdout:
|
||||
'{"Name":"ide.exe","ParentProcessId":0,"CommandLine":"ide.exe"}',
|
||||
},
|
||||
],
|
||||
]);
|
||||
|
||||
// Mock the process.pid to test traversal with missing processes
|
||||
Object.defineProperty(process, 'pid', {
|
||||
value: 1001,
|
||||
configurable: true,
|
||||
});
|
||||
const processes = [
|
||||
{
|
||||
ProcessId: 1000,
|
||||
ParentProcessId: 900,
|
||||
Name: 'node.exe',
|
||||
CommandLine: 'node.exe',
|
||||
},
|
||||
{
|
||||
ProcessId: 900,
|
||||
ParentProcessId: 0,
|
||||
Name: 'explorer.exe',
|
||||
CommandLine: 'explorer.exe',
|
||||
},
|
||||
];
|
||||
|
||||
mockedExec.mockImplementation((command: string) => {
|
||||
const pidMatch = command.match(/ProcessId=(\d+)/);
|
||||
if (pidMatch) {
|
||||
const pid = parseInt(pidMatch[1], 10);
|
||||
return Promise.resolve(processInfoMap.get(pid) || { stdout: '' });
|
||||
mockedExec.mockImplementation((file: string, _args: string[]) => {
|
||||
if (file === 'powershell') {
|
||||
return Promise.resolve({ stdout: JSON.stringify(processes) });
|
||||
}
|
||||
return Promise.reject(new Error('Invalid command for mock'));
|
||||
return Promise.resolve({ stdout: '' });
|
||||
});
|
||||
|
||||
const result = await getIdeProcessInfo();
|
||||
// Should return the current process command since traversal continues despite missing processes
|
||||
expect(result).toEqual({ pid: 1001, command: 'parent.exe' });
|
||||
|
||||
// Reset process.pid
|
||||
Object.defineProperty(process, 'pid', {
|
||||
value: 1000,
|
||||
configurable: true,
|
||||
});
|
||||
});
|
||||
|
||||
it('should handle partial JSON data with defaults', async () => {
|
||||
(os.platform as Mock).mockReturnValue('win32');
|
||||
mockedExec
|
||||
.mockResolvedValueOnce({ stdout: '{"Name":"partial.exe"}' }) // Missing ParentProcessId, defaults to 0
|
||||
.mockResolvedValueOnce({
|
||||
stdout:
|
||||
'{"Name":"root.exe","ParentProcessId":0,"CommandLine":"root.exe"}',
|
||||
}); // Get grandparent info
|
||||
|
||||
const result = await getIdeProcessInfo();
|
||||
expect(result).toEqual({ pid: 1000, command: 'root.exe' });
|
||||
// ancestors = [1000, 900], length = 2 (< 3)
|
||||
// Heuristic: return ancestors[length-1] = ancestors[1] = 900 (explorer.exe)
|
||||
expect(result).toEqual({ pid: 900, command: 'explorer.exe' });
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -4,74 +4,43 @@
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { exec } from 'node:child_process';
|
||||
import { exec, execFile } from 'node:child_process';
|
||||
import { promisify } from 'node:util';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
|
||||
const execAsync = promisify(exec);
|
||||
const execFileAsync = promisify(execFile);
|
||||
|
||||
const MAX_TRAVERSAL_DEPTH = 32;
|
||||
|
||||
/**
|
||||
* Fetches the parent process ID, name, and command for a given process ID.
|
||||
*
|
||||
* @param pid The process ID to inspect.
|
||||
* @returns A promise that resolves to the parent's PID, name, and command.
|
||||
*/
|
||||
async function getProcessInfo(pid: number): Promise<{
|
||||
parentPid: number;
|
||||
name: string;
|
||||
command: string;
|
||||
}> {
|
||||
// Only used for Unix systems (macOS and Linux)
|
||||
try {
|
||||
const platform = os.platform();
|
||||
if (platform === 'win32') {
|
||||
const powershellCommand = [
|
||||
'$p = Get-CimInstance Win32_Process',
|
||||
`-Filter 'ProcessId=${pid}'`,
|
||||
'-ErrorAction SilentlyContinue;',
|
||||
'if ($p) {',
|
||||
'@{Name=$p.Name;ParentProcessId=$p.ParentProcessId;CommandLine=$p.CommandLine}',
|
||||
'| ConvertTo-Json',
|
||||
'}',
|
||||
].join(' ');
|
||||
const { stdout } = await execAsync(`powershell "${powershellCommand}"`);
|
||||
const output = stdout.trim();
|
||||
if (!output) return { parentPid: 0, name: '', command: '' };
|
||||
const {
|
||||
Name = '',
|
||||
ParentProcessId = 0,
|
||||
CommandLine = '',
|
||||
} = JSON.parse(output);
|
||||
return {
|
||||
parentPid: ParentProcessId,
|
||||
name: Name,
|
||||
command: CommandLine ?? '',
|
||||
};
|
||||
} else {
|
||||
const command = `ps -o ppid=,command= -p ${pid}`;
|
||||
const { stdout } = await execAsync(command);
|
||||
const trimmedStdout = stdout.trim();
|
||||
if (!trimmedStdout) {
|
||||
return { parentPid: 0, name: '', command: '' };
|
||||
}
|
||||
const ppidString = trimmedStdout.split(/\s+/)[0];
|
||||
const parentPid = parseInt(ppidString, 10);
|
||||
const fullCommand = trimmedStdout.substring(ppidString.length).trim();
|
||||
const processName = path.basename(fullCommand.split(' ')[0]);
|
||||
return {
|
||||
parentPid: isNaN(parentPid) ? 1 : parentPid,
|
||||
name: processName,
|
||||
command: fullCommand,
|
||||
};
|
||||
const command = `ps -o ppid=,command= -p ${pid}`;
|
||||
const { stdout } = await execAsync(command);
|
||||
const trimmedStdout = stdout.trim();
|
||||
if (!trimmedStdout) {
|
||||
return { parentPid: 0, name: '', command: '' };
|
||||
}
|
||||
const parts = trimmedStdout.split(/\s+/);
|
||||
const ppidString = parts[0];
|
||||
const parentPid = parseInt(ppidString, 10);
|
||||
const fullCommand = trimmedStdout.substring(ppidString.length).trim();
|
||||
const processName = path.basename(fullCommand.split(' ')[0]);
|
||||
return {
|
||||
parentPid: isNaN(parentPid) ? 1 : parentPid,
|
||||
name: processName,
|
||||
command: fullCommand,
|
||||
};
|
||||
} catch (_e) {
|
||||
console.debug(`Failed to get process info for pid ${pid}:`, _e);
|
||||
return { parentPid: 0, name: '', command: '' };
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Finds the IDE process info on Unix-like systems.
|
||||
*
|
||||
@@ -106,15 +75,15 @@ async function getIdeProcessInfoForUnix(): Promise<{
|
||||
} catch {
|
||||
// Ignore if getting grandparent fails, we'll just use the parent pid.
|
||||
}
|
||||
const { command } = await getProcessInfo(idePid);
|
||||
return { pid: idePid, command };
|
||||
const { command: ideCommand } = await getProcessInfo(idePid);
|
||||
return { pid: idePid, command: ideCommand };
|
||||
}
|
||||
|
||||
if (parentPid <= 1) {
|
||||
break; // Reached the root
|
||||
}
|
||||
currentPid = parentPid;
|
||||
} catch {
|
||||
} catch (_e) {
|
||||
// Process in chain died
|
||||
break;
|
||||
}
|
||||
@@ -124,50 +93,104 @@ async function getIdeProcessInfoForUnix(): Promise<{
|
||||
return { pid: currentPid, command };
|
||||
}
|
||||
|
||||
interface ProcessInfo {
|
||||
pid: number;
|
||||
parentPid: number;
|
||||
name: string;
|
||||
command: string;
|
||||
}
|
||||
|
||||
interface RawProcessInfo {
|
||||
ProcessId?: number;
|
||||
ParentProcessId?: number;
|
||||
Name?: string;
|
||||
CommandLine?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Finds the IDE process info on Windows.
|
||||
*
|
||||
* The strategy is to find the great-grandchild of the root process.
|
||||
*
|
||||
* @returns A promise that resolves to the PID and command of the IDE process.
|
||||
* Fetches the entire process table on Windows.
|
||||
*/
|
||||
async function getProcessTableWindows(): Promise<Map<number, ProcessInfo>> {
|
||||
const processMap = new Map<number, ProcessInfo>();
|
||||
try {
|
||||
const powershellCommand =
|
||||
'Get-CimInstance Win32_Process | Select-Object ProcessId,ParentProcessId,Name,CommandLine | ConvertTo-Json -Compress';
|
||||
const { stdout } = await execFileAsync(
|
||||
'powershell',
|
||||
['-NoProfile', '-NonInteractive', '-Command', powershellCommand],
|
||||
{ maxBuffer: 10 * 1024 * 1024 },
|
||||
);
|
||||
|
||||
if (!stdout.trim()) {
|
||||
return processMap;
|
||||
}
|
||||
|
||||
let processes: RawProcessInfo | RawProcessInfo[];
|
||||
try {
|
||||
processes = JSON.parse(stdout);
|
||||
} catch (_e) {
|
||||
return processMap;
|
||||
}
|
||||
|
||||
if (!Array.isArray(processes)) {
|
||||
processes = [processes];
|
||||
}
|
||||
|
||||
for (const p of processes) {
|
||||
if (p && typeof p.ProcessId === 'number') {
|
||||
processMap.set(p.ProcessId, {
|
||||
pid: p.ProcessId,
|
||||
parentPid: p.ParentProcessId || 0,
|
||||
name: p.Name || '',
|
||||
command: p.CommandLine || '',
|
||||
});
|
||||
}
|
||||
}
|
||||
} catch (_e) {
|
||||
// Fallback or error handling if PowerShell fails
|
||||
}
|
||||
return processMap;
|
||||
}
|
||||
|
||||
async function getIdeProcessInfoForWindows(): Promise<{
|
||||
pid: number;
|
||||
command: string;
|
||||
}> {
|
||||
let currentPid = process.pid;
|
||||
let previousPid = process.pid;
|
||||
// Fetch the entire process table in one go.
|
||||
const processMap = await getProcessTableWindows();
|
||||
|
||||
for (let i = 0; i < MAX_TRAVERSAL_DEPTH; i++) {
|
||||
try {
|
||||
const { parentPid } = await getProcessInfo(currentPid);
|
||||
const myPid = process.pid;
|
||||
const myProc = processMap.get(myPid);
|
||||
|
||||
if (parentPid > 0) {
|
||||
try {
|
||||
const { parentPid: grandParentPid } = await getProcessInfo(parentPid);
|
||||
if (grandParentPid === 0) {
|
||||
// We've found the grandchild of the root (`currentPid`). The IDE
|
||||
// process is its child, which we've stored in `previousPid`.
|
||||
const { command } = await getProcessInfo(previousPid);
|
||||
return { pid: previousPid, command };
|
||||
}
|
||||
} catch {
|
||||
// getting grandparent failed, proceed
|
||||
}
|
||||
}
|
||||
if (!myProc) {
|
||||
// Fallback: return current process info if snapshot fails
|
||||
return { pid: myPid, command: '' };
|
||||
}
|
||||
|
||||
if (parentPid <= 0) {
|
||||
break; // Reached the root
|
||||
}
|
||||
previousPid = currentPid;
|
||||
currentPid = parentPid;
|
||||
} catch {
|
||||
// Process in chain died
|
||||
// Perform tree traversal in memory
|
||||
const ancestors: ProcessInfo[] = [];
|
||||
let curr: ProcessInfo | undefined = myProc;
|
||||
|
||||
for (let i = 0; i < MAX_TRAVERSAL_DEPTH && curr; i++) {
|
||||
ancestors.push(curr);
|
||||
|
||||
if (curr.parentPid === 0 || !processMap.has(curr.parentPid)) {
|
||||
// Parent process not in map, stop traversal
|
||||
break;
|
||||
}
|
||||
curr = processMap.get(curr.parentPid);
|
||||
}
|
||||
const { command } = await getProcessInfo(currentPid);
|
||||
return { pid: currentPid, command };
|
||||
|
||||
// Use heuristic: return the great-grandparent (ancestors[length-3])
|
||||
if (ancestors.length >= 3) {
|
||||
const target = ancestors[ancestors.length - 3];
|
||||
return { pid: target.pid, command: target.command };
|
||||
} else if (ancestors.length > 0) {
|
||||
const target = ancestors[ancestors.length - 1];
|
||||
return { pid: target.pid, command: target.command };
|
||||
}
|
||||
|
||||
return { pid: myPid, command: myProc.command };
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
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>;
|
||||
}
|
||||
24
packages/sdk-java/.editorconfig
Normal file
24
packages/sdk-java/.editorconfig
Normal file
@@ -0,0 +1,24 @@
|
||||
root = true
|
||||
|
||||
[*]
|
||||
charset = utf-8
|
||||
end_of_line = lf
|
||||
insert_final_newline = true
|
||||
trim_trailing_whitespace = true
|
||||
indent_style = space
|
||||
indent_size = 4
|
||||
tab_width = 4
|
||||
ij_continuation_indent_size = 8
|
||||
|
||||
[*.java]
|
||||
ij_java_doc_align_exception_comments = false
|
||||
ij_java_doc_align_param_comments = false
|
||||
|
||||
[*.{yaml, yml, sh, ps1}]
|
||||
indent_size = 2
|
||||
|
||||
[*.{md, mkd, markdown}]
|
||||
trim_trailing_whitespace = false
|
||||
|
||||
[{**/res/**.xml, **/AndroidManifest.xml}]
|
||||
ij_continuation_indent_size = 4
|
||||
13
packages/sdk-java/.gitignore
vendored
Normal file
13
packages/sdk-java/.gitignore
vendored
Normal file
@@ -0,0 +1,13 @@
|
||||
### IntelliJ IDEA ###
|
||||
.idea
|
||||
*.iws
|
||||
*.iml
|
||||
*.ipr
|
||||
|
||||
# Mac
|
||||
.DS_Store
|
||||
|
||||
# Maven
|
||||
log/
|
||||
target/
|
||||
|
||||
131
packages/sdk-java/checkstyle.xml
Normal file
131
packages/sdk-java/checkstyle.xml
Normal file
@@ -0,0 +1,131 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE module PUBLIC
|
||||
"-//Puppy Crawl//DTD Check Configuration 1.3//EN"
|
||||
"http://checkstyle.sourceforge.net/dtds/configuration_1_3.dtd">
|
||||
<module name="Checker">
|
||||
<module name="FileTabCharacter" />
|
||||
<module name="NewlineAtEndOfFile">
|
||||
<property name="lineSeparator" value="lf" />
|
||||
</module>
|
||||
<module name="RegexpMultiline">
|
||||
<property name="format" value="\r" />
|
||||
<property name="message" value="Line contains carriage return" />
|
||||
</module>
|
||||
<module name="RegexpMultiline">
|
||||
<property name="format" value=" \n" />
|
||||
<property name="message" value="Line has trailing whitespace" />
|
||||
</module>
|
||||
<module name="RegexpMultiline">
|
||||
<property name="format" value="\n\n\n" />
|
||||
<property name="message" value="Multiple consecutive blank lines" />
|
||||
</module>
|
||||
<module name="RegexpMultiline">
|
||||
<property name="format" value="\n\n\Z" />
|
||||
<property name="message" value="Blank line before end of file" />
|
||||
</module>
|
||||
|
||||
<module name="RegexpMultiline">
|
||||
<property name="format" value="\{\n\n" />
|
||||
<property name="message" value="Blank line after opening brace" />
|
||||
</module>
|
||||
<module name="RegexpMultiline">
|
||||
<property name="format" value="\n\n\s*\}" />
|
||||
<property name="message" value="Blank line before closing brace" />
|
||||
</module>
|
||||
<module name="RegexpMultiline">
|
||||
<property name="format" value="->\s*\{\s+\}" />
|
||||
<property name="message" value="Whitespace inside empty lambda body" />
|
||||
</module>
|
||||
|
||||
<module name="TreeWalker">
|
||||
<module name="SuppressWarningsHolder" />
|
||||
|
||||
<module name="EmptyBlock">
|
||||
<property name="option" value="text" />
|
||||
<property name="tokens" value="
|
||||
LITERAL_DO, LITERAL_ELSE, LITERAL_FINALLY, LITERAL_IF,
|
||||
LITERAL_FOR, LITERAL_TRY, LITERAL_WHILE, INSTANCE_INIT, STATIC_INIT" />
|
||||
</module>
|
||||
<module name="EmptyStatement" />
|
||||
<module name="EmptyForInitializerPad" />
|
||||
<module name="MethodParamPad">
|
||||
<property name="allowLineBreaks" value="true" />
|
||||
<property name="option" value="nospace" />
|
||||
</module>
|
||||
<module name="ParenPad" />
|
||||
<module name="TypecastParenPad" />
|
||||
<module name="NeedBraces" />
|
||||
<module name="LeftCurly">
|
||||
<property name="option" value="eol" />
|
||||
<property name="tokens" value="
|
||||
LITERAL_CATCH, LITERAL_DO, LITERAL_ELSE, LITERAL_FINALLY, LITERAL_FOR,
|
||||
LITERAL_IF, LITERAL_SWITCH, LITERAL_SYNCHRONIZED, LITERAL_TRY, LITERAL_WHILE" />
|
||||
</module>
|
||||
<module name="GenericWhitespace" />
|
||||
<module name="WhitespaceAfter" />
|
||||
<module name="NoWhitespaceAfter" />
|
||||
<module name="NoWhitespaceBefore" />
|
||||
<module name="SingleSpaceSeparator" />
|
||||
<module name="Indentation">
|
||||
<property name="throwsIndent" value="8" />
|
||||
<property name="lineWrappingIndentation" value="8" />
|
||||
</module>
|
||||
|
||||
<module name="UpperEll" />
|
||||
<module name="DefaultComesLast" />
|
||||
<module name="ArrayTypeStyle" />
|
||||
<module name="ModifierOrder" />
|
||||
<module name="OneStatementPerLine" />
|
||||
<module name="StringLiteralEquality" />
|
||||
<module name="MutableException" />
|
||||
<module name="EqualsHashCode" />
|
||||
<module name="ExplicitInitialization" />
|
||||
<module name="OneTopLevelClass" />
|
||||
|
||||
<module name="MemberName" />
|
||||
<module name="PackageName" />
|
||||
<module name="ClassTypeParameterName">
|
||||
<property name="format" value="^[A-Z][0-9]?$" />
|
||||
</module>
|
||||
<module name="MethodTypeParameterName">
|
||||
<property name="format" value="^[A-Z][0-9]?$" />
|
||||
</module>
|
||||
<module name="AnnotationUseStyle">
|
||||
<property name="trailingArrayComma" value="ignore" />
|
||||
</module>
|
||||
|
||||
<module name="RedundantImport" />
|
||||
<module name="UnusedImports" />
|
||||
<!-- <module name="ImportOrder">-->
|
||||
<!-- <property name="groups" value="*,javax,java" />-->
|
||||
<!-- <property name="separated" value="true" />-->
|
||||
<!-- <property name="option" value="bottom" />-->
|
||||
<!-- <property name="sortStaticImportsAlphabetically" value="true" />-->
|
||||
<!-- </module>-->
|
||||
|
||||
<module name="WhitespaceAround">
|
||||
<property name="allowEmptyConstructors" value="true" />
|
||||
<property name="allowEmptyMethods" value="true" />
|
||||
<property name="allowEmptyLambdas" value="true" />
|
||||
<property name="ignoreEnhancedForColon" value="false" />
|
||||
<property name="tokens" value="
|
||||
ASSIGN, BAND, BAND_ASSIGN, BOR, BOR_ASSIGN, BSR, BSR_ASSIGN,
|
||||
BXOR, BXOR_ASSIGN, COLON, DIV, DIV_ASSIGN, DO_WHILE, EQUAL, GE, GT, LAND,
|
||||
LAMBDA, LE, LITERAL_ASSERT, LITERAL_CATCH, LITERAL_DO, LITERAL_ELSE,
|
||||
LITERAL_FINALLY, LITERAL_FOR, LITERAL_IF, LITERAL_RETURN, LITERAL_SWITCH,
|
||||
LITERAL_SYNCHRONIZED, LITERAL_TRY, LITERAL_WHILE,
|
||||
LOR, LT, MINUS, MINUS_ASSIGN, MOD, MOD_ASSIGN, NOT_EQUAL,
|
||||
PLUS, PLUS_ASSIGN, QUESTION, SL, SLIST, SL_ASSIGN, SR, SR_ASSIGN,
|
||||
STAR, STAR_ASSIGN, TYPE_EXTENSION_AND" />
|
||||
</module>
|
||||
|
||||
<module name="WhitespaceAfter" />
|
||||
|
||||
<module name="NoWhitespaceAfter">
|
||||
<property name="tokens" value="DOT" />
|
||||
<property name="allowLineBreaks" value="false" />
|
||||
</module>
|
||||
|
||||
<module name="MissingOverride"/>
|
||||
</module>
|
||||
</module>
|
||||
105
packages/sdk-java/pom.xml
Normal file
105
packages/sdk-java/pom.xml
Normal file
@@ -0,0 +1,105 @@
|
||||
<project xmlns="http://maven.apache.org/POM/4.0.0"
|
||||
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
|
||||
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/maven-v4_0_0.xsd">
|
||||
<modelVersion>4.0.0</modelVersion>
|
||||
<groupId>com.alibaba</groupId>
|
||||
<artifactId>qwencode-sdk-java</artifactId>
|
||||
<packaging>jar</packaging>
|
||||
<version>0.0.1</version>
|
||||
<name>qwencode-sdk-java</name>
|
||||
<url>https://maven.apache.org</url>
|
||||
<licenses>
|
||||
<license>
|
||||
<name>Apache 2</name>
|
||||
<url>https://www.apache.org/licenses/LICENSE-2.0.txt</url>
|
||||
<distribution>repo</distribution>
|
||||
<comments>A business-friendly OSS license</comments>
|
||||
</license>
|
||||
</licenses>
|
||||
<scm>
|
||||
<url>https://github.com/QwenLM/qwen-code</url>
|
||||
<connection>scm:git:https://github.com/QwenLM/qwen-code.git</connection>
|
||||
</scm>
|
||||
<properties>
|
||||
<maven.compiler.target>1.8</maven.compiler.target>
|
||||
<maven.compiler.source>1.8</maven.compiler.source>
|
||||
<project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
|
||||
<checkstyle-maven-plugin.version>3.6.0</checkstyle-maven-plugin.version>
|
||||
<junit5.version>5.14.1</junit5.version>
|
||||
<logback-classic.version>1.3.16</logback-classic.version>
|
||||
</properties>
|
||||
|
||||
<dependencyManagement>
|
||||
<dependencies>
|
||||
<dependency>
|
||||
<groupId>org.junit</groupId>
|
||||
<artifactId>junit-bom</artifactId>
|
||||
<type>pom</type>
|
||||
<version>${junit5.version}</version>
|
||||
<scope>import</scope>
|
||||
</dependency>
|
||||
</dependencies>
|
||||
</dependencyManagement>
|
||||
<dependencies>
|
||||
<dependency>
|
||||
<groupId>ch.qos.logback</groupId>
|
||||
<artifactId>logback-classic</artifactId>
|
||||
<version>${logback-classic.version}</version>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>org.apache.commons</groupId>
|
||||
<artifactId>commons-lang3</artifactId>
|
||||
<version>3.20.0</version>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>org.junit.jupiter</groupId>
|
||||
<artifactId>junit-jupiter</artifactId>
|
||||
<scope>test</scope>
|
||||
</dependency>
|
||||
</dependencies>
|
||||
|
||||
<build>
|
||||
<plugins>
|
||||
<plugin>
|
||||
<groupId>org.apache.maven.plugins</groupId>
|
||||
<artifactId>maven-checkstyle-plugin</artifactId>
|
||||
<version>${checkstyle-maven-plugin.version}</version>
|
||||
<configuration>
|
||||
<configLocation>checkstyle.xml</configLocation>
|
||||
</configuration>
|
||||
<executions>
|
||||
<execution>
|
||||
<goals>
|
||||
<goal>check</goal>
|
||||
</goals>
|
||||
</execution>
|
||||
</executions>
|
||||
</plugin>
|
||||
</plugins>
|
||||
</build>
|
||||
|
||||
<organization>
|
||||
<name>Alibaba Group</name>
|
||||
<url>https://github.com/alibaba</url>
|
||||
</organization>
|
||||
<developers>
|
||||
<developer>
|
||||
<id>skyfire</id>
|
||||
<name>skyfire</name>
|
||||
<email>gengwei.gw(at)alibaba-inc.com</email>
|
||||
<roles>
|
||||
<role>Developer</role>
|
||||
<role>Designer</role>
|
||||
</roles>
|
||||
<timezone>+8</timezone>
|
||||
<url>https://github.com/gwinthis</url>
|
||||
</developer>
|
||||
</developers>
|
||||
|
||||
<distributionManagement>
|
||||
<snapshotRepository>
|
||||
<id>central</id>
|
||||
<url>https://central.sonatype.com/repository/maven-snapshots/</url>
|
||||
</snapshotRepository>
|
||||
</distributionManagement>
|
||||
</project>
|
||||
@@ -0,0 +1,27 @@
|
||||
package com.alibaba.qwen.code.cli.transport;
|
||||
|
||||
public enum PermissionMode {
|
||||
DEFAULT("default"),
|
||||
PLAN("plan"),
|
||||
AUTO_EDIT("auto-edit"),
|
||||
YOLO("yolo");
|
||||
|
||||
private final String value;
|
||||
|
||||
PermissionMode(String value) {
|
||||
this.value = value;
|
||||
}
|
||||
|
||||
public String getValue() {
|
||||
return value;
|
||||
}
|
||||
|
||||
public static PermissionMode fromValue(String value) {
|
||||
for (PermissionMode mode : PermissionMode.values()) {
|
||||
if (mode.value.equals(value)) {
|
||||
return mode;
|
||||
}
|
||||
}
|
||||
throw new IllegalArgumentException("Unknown permission mode: " + value);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,133 @@
|
||||
package com.alibaba.qwen.code.cli.transport;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
public class TransportOptions implements Cloneable {
|
||||
private String pathToQwenExecutable;
|
||||
private String cwd;
|
||||
private String model;
|
||||
private PermissionMode permissionMode;
|
||||
private Map<String, String> env;
|
||||
private Integer maxSessionTurns;
|
||||
private List<String> coreTools;
|
||||
private List<String> excludeTools;
|
||||
private List<String> allowedTools;
|
||||
private String authType;
|
||||
private Boolean includePartialMessages;
|
||||
private Long turnTimeoutMs;
|
||||
private Long messageTimeoutMs;
|
||||
|
||||
public String getPathToQwenExecutable() {
|
||||
return pathToQwenExecutable;
|
||||
}
|
||||
|
||||
public void setPathToQwenExecutable(String pathToQwenExecutable) {
|
||||
this.pathToQwenExecutable = pathToQwenExecutable;
|
||||
}
|
||||
|
||||
public String getCwd() {
|
||||
return cwd;
|
||||
}
|
||||
|
||||
public void setCwd(String cwd) {
|
||||
this.cwd = cwd;
|
||||
}
|
||||
|
||||
public String getModel() {
|
||||
return model;
|
||||
}
|
||||
|
||||
public void setModel(String model) {
|
||||
this.model = model;
|
||||
}
|
||||
|
||||
public PermissionMode getPermissionMode() {
|
||||
return permissionMode;
|
||||
}
|
||||
|
||||
public void setPermissionMode(PermissionMode permissionMode) {
|
||||
this.permissionMode = permissionMode;
|
||||
}
|
||||
|
||||
public Map<String, String> getEnv() {
|
||||
return env;
|
||||
}
|
||||
|
||||
public void setEnv(Map<String, String> env) {
|
||||
this.env = env;
|
||||
}
|
||||
|
||||
public Integer getMaxSessionTurns() {
|
||||
return maxSessionTurns;
|
||||
}
|
||||
|
||||
public void setMaxSessionTurns(Integer maxSessionTurns) {
|
||||
this.maxSessionTurns = maxSessionTurns;
|
||||
}
|
||||
|
||||
public List<String> getCoreTools() {
|
||||
return coreTools;
|
||||
}
|
||||
|
||||
public void setCoreTools(List<String> coreTools) {
|
||||
this.coreTools = coreTools;
|
||||
}
|
||||
|
||||
public List<String> getExcludeTools() {
|
||||
return excludeTools;
|
||||
}
|
||||
|
||||
public void setExcludeTools(List<String> excludeTools) {
|
||||
this.excludeTools = excludeTools;
|
||||
}
|
||||
|
||||
public List<String> getAllowedTools() {
|
||||
return allowedTools;
|
||||
}
|
||||
|
||||
public void setAllowedTools(List<String> allowedTools) {
|
||||
this.allowedTools = allowedTools;
|
||||
}
|
||||
|
||||
public String getAuthType() {
|
||||
return authType;
|
||||
}
|
||||
|
||||
public void setAuthType(String authType) {
|
||||
this.authType = authType;
|
||||
}
|
||||
|
||||
public Boolean getIncludePartialMessages() {
|
||||
return includePartialMessages;
|
||||
}
|
||||
|
||||
public void setIncludePartialMessages(Boolean includePartialMessages) {
|
||||
this.includePartialMessages = includePartialMessages;
|
||||
}
|
||||
|
||||
public Long getTurnTimeoutMs() {
|
||||
return turnTimeoutMs;
|
||||
}
|
||||
|
||||
public void setTurnTimeoutMs(Long turnTimeoutMs) {
|
||||
this.turnTimeoutMs = turnTimeoutMs;
|
||||
}
|
||||
|
||||
public Long getMessageTimeoutMs() {
|
||||
return messageTimeoutMs;
|
||||
}
|
||||
|
||||
public void setMessageTimeoutMs(Long messageTimeoutMs) {
|
||||
this.messageTimeoutMs = messageTimeoutMs;
|
||||
}
|
||||
|
||||
@Override
|
||||
public TransportOptions clone() {
|
||||
try {
|
||||
return (TransportOptions) super.clone();
|
||||
} catch (CloneNotSupportedException e) {
|
||||
throw new AssertionError();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,182 @@
|
||||
package com.alibaba.qwen.code.cli.transport.process;
|
||||
|
||||
import com.alibaba.qwen.code.cli.transport.TransportOptions;
|
||||
|
||||
import org.apache.commons.lang3.exception.ContextedRuntimeException;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
|
||||
import java.io.BufferedReader;
|
||||
import java.io.BufferedWriter;
|
||||
import java.io.File;
|
||||
import java.io.IOException;
|
||||
import java.io.InputStreamReader;
|
||||
import java.io.OutputStreamWriter;
|
||||
import java.lang.ProcessBuilder.Redirect;
|
||||
import java.util.concurrent.CompletableFuture;
|
||||
import java.util.concurrent.ExecutionException;
|
||||
import java.util.concurrent.TimeUnit;
|
||||
import java.util.concurrent.TimeoutException;
|
||||
import java.util.function.Function;
|
||||
|
||||
public class ProcessTransport {
|
||||
private static final Logger log = LoggerFactory.getLogger(ProcessTransport.class);
|
||||
TransportOptionsAdapter transportOptionsAdapter;
|
||||
|
||||
protected final Long turnTimeoutMs;
|
||||
protected final Long messageTimeoutMs;
|
||||
|
||||
protected Process process;
|
||||
protected BufferedWriter processInput;
|
||||
protected BufferedReader processOutput;
|
||||
protected BufferedReader processError;
|
||||
|
||||
public ProcessTransport(TransportOptions transportOptions) throws IOException {
|
||||
this.transportOptionsAdapter = new TransportOptionsAdapter(transportOptions);
|
||||
turnTimeoutMs = transportOptionsAdapter.getHandledTransportOptions().getTurnTimeoutMs();
|
||||
messageTimeoutMs = transportOptionsAdapter.getHandledTransportOptions().getMessageTimeoutMs();
|
||||
start();
|
||||
}
|
||||
|
||||
protected void start() throws IOException {
|
||||
String[] commandArgs = transportOptionsAdapter.buildCommandArgs();
|
||||
log.debug("trans to command args: {}", transportOptionsAdapter);
|
||||
|
||||
ProcessBuilder processBuilder = new ProcessBuilder(commandArgs)
|
||||
.redirectOutput(Redirect.PIPE)
|
||||
.redirectInput(Redirect.PIPE)
|
||||
.redirectError(Redirect.PIPE)
|
||||
.redirectErrorStream(false)
|
||||
.directory(new File(transportOptionsAdapter.getCwd()));
|
||||
|
||||
process = processBuilder.start();
|
||||
processInput = new BufferedWriter(new OutputStreamWriter(process.getOutputStream()));
|
||||
processOutput = new BufferedReader(new InputStreamReader(process.getInputStream()));
|
||||
processError = new BufferedReader(new InputStreamReader(process.getErrorStream()));
|
||||
startErrorReading();
|
||||
}
|
||||
|
||||
public void close() throws IOException {
|
||||
if (processInput != null) {
|
||||
processInput.close();
|
||||
}
|
||||
if (processOutput != null) {
|
||||
processOutput.close();
|
||||
}
|
||||
if (processError != null) {
|
||||
processError.close();
|
||||
}
|
||||
if (process != null) {
|
||||
process.destroy();
|
||||
}
|
||||
}
|
||||
|
||||
public String inputWaitForOneLine(String message) throws IOException, ExecutionException, InterruptedException, TimeoutException {
|
||||
return inputWaitForOneLine(message, turnTimeoutMs);
|
||||
}
|
||||
|
||||
private String inputWaitForOneLine(String message, long timeOutInMs)
|
||||
throws IOException, TimeoutException, InterruptedException, ExecutionException {
|
||||
inputNoWaitResponse(message);
|
||||
CompletableFuture<String> future = CompletableFuture.supplyAsync(() -> {
|
||||
try {
|
||||
return processOutput.readLine();
|
||||
} catch (IOException e) {
|
||||
throw new ContextedRuntimeException("read line error", e)
|
||||
.addContextValue("message", message);
|
||||
}
|
||||
});
|
||||
|
||||
try {
|
||||
String line = future.get(timeOutInMs, TimeUnit.MILLISECONDS);
|
||||
log.info("inputWaitForOneLine result: {}", line);
|
||||
return line;
|
||||
} catch (TimeoutException e) {
|
||||
future.cancel(true);
|
||||
log.warn("read message timeout {}, canceled readOneLine task", timeOutInMs, e);
|
||||
throw e;
|
||||
} catch (InterruptedException e) {
|
||||
future.cancel(true);
|
||||
log.warn("interrupted task, canceled task", e);
|
||||
throw e;
|
||||
} catch (ExecutionException e) {
|
||||
future.cancel(true);
|
||||
log.warn("the readOneLine task execute error", e);
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
|
||||
public void inputWaitForMultiLine(String message, Function<String, Boolean> callBackFunction) throws IOException {
|
||||
inputWaitForMultiLine(message, callBackFunction, turnTimeoutMs);
|
||||
}
|
||||
|
||||
private void inputWaitForMultiLine(String message, Function<String, Boolean> callBackFunction, long timeOutInMs) throws IOException {
|
||||
log.debug("input message for multiLine: {}", message);
|
||||
inputNoWaitResponse(message);
|
||||
|
||||
CompletableFuture<Void> future = CompletableFuture.runAsync(() -> iterateOutput(callBackFunction));
|
||||
try {
|
||||
future.get(timeOutInMs, TimeUnit.MILLISECONDS);
|
||||
} catch (TimeoutException e) {
|
||||
future.cancel(true);
|
||||
log.warn("read message timeout {}, canceled readMultiMessages task", timeOutInMs, e);
|
||||
} catch (InterruptedException e) {
|
||||
future.cancel(true);
|
||||
log.warn("interrupted task, canceled task", e);
|
||||
} catch (ExecutionException e) {
|
||||
future.cancel(true);
|
||||
log.warn("the readMultiMessages task execute error", e);
|
||||
} catch (Exception e) {
|
||||
future.cancel(true);
|
||||
log.warn("other error");
|
||||
}
|
||||
}
|
||||
|
||||
public void inputNoWaitResponse(String message) throws IOException {
|
||||
log.debug("input message to agent: {}", message);
|
||||
processInput.write(message);
|
||||
processInput.newLine();
|
||||
processInput.flush();
|
||||
}
|
||||
|
||||
private void startErrorReading() {
|
||||
CompletableFuture.runAsync(() -> {
|
||||
try {
|
||||
String line;
|
||||
while ((line = processError.readLine()) != null) {
|
||||
System.err.println("错误: " + line);
|
||||
}
|
||||
} catch (Exception e) {
|
||||
System.err.println("错误: " + e.getMessage());
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private void iterateOutput(Function<String, Boolean> callBackFunction) {
|
||||
CompletableFuture<Void> future = CompletableFuture.runAsync(() -> {
|
||||
try {
|
||||
for (String line = processOutput.readLine(); line != null; line = processOutput.readLine()) {
|
||||
log.debug("read a message from agent {}", line);
|
||||
if (callBackFunction.apply(line)) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
} catch (IOException e) {
|
||||
throw new RuntimeException("read process output error", e);
|
||||
}
|
||||
});
|
||||
|
||||
try {
|
||||
future.get(messageTimeoutMs, TimeUnit.MILLISECONDS);
|
||||
} catch (InterruptedException e) {
|
||||
log.warn("read message task interrupted", e);
|
||||
future.cancel(true);
|
||||
} catch (TimeoutException e) {
|
||||
log.warn("Operation timed out", e);
|
||||
future.cancel(true);
|
||||
} catch (Exception e) {
|
||||
future.cancel(true);
|
||||
log.warn("Operation error", e);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,102 @@
|
||||
package com.alibaba.qwen.code.cli.transport.process;
|
||||
|
||||
import com.alibaba.qwen.code.cli.transport.TransportOptions;
|
||||
|
||||
import org.apache.commons.lang3.StringUtils;
|
||||
|
||||
import java.io.File;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Arrays;
|
||||
import java.util.HashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Optional;
|
||||
|
||||
class TransportOptionsAdapter {
|
||||
TransportOptions transportOptions;
|
||||
private static final Long DEFAULT_TURN_TIMEOUT_MS = 1000 * 60 * 30L;
|
||||
private static final Long DEFAULT_MESSAGE_TIMEOUT_MS = 1000 * 60 * 3L;
|
||||
|
||||
TransportOptionsAdapter(TransportOptions userTransportOptions) {
|
||||
transportOptions = addDefaultTransportOptions(userTransportOptions);
|
||||
}
|
||||
|
||||
TransportOptions getHandledTransportOptions() {
|
||||
return transportOptions;
|
||||
}
|
||||
|
||||
String getCwd() {
|
||||
return transportOptions.getCwd();
|
||||
}
|
||||
|
||||
String[] buildCommandArgs() {
|
||||
List<String> args = new ArrayList<>(
|
||||
Arrays.asList(transportOptions.getPathToQwenExecutable(), "--input-format", "stream-json", "--output-format",
|
||||
"stream-json", "--channel=SDK"));
|
||||
|
||||
if (StringUtils.isNotBlank(transportOptions.getModel())) {
|
||||
args.add("--model");
|
||||
args.add(transportOptions.getModel());
|
||||
}
|
||||
|
||||
if (transportOptions.getPermissionMode() != null) {
|
||||
args.add("--permission-mode");
|
||||
args.add(transportOptions.getPermissionMode().getValue());
|
||||
}
|
||||
|
||||
if (transportOptions.getMaxSessionTurns() != null) {
|
||||
args.add("--max-session-turns");
|
||||
args.add(transportOptions.getMaxSessionTurns().toString());
|
||||
}
|
||||
|
||||
if (transportOptions.getCoreTools() != null && !transportOptions.getCoreTools().isEmpty()) {
|
||||
args.add("--core-tools");
|
||||
args.add(String.join(",", transportOptions.getCoreTools()));
|
||||
}
|
||||
|
||||
if (transportOptions.getExcludeTools() != null && !transportOptions.getExcludeTools().isEmpty()) {
|
||||
args.add("--exclude-tools");
|
||||
args.add(String.join(",", transportOptions.getExcludeTools()));
|
||||
}
|
||||
|
||||
if (transportOptions.getAllowedTools() != null && !transportOptions.getAllowedTools().isEmpty()) {
|
||||
args.add("--allowed-tools");
|
||||
args.add(String.join(",", transportOptions.getAllowedTools()));
|
||||
}
|
||||
|
||||
if (StringUtils.isNotBlank(transportOptions.getAuthType())) {
|
||||
args.add("--auth-type");
|
||||
args.add(transportOptions.getAuthType());
|
||||
}
|
||||
|
||||
if (transportOptions.getIncludePartialMessages() != null && transportOptions.getIncludePartialMessages()) {
|
||||
args.add("--include-partial-messages");
|
||||
}
|
||||
return args.toArray(new String[] {});
|
||||
}
|
||||
|
||||
private TransportOptions addDefaultTransportOptions(TransportOptions userTransportOptions) {
|
||||
TransportOptions transportOptions = userTransportOptions.clone();
|
||||
|
||||
if (StringUtils.isBlank(transportOptions.getPathToQwenExecutable())) {
|
||||
transportOptions.setPathToQwenExecutable("qwen");
|
||||
}
|
||||
|
||||
if (StringUtils.isBlank(transportOptions.getCwd())) {
|
||||
transportOptions.setCwd(new File("").getAbsolutePath());
|
||||
}
|
||||
|
||||
Map<String, String> env = new HashMap<>(System.getenv());
|
||||
Optional.ofNullable(transportOptions.getEnv()).ifPresent(env::putAll);
|
||||
transportOptions.setEnv(env);
|
||||
|
||||
if (transportOptions.getTurnTimeoutMs() == null) {
|
||||
transportOptions.setTurnTimeoutMs(DEFAULT_TURN_TIMEOUT_MS);
|
||||
}
|
||||
|
||||
if (transportOptions.getMessageTimeoutMs() == null) {
|
||||
transportOptions.setMessageTimeoutMs(DEFAULT_MESSAGE_TIMEOUT_MS);
|
||||
}
|
||||
return transportOptions;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
package com.alibaba.qwen.code.cli.transport;
|
||||
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
|
||||
public class PermissionModeTest {
|
||||
|
||||
@Test
|
||||
public void shouldBeReturnQwenPermissionModeValue() {
|
||||
assertEquals("default", PermissionMode.DEFAULT.getValue());
|
||||
assertEquals("plan", PermissionMode.PLAN.getValue());
|
||||
assertEquals("auto-edit", PermissionMode.AUTO_EDIT.getValue());
|
||||
assertEquals("yolo", PermissionMode.YOLO.getValue());
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
package com.alibaba.qwen.code.cli.transport.process;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.util.concurrent.ExecutionException;
|
||||
import java.util.concurrent.TimeoutException;
|
||||
|
||||
import com.alibaba.qwen.code.cli.transport.TransportOptions;
|
||||
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
class ProcessTransportTest {
|
||||
|
||||
@Test
|
||||
void shouldStartAndCloseSuccessfully() throws IOException {
|
||||
TransportOptions transportOptions = new TransportOptions();
|
||||
ProcessTransport processTransport = new ProcessTransport(transportOptions);
|
||||
processTransport.close();
|
||||
}
|
||||
|
||||
@Test
|
||||
void shouldInputWaitForOneLineSuccessfully() throws IOException, ExecutionException, InterruptedException, TimeoutException {
|
||||
TransportOptions transportOptions = new TransportOptions();
|
||||
ProcessTransport processTransport = new ProcessTransport(transportOptions);
|
||||
|
||||
String message = "{\"type\": \"control_request\", \"request_id\": \"1\", \"request\": {\"subtype\": \"initialize\"} }";
|
||||
System.out.println(processTransport.inputWaitForOneLine(message));
|
||||
}
|
||||
|
||||
}
|
||||
@@ -13,9 +13,8 @@ npm install @qwen-code/sdk
|
||||
## Requirements
|
||||
|
||||
- Node.js >= 20.0.0
|
||||
- [Qwen Code](https://github.com/QwenLM/qwen-code) >= 0.4.0 (stable) installed and accessible in PATH
|
||||
|
||||
> **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
|
||||
|
||||
@@ -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
|
||||
|
||||
Apache-2.0 - see [LICENSE](./LICENSE) for details.
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@qwen-code/sdk",
|
||||
"version": "0.5.1",
|
||||
"version": "0.6.0",
|
||||
"description": "TypeScript SDK for programmatic access to qwen-code CLI",
|
||||
"main": "./dist/index.cjs",
|
||||
"module": "./dist/index.mjs",
|
||||
@@ -45,7 +45,8 @@
|
||||
"node": ">=18.0.0"
|
||||
},
|
||||
"dependencies": {
|
||||
"@modelcontextprotocol/sdk": "^1.0.4"
|
||||
"@modelcontextprotocol/sdk": "^1.0.4",
|
||||
"tiktoken": "^1.0.21"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^20.14.0",
|
||||
|
||||
@@ -91,3 +91,35 @@ if (existsSync(licenseSource)) {
|
||||
console.warn('Could not copy LICENSE:', error.message);
|
||||
}
|
||||
}
|
||||
|
||||
console.log('Bundling CLI into SDK package...');
|
||||
const repoRoot = join(rootDir, '..', '..');
|
||||
const rootDistDir = join(repoRoot, 'dist');
|
||||
|
||||
if (!existsSync(rootDistDir) || !existsSync(join(rootDistDir, 'cli.js'))) {
|
||||
console.log('Building CLI bundle...');
|
||||
try {
|
||||
execSync('npm run bundle', { stdio: 'inherit', cwd: repoRoot });
|
||||
} catch (error) {
|
||||
console.error('Failed to build CLI bundle:', error.message);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
const cliDistDir = join(rootDir, 'dist', 'cli');
|
||||
mkdirSync(cliDistDir, { recursive: true });
|
||||
|
||||
console.log('Copying CLI bundle...');
|
||||
cpSync(join(rootDistDir, 'cli.js'), join(cliDistDir, 'cli.js'));
|
||||
|
||||
const vendorSource = join(rootDistDir, 'vendor');
|
||||
if (existsSync(vendorSource)) {
|
||||
cpSync(vendorSource, join(cliDistDir, 'vendor'), { recursive: true });
|
||||
}
|
||||
|
||||
const localesSource = join(rootDistDir, 'locales');
|
||||
if (existsSync(localesSource)) {
|
||||
cpSync(localesSource, join(cliDistDir, 'locales'), { recursive: true });
|
||||
}
|
||||
|
||||
console.log('CLI bundle copied successfully to SDK package');
|
||||
|
||||
@@ -2,24 +2,16 @@
|
||||
* CLI path auto-detection and subprocess spawning utilities
|
||||
*
|
||||
* Supports multiple execution modes:
|
||||
* 1. Native binary: 'qwen' (production)
|
||||
* 2. Node.js bundle: 'node /path/to/cli.js' (production validation)
|
||||
* 1. Bundled CLI: Node.js bundle included in the SDK package (default)
|
||||
* 2. Node.js bundle: 'node /path/to/cli.js' (custom path)
|
||||
* 3. Bun bundle: 'bun /path/to/cli.js' (alternative runtime)
|
||||
* 4. TypeScript source: 'tsx /path/to/index.ts' (development)
|
||||
*
|
||||
* Auto-detection locations for native binary:
|
||||
* 1. QWEN_CODE_CLI_PATH environment variable
|
||||
* 2. ~/.volta/bin/qwen
|
||||
* 3. ~/.npm-global/bin/qwen
|
||||
* 4. /usr/local/bin/qwen
|
||||
* 5. ~/.local/bin/qwen
|
||||
* 6. ~/node_modules/.bin/qwen
|
||||
* 7. ~/.yarn/bin/qwen
|
||||
*/
|
||||
|
||||
import * as fs from 'node:fs';
|
||||
import * as path from 'node:path';
|
||||
import { execSync } from 'node:child_process';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
/**
|
||||
* Executable types supported by the SDK
|
||||
@@ -40,49 +32,38 @@ export type SpawnInfo = {
|
||||
originalInput: string;
|
||||
};
|
||||
|
||||
export function findNativeCliPath(): string {
|
||||
const homeDir = process.env['HOME'] || process.env['USERPROFILE'] || '';
|
||||
function getBundledCliPath(): string | null {
|
||||
try {
|
||||
const currentFile =
|
||||
typeof __filename !== 'undefined'
|
||||
? __filename
|
||||
: fileURLToPath(import.meta.url);
|
||||
|
||||
const candidates: Array<string | undefined> = [
|
||||
// 1. Environment variable (highest priority)
|
||||
process.env['QWEN_CODE_CLI_PATH'],
|
||||
const currentDir = path.dirname(currentFile);
|
||||
|
||||
// 2. Volta bin
|
||||
path.join(homeDir, '.volta', 'bin', 'qwen'),
|
||||
const bundledCliPath = path.join(currentDir, 'cli', 'cli.js');
|
||||
|
||||
// 3. Global npm installations
|
||||
path.join(homeDir, '.npm-global', 'bin', 'qwen'),
|
||||
|
||||
// 4. Common Unix binary locations
|
||||
'/usr/local/bin/qwen',
|
||||
|
||||
// 5. User local bin
|
||||
path.join(homeDir, '.local', 'bin', 'qwen'),
|
||||
|
||||
// 6. Node modules bin in home directory
|
||||
path.join(homeDir, 'node_modules', '.bin', 'qwen'),
|
||||
|
||||
// 7. Yarn global bin
|
||||
path.join(homeDir, '.yarn', 'bin', 'qwen'),
|
||||
];
|
||||
|
||||
// Find first existing candidate
|
||||
for (const candidate of candidates) {
|
||||
if (candidate && fs.existsSync(candidate)) {
|
||||
return path.resolve(candidate);
|
||||
if (fs.existsSync(bundledCliPath)) {
|
||||
return bundledCliPath;
|
||||
}
|
||||
|
||||
return null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export function findNativeCliPath(): string {
|
||||
const bundledCli = getBundledCliPath();
|
||||
if (bundledCli) {
|
||||
return bundledCli;
|
||||
}
|
||||
|
||||
// Not found - throw helpful error
|
||||
throw new Error(
|
||||
'qwen CLI not found. Please:\n' +
|
||||
' 1. Install qwen globally: npm install -g qwen\n' +
|
||||
' 2. Or provide explicit executable: query({ pathToQwenExecutable: "/path/to/qwen" })\n' +
|
||||
' 3. Or set environment variable: QWEN_CODE_CLI_PATH="/path/to/qwen"\n' +
|
||||
'\n' +
|
||||
'For development/testing, you can also use:\n' +
|
||||
'Bundled qwen CLI not found. The CLI should be included in the SDK package.\n' +
|
||||
'If you need to use a custom CLI, provide explicit executable:\n' +
|
||||
' • query({ pathToQwenExecutable: "/path/to/cli.js" })\n' +
|
||||
' • 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" })',
|
||||
);
|
||||
}
|
||||
|
||||
@@ -38,6 +38,8 @@ describe('CLI Path Utilities', () => {
|
||||
mockFs.statSync.mockReturnValue({
|
||||
isFile: () => true,
|
||||
} as ReturnType<typeof import('fs').statSync>);
|
||||
// Default: return true for existsSync (can be overridden in specific tests)
|
||||
mockFs.existsSync.mockReturnValue(true);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
@@ -50,28 +52,26 @@ describe('CLI Path Utilities', () => {
|
||||
|
||||
describe('parseExecutableSpec', () => {
|
||||
describe('auto-detection (no spec provided)', () => {
|
||||
it('should auto-detect native CLI when no spec provided', () => {
|
||||
// Mock environment variable
|
||||
const originalEnv = process.env['QWEN_CODE_CLI_PATH'];
|
||||
process.env['QWEN_CODE_CLI_PATH'] = '/usr/local/bin/qwen';
|
||||
mockFs.existsSync.mockReturnValue(true);
|
||||
it('should auto-detect bundled CLI when no spec provided', () => {
|
||||
// Mock existsSync to return true for bundled CLI
|
||||
mockFs.existsSync.mockImplementation((p) => {
|
||||
const pathStr = p.toString();
|
||||
return (
|
||||
pathStr.includes('cli/cli.js') || pathStr.includes('cli\\cli.js')
|
||||
);
|
||||
});
|
||||
|
||||
const result = parseExecutableSpec();
|
||||
|
||||
expect(result).toEqual({
|
||||
executablePath: path.resolve('/usr/local/bin/qwen'),
|
||||
isExplicitRuntime: false,
|
||||
});
|
||||
|
||||
// Restore env
|
||||
process.env['QWEN_CODE_CLI_PATH'] = originalEnv;
|
||||
expect(result.executablePath).toContain('cli.js');
|
||||
expect(result.isExplicitRuntime).toBe(false);
|
||||
});
|
||||
|
||||
it('should throw when auto-detection fails', () => {
|
||||
it('should throw when bundled CLI not found', () => {
|
||||
mockFs.existsSync.mockReturnValue(false);
|
||||
|
||||
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', () => {
|
||||
it('should auto-detect when no spec provided', () => {
|
||||
// Mock environment variable
|
||||
const originalEnv = process.env['QWEN_CODE_CLI_PATH'];
|
||||
process.env['QWEN_CODE_CLI_PATH'] = '/usr/local/bin/qwen';
|
||||
it('should auto-detect bundled CLI when no spec provided', () => {
|
||||
// Mock existsSync to return true for bundled CLI
|
||||
mockFs.existsSync.mockImplementation((p) => {
|
||||
const pathStr = p.toString();
|
||||
return (
|
||||
pathStr.includes('cli/cli.js') || pathStr.includes('cli\\cli.js')
|
||||
);
|
||||
});
|
||||
|
||||
const result = prepareSpawnInfo();
|
||||
|
||||
expect(result).toEqual({
|
||||
command: path.resolve('/usr/local/bin/qwen'),
|
||||
args: [],
|
||||
type: 'native',
|
||||
originalInput: '',
|
||||
});
|
||||
|
||||
// Restore env
|
||||
process.env['QWEN_CODE_CLI_PATH'] = originalEnv;
|
||||
expect(result.command).toBe(process.execPath);
|
||||
expect(result.args[0]).toContain('cli.js');
|
||||
expect(result.type).toBe('node');
|
||||
expect(result.originalInput).toBe('');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('findNativeCliPath', () => {
|
||||
it('should find CLI from environment variable', () => {
|
||||
const originalEnv = process.env['QWEN_CODE_CLI_PATH'];
|
||||
process.env['QWEN_CODE_CLI_PATH'] = '/custom/path/to/qwen';
|
||||
mockFs.existsSync.mockReturnValue(true);
|
||||
|
||||
const result = findNativeCliPath();
|
||||
|
||||
expect(result).toBe(path.resolve('/custom/path/to/qwen'));
|
||||
|
||||
process.env['QWEN_CODE_CLI_PATH'] = originalEnv;
|
||||
});
|
||||
|
||||
it('should search common installation locations', () => {
|
||||
const originalEnv = process.env['QWEN_CODE_CLI_PATH'];
|
||||
delete process.env['QWEN_CODE_CLI_PATH'];
|
||||
|
||||
// Mock fs.existsSync to return true for volta bin
|
||||
// Use path.join to match platform-specific path separators
|
||||
const voltaBinPath = path.join('.volta', 'bin', 'qwen');
|
||||
it('should find bundled CLI', () => {
|
||||
// Mock existsSync to return true for bundled CLI
|
||||
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();
|
||||
|
||||
expect(result).toContain(voltaBinPath);
|
||||
|
||||
process.env['QWEN_CODE_CLI_PATH'] = originalEnv;
|
||||
expect(result).toContain('cli.js');
|
||||
});
|
||||
|
||||
it('should throw descriptive error when CLI not found', () => {
|
||||
const originalEnv = process.env['QWEN_CODE_CLI_PATH'];
|
||||
delete process.env['QWEN_CODE_CLI_PATH'];
|
||||
it('should throw descriptive error when bundled CLI not found', () => {
|
||||
mockFs.existsSync.mockReturnValue(false);
|
||||
|
||||
expect(() => findNativeCliPath()).toThrow('qwen CLI not found. Please:');
|
||||
|
||||
process.env['QWEN_CODE_CLI_PATH'] = originalEnv;
|
||||
expect(() => findNativeCliPath()).toThrow('Bundled qwen CLI not found');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -634,13 +613,10 @@ describe('CLI Path Utilities', () => {
|
||||
mockFs.existsSync.mockReturnValue(false);
|
||||
|
||||
expect(() => parseExecutableSpec('/missing/file')).toThrow(
|
||||
'Set QWEN_CODE_CLI_PATH environment variable',
|
||||
'Executable file not found at',
|
||||
);
|
||||
expect(() => parseExecutableSpec('/missing/file')).toThrow(
|
||||
'Install qwen globally: npm install -g qwen',
|
||||
);
|
||||
expect(() => parseExecutableSpec('/missing/file')).toThrow(
|
||||
'Force specific runtime: bun:/path/to/cli.js or tsx:/path/to/index.ts',
|
||||
'Please check the file path and ensure the file exists',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@qwen-code/qwen-code-test-utils",
|
||||
"version": "0.5.1",
|
||||
"version": "0.6.0",
|
||||
"private": true,
|
||||
"main": "src/index.ts",
|
||||
"license": "Apache-2.0",
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
"name": "qwen-code-vscode-ide-companion",
|
||||
"displayName": "Qwen Code Companion",
|
||||
"description": "Enable Qwen Code with direct access to your VS Code workspace.",
|
||||
"version": "0.5.1",
|
||||
"version": "0.6.0",
|
||||
"publisher": "qwenlm",
|
||||
"icon": "assets/icon.png",
|
||||
"repository": {
|
||||
@@ -11,7 +11,7 @@
|
||||
"directory": "packages/vscode-ide-companion"
|
||||
},
|
||||
"engines": {
|
||||
"vscode": "^1.99.0"
|
||||
"vscode": "^1.85.0"
|
||||
},
|
||||
"license": "LICENSE",
|
||||
"preview": true,
|
||||
@@ -137,7 +137,7 @@
|
||||
"@types/react": "^19.1.8",
|
||||
"@types/react-dom": "^19.1.6",
|
||||
"@types/semver": "^7.7.1",
|
||||
"@types/vscode": "^1.99.0",
|
||||
"@types/vscode": "^1.85.0",
|
||||
"@typescript-eslint/eslint-plugin": "^8.31.1",
|
||||
"@typescript-eslint/parser": "^8.31.1",
|
||||
"@vscode/vsce": "^3.6.0",
|
||||
|
||||
@@ -35,7 +35,7 @@ function npmBin() {
|
||||
function run(cmd, args, opts = {}) {
|
||||
const res = spawnSync(cmd, args, {
|
||||
stdio: 'inherit',
|
||||
shell: process.platform === 'win32' ? true : false,
|
||||
shell: process.platform === 'win32',
|
||||
...opts,
|
||||
});
|
||||
if (res.error) {
|
||||
|
||||
@@ -27,13 +27,14 @@ vi.mock('node:fs/promises', () => ({
|
||||
writeFile: vi.fn(() => Promise.resolve(undefined)),
|
||||
unlink: vi.fn(() => Promise.resolve(undefined)),
|
||||
chmod: vi.fn(() => Promise.resolve(undefined)),
|
||||
mkdir: vi.fn(() => Promise.resolve(undefined)),
|
||||
}));
|
||||
|
||||
vi.mock('node:os', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof os>();
|
||||
return {
|
||||
...actual,
|
||||
tmpdir: vi.fn(() => '/tmp'),
|
||||
homedir: vi.fn(() => '/home/test'),
|
||||
};
|
||||
});
|
||||
|
||||
@@ -128,30 +129,24 @@ describe('IDEServer', () => {
|
||||
);
|
||||
|
||||
const port = getPortFromMock(replaceMock);
|
||||
const expectedPortFile = path.join(
|
||||
'/tmp',
|
||||
`qwen-code-ide-server-${port}.json`,
|
||||
);
|
||||
const expectedPpidPortFile = path.join(
|
||||
'/tmp',
|
||||
`qwen-code-ide-server-${process.ppid}.json`,
|
||||
const expectedLockFile = path.join(
|
||||
'/home/test',
|
||||
'.qwen',
|
||||
'ide',
|
||||
`${port}.lock`,
|
||||
);
|
||||
const expectedContent = JSON.stringify({
|
||||
port: parseInt(port, 10),
|
||||
workspacePath: expectedWorkspacePaths,
|
||||
ppid: process.ppid,
|
||||
authToken: 'test-auth-token',
|
||||
ideName: 'VS Code',
|
||||
});
|
||||
expect(fs.writeFile).toHaveBeenCalledWith(
|
||||
expectedPortFile,
|
||||
expectedLockFile,
|
||||
expectedContent,
|
||||
);
|
||||
expect(fs.writeFile).toHaveBeenCalledWith(
|
||||
expectedPpidPortFile,
|
||||
expectedContent,
|
||||
);
|
||||
expect(fs.chmod).toHaveBeenCalledWith(expectedPortFile, 0o600);
|
||||
expect(fs.chmod).toHaveBeenCalledWith(expectedPpidPortFile, 0o600);
|
||||
expect(fs.chmod).toHaveBeenCalledWith(expectedLockFile, 0o600);
|
||||
});
|
||||
|
||||
it('should set a single folder path', async () => {
|
||||
@@ -166,30 +161,24 @@ describe('IDEServer', () => {
|
||||
);
|
||||
|
||||
const port = getPortFromMock(replaceMock);
|
||||
const expectedPortFile = path.join(
|
||||
'/tmp',
|
||||
`qwen-code-ide-server-${port}.json`,
|
||||
);
|
||||
const expectedPpidPortFile = path.join(
|
||||
'/tmp',
|
||||
`qwen-code-ide-server-${process.ppid}.json`,
|
||||
const expectedLockFile = path.join(
|
||||
'/home/test',
|
||||
'.qwen',
|
||||
'ide',
|
||||
`${port}.lock`,
|
||||
);
|
||||
const expectedContent = JSON.stringify({
|
||||
port: parseInt(port, 10),
|
||||
workspacePath: '/foo/bar',
|
||||
ppid: process.ppid,
|
||||
authToken: 'test-auth-token',
|
||||
ideName: 'VS Code',
|
||||
});
|
||||
expect(fs.writeFile).toHaveBeenCalledWith(
|
||||
expectedPortFile,
|
||||
expectedLockFile,
|
||||
expectedContent,
|
||||
);
|
||||
expect(fs.writeFile).toHaveBeenCalledWith(
|
||||
expectedPpidPortFile,
|
||||
expectedContent,
|
||||
);
|
||||
expect(fs.chmod).toHaveBeenCalledWith(expectedPortFile, 0o600);
|
||||
expect(fs.chmod).toHaveBeenCalledWith(expectedPpidPortFile, 0o600);
|
||||
expect(fs.chmod).toHaveBeenCalledWith(expectedLockFile, 0o600);
|
||||
});
|
||||
|
||||
it('should set an empty string if no folders are open', async () => {
|
||||
@@ -204,30 +193,24 @@ describe('IDEServer', () => {
|
||||
);
|
||||
|
||||
const port = getPortFromMock(replaceMock);
|
||||
const expectedPortFile = path.join(
|
||||
'/tmp',
|
||||
`qwen-code-ide-server-${port}.json`,
|
||||
);
|
||||
const expectedPpidPortFile = path.join(
|
||||
'/tmp',
|
||||
`qwen-code-ide-server-${process.ppid}.json`,
|
||||
const expectedLockFile = path.join(
|
||||
'/home/test',
|
||||
'.qwen',
|
||||
'ide',
|
||||
`${port}.lock`,
|
||||
);
|
||||
const expectedContent = JSON.stringify({
|
||||
port: parseInt(port, 10),
|
||||
workspacePath: '',
|
||||
ppid: process.ppid,
|
||||
authToken: 'test-auth-token',
|
||||
ideName: 'VS Code',
|
||||
});
|
||||
expect(fs.writeFile).toHaveBeenCalledWith(
|
||||
expectedPortFile,
|
||||
expectedLockFile,
|
||||
expectedContent,
|
||||
);
|
||||
expect(fs.writeFile).toHaveBeenCalledWith(
|
||||
expectedPpidPortFile,
|
||||
expectedContent,
|
||||
);
|
||||
expect(fs.chmod).toHaveBeenCalledWith(expectedPortFile, 0o600);
|
||||
expect(fs.chmod).toHaveBeenCalledWith(expectedPpidPortFile, 0o600);
|
||||
expect(fs.chmod).toHaveBeenCalledWith(expectedLockFile, 0o600);
|
||||
});
|
||||
|
||||
it('should update the path when workspace folders change', async () => {
|
||||
@@ -256,30 +239,24 @@ describe('IDEServer', () => {
|
||||
);
|
||||
|
||||
const port = getPortFromMock(replaceMock);
|
||||
const expectedPortFile = path.join(
|
||||
'/tmp',
|
||||
`qwen-code-ide-server-${port}.json`,
|
||||
);
|
||||
const expectedPpidPortFile = path.join(
|
||||
'/tmp',
|
||||
`qwen-code-ide-server-${process.ppid}.json`,
|
||||
const expectedLockFile = path.join(
|
||||
'/home/test',
|
||||
'.qwen',
|
||||
'ide',
|
||||
`${port}.lock`,
|
||||
);
|
||||
const expectedContent = JSON.stringify({
|
||||
port: parseInt(port, 10),
|
||||
workspacePath: expectedWorkspacePaths,
|
||||
ppid: process.ppid,
|
||||
authToken: 'test-auth-token',
|
||||
ideName: 'VS Code',
|
||||
});
|
||||
expect(fs.writeFile).toHaveBeenCalledWith(
|
||||
expectedPortFile,
|
||||
expectedLockFile,
|
||||
expectedContent,
|
||||
);
|
||||
expect(fs.writeFile).toHaveBeenCalledWith(
|
||||
expectedPpidPortFile,
|
||||
expectedContent,
|
||||
);
|
||||
expect(fs.chmod).toHaveBeenCalledWith(expectedPortFile, 0o600);
|
||||
expect(fs.chmod).toHaveBeenCalledWith(expectedPpidPortFile, 0o600);
|
||||
expect(fs.chmod).toHaveBeenCalledWith(expectedLockFile, 0o600);
|
||||
|
||||
// Simulate removing a folder
|
||||
vscodeMock.workspace.workspaceFolders = [{ uri: { fsPath: '/baz/qux' } }];
|
||||
@@ -294,36 +271,26 @@ describe('IDEServer', () => {
|
||||
workspacePath: '/baz/qux',
|
||||
ppid: process.ppid,
|
||||
authToken: 'test-auth-token',
|
||||
ideName: 'VS Code',
|
||||
});
|
||||
expect(fs.writeFile).toHaveBeenCalledWith(
|
||||
expectedPortFile,
|
||||
expectedLockFile,
|
||||
expectedContent2,
|
||||
);
|
||||
expect(fs.writeFile).toHaveBeenCalledWith(
|
||||
expectedPpidPortFile,
|
||||
expectedContent2,
|
||||
);
|
||||
expect(fs.chmod).toHaveBeenCalledWith(expectedPortFile, 0o600);
|
||||
expect(fs.chmod).toHaveBeenCalledWith(expectedPpidPortFile, 0o600);
|
||||
expect(fs.chmod).toHaveBeenCalledWith(expectedLockFile, 0o600);
|
||||
});
|
||||
|
||||
it('should clear env vars and delete port file on stop', async () => {
|
||||
it('should clear env vars and delete lock file on stop', async () => {
|
||||
await ideServer.start(mockContext);
|
||||
const replaceMock = mockContext.environmentVariableCollection.replace;
|
||||
const port = getPortFromMock(replaceMock);
|
||||
const portFile = path.join('/tmp', `qwen-code-ide-server-${port}.json`);
|
||||
const ppidPortFile = path.join(
|
||||
'/tmp',
|
||||
`qwen-code-ide-server-${process.ppid}.json`,
|
||||
);
|
||||
expect(fs.writeFile).toHaveBeenCalledWith(portFile, expect.any(String));
|
||||
expect(fs.writeFile).toHaveBeenCalledWith(ppidPortFile, expect.any(String));
|
||||
const lockFile = path.join('/home/test', '.qwen', 'ide', `${port}.lock`);
|
||||
expect(fs.writeFile).toHaveBeenCalledWith(lockFile, expect.any(String));
|
||||
|
||||
await ideServer.stop();
|
||||
|
||||
expect(mockContext.environmentVariableCollection.clear).toHaveBeenCalled();
|
||||
expect(fs.unlink).toHaveBeenCalledWith(portFile);
|
||||
expect(fs.unlink).toHaveBeenCalledWith(ppidPortFile);
|
||||
expect(fs.unlink).toHaveBeenCalledWith(lockFile);
|
||||
});
|
||||
|
||||
it.skipIf(process.platform !== 'win32')(
|
||||
@@ -344,30 +311,24 @@ describe('IDEServer', () => {
|
||||
);
|
||||
|
||||
const port = getPortFromMock(replaceMock);
|
||||
const expectedPortFile = path.join(
|
||||
'/tmp',
|
||||
`qwen-code-ide-server-${port}.json`,
|
||||
);
|
||||
const expectedPpidPortFile = path.join(
|
||||
'/tmp',
|
||||
`qwen-code-ide-server-${process.ppid}.json`,
|
||||
const expectedLockFile = path.join(
|
||||
'/home/test',
|
||||
'.qwen',
|
||||
'ide',
|
||||
`${port}.lock`,
|
||||
);
|
||||
const expectedContent = JSON.stringify({
|
||||
port: parseInt(port, 10),
|
||||
workspacePath: expectedWorkspacePaths,
|
||||
ppid: process.ppid,
|
||||
authToken: 'test-auth-token',
|
||||
ideName: 'VS Code',
|
||||
});
|
||||
expect(fs.writeFile).toHaveBeenCalledWith(
|
||||
expectedPortFile,
|
||||
expectedLockFile,
|
||||
expectedContent,
|
||||
);
|
||||
expect(fs.writeFile).toHaveBeenCalledWith(
|
||||
expectedPpidPortFile,
|
||||
expectedContent,
|
||||
);
|
||||
expect(fs.chmod).toHaveBeenCalledWith(expectedPortFile, 0o600);
|
||||
expect(fs.chmod).toHaveBeenCalledWith(expectedPpidPortFile, 0o600);
|
||||
expect(fs.chmod).toHaveBeenCalledWith(expectedLockFile, 0o600);
|
||||
},
|
||||
);
|
||||
|
||||
@@ -379,7 +340,7 @@ describe('IDEServer', () => {
|
||||
port = (ideServer as unknown as { port: number }).port;
|
||||
});
|
||||
|
||||
it('should allow request without auth token for backwards compatibility', async () => {
|
||||
it('should reject request without auth token', async () => {
|
||||
const response = await fetch(`http://localhost:${port}/mcp`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
@@ -390,7 +351,9 @@ describe('IDEServer', () => {
|
||||
id: 1,
|
||||
}),
|
||||
});
|
||||
expect(response.status).not.toBe(401);
|
||||
expect(response.status).toBe(401);
|
||||
const body = await response.text();
|
||||
expect(body).toBe('Unauthorized');
|
||||
});
|
||||
|
||||
it('should allow request with valid auth token', async () => {
|
||||
@@ -550,6 +513,7 @@ describe('IDEServer HTTP endpoints', () => {
|
||||
headers: {
|
||||
Host: `localhost:${port}`,
|
||||
'Content-Type': 'application/json',
|
||||
Authorization: 'Bearer test-auth-token',
|
||||
},
|
||||
},
|
||||
JSON.stringify({ jsonrpc: '2.0', method: 'initialize' }),
|
||||
|
||||
@@ -10,6 +10,7 @@ import {
|
||||
IdeContextNotificationSchema,
|
||||
OpenDiffRequestSchema,
|
||||
} from '@qwen-code/qwen-code-core/src/ide/types.js';
|
||||
import { detectIdeFromEnv } from '@qwen-code/qwen-code-core/src/ide/detect-ide.js';
|
||||
import { isInitializeRequest } from '@modelcontextprotocol/sdk/types.js';
|
||||
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
|
||||
import { StreamableHTTPServerTransport } from '@modelcontextprotocol/sdk/server/streamableHttp.js';
|
||||
@@ -38,12 +39,24 @@ class CORSError extends Error {
|
||||
const MCP_SESSION_ID_HEADER = 'mcp-session-id';
|
||||
const IDE_SERVER_PORT_ENV_VAR = 'QWEN_CODE_IDE_SERVER_PORT';
|
||||
const IDE_WORKSPACE_PATH_ENV_VAR = 'QWEN_CODE_IDE_WORKSPACE_PATH';
|
||||
const QWEN_DIR = '.qwen';
|
||||
const IDE_DIR = 'ide';
|
||||
|
||||
async function getGlobalIdeDir(): Promise<string> {
|
||||
const homeDir = os.homedir();
|
||||
// Prefer home dir, but fall back to tmpdir if unavailable (matches core Storage behavior).
|
||||
const baseDir = homeDir
|
||||
? path.join(homeDir, QWEN_DIR)
|
||||
: path.join(os.tmpdir(), QWEN_DIR);
|
||||
const ideDir = path.join(baseDir, IDE_DIR);
|
||||
await fs.mkdir(ideDir, { recursive: true });
|
||||
return ideDir;
|
||||
}
|
||||
|
||||
interface WritePortAndWorkspaceArgs {
|
||||
context: vscode.ExtensionContext;
|
||||
port: number;
|
||||
portFile: string;
|
||||
ppidPortFile: string;
|
||||
lockFile: string;
|
||||
authToken: string;
|
||||
log: (message: string) => void;
|
||||
}
|
||||
@@ -51,8 +64,7 @@ interface WritePortAndWorkspaceArgs {
|
||||
async function writePortAndWorkspace({
|
||||
context,
|
||||
port,
|
||||
portFile,
|
||||
ppidPortFile,
|
||||
lockFile,
|
||||
authToken,
|
||||
log,
|
||||
}: WritePortAndWorkspaceArgs): Promise<void> {
|
||||
@@ -71,26 +83,24 @@ async function writePortAndWorkspace({
|
||||
workspacePath,
|
||||
);
|
||||
|
||||
const ideInfo = detectIdeFromEnv();
|
||||
const content = JSON.stringify({
|
||||
port,
|
||||
workspacePath,
|
||||
ppid: process.ppid,
|
||||
authToken,
|
||||
ideName: ideInfo.displayName,
|
||||
});
|
||||
|
||||
log(`Writing port file to: ${portFile}`);
|
||||
log(`Writing ppid port file to: ${ppidPortFile}`);
|
||||
log(`Writing IDE lock file to: ${lockFile}`);
|
||||
|
||||
try {
|
||||
await Promise.all([
|
||||
fs.writeFile(portFile, content).then(() => fs.chmod(portFile, 0o600)),
|
||||
fs
|
||||
.writeFile(ppidPortFile, content)
|
||||
.then(() => fs.chmod(ppidPortFile, 0o600)),
|
||||
]);
|
||||
await fs.mkdir(path.dirname(lockFile), { recursive: true });
|
||||
await fs.writeFile(lockFile, content);
|
||||
await fs.chmod(lockFile, 0o600);
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : String(err);
|
||||
log(`Failed to write port to file: ${message}`);
|
||||
log(`Failed to write IDE lock file: ${message}`);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -121,8 +131,7 @@ export class IDEServer {
|
||||
private server: HTTPServer | undefined;
|
||||
private context: vscode.ExtensionContext | undefined;
|
||||
private log: (message: string) => void;
|
||||
private portFile: string | undefined;
|
||||
private ppidPortFile: string | undefined;
|
||||
private lockFile: string | undefined;
|
||||
private port: number | undefined;
|
||||
private authToken: string | undefined;
|
||||
private transports: { [sessionId: string]: StreamableHTTPServerTransport } =
|
||||
@@ -174,19 +183,24 @@ export class IDEServer {
|
||||
|
||||
app.use((req, res, next) => {
|
||||
const authHeader = req.headers.authorization;
|
||||
if (authHeader) {
|
||||
const parts = authHeader.split(' ');
|
||||
if (parts.length !== 2 || parts[0] !== 'Bearer') {
|
||||
this.log('Malformed Authorization header. Rejecting request.');
|
||||
res.status(401).send('Unauthorized');
|
||||
return;
|
||||
}
|
||||
const token = parts[1];
|
||||
if (token !== this.authToken) {
|
||||
this.log('Invalid auth token provided. Rejecting request.');
|
||||
res.status(401).send('Unauthorized');
|
||||
return;
|
||||
}
|
||||
if (!authHeader) {
|
||||
this.log('Missing Authorization header. Rejecting request.');
|
||||
res.status(401).send('Unauthorized');
|
||||
return;
|
||||
}
|
||||
|
||||
const parts = authHeader.split(' ');
|
||||
if (parts.length !== 2 || parts[0] !== 'Bearer') {
|
||||
this.log('Malformed Authorization header. Rejecting request.');
|
||||
res.status(401).send('Unauthorized');
|
||||
return;
|
||||
}
|
||||
|
||||
const token = parts[1];
|
||||
if (token !== this.authToken) {
|
||||
this.log('Invalid auth token provided. Rejecting request.');
|
||||
res.status(401).send('Unauthorized');
|
||||
return;
|
||||
}
|
||||
next();
|
||||
});
|
||||
@@ -327,22 +341,21 @@ export class IDEServer {
|
||||
const address = (this.server as HTTPServer).address();
|
||||
if (address && typeof address !== 'string') {
|
||||
this.port = address.port;
|
||||
this.portFile = path.join(
|
||||
os.tmpdir(),
|
||||
`qwen-code-ide-server-${this.port}.json`,
|
||||
);
|
||||
this.ppidPortFile = path.join(
|
||||
os.tmpdir(),
|
||||
`qwen-code-ide-server-${process.ppid}.json`,
|
||||
);
|
||||
try {
|
||||
const ideDir = await getGlobalIdeDir();
|
||||
// Name the lock file by port to support multiple server instances.
|
||||
this.lockFile = path.join(ideDir, `${this.port}.lock`);
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : String(err);
|
||||
this.log(`Failed to determine IDE lock directory: ${message}`);
|
||||
}
|
||||
this.log(`IDE server listening on http://127.0.0.1:${this.port}`);
|
||||
|
||||
if (this.authToken) {
|
||||
if (this.authToken && this.lockFile) {
|
||||
await writePortAndWorkspace({
|
||||
context,
|
||||
port: this.port,
|
||||
portFile: this.portFile,
|
||||
ppidPortFile: this.ppidPortFile,
|
||||
lockFile: this.lockFile,
|
||||
authToken: this.authToken,
|
||||
log: this.log,
|
||||
});
|
||||
@@ -371,15 +384,13 @@ export class IDEServer {
|
||||
this.context &&
|
||||
this.server &&
|
||||
this.port &&
|
||||
this.portFile &&
|
||||
this.ppidPortFile &&
|
||||
this.lockFile &&
|
||||
this.authToken
|
||||
) {
|
||||
await writePortAndWorkspace({
|
||||
context: this.context,
|
||||
port: this.port,
|
||||
portFile: this.portFile,
|
||||
ppidPortFile: this.ppidPortFile,
|
||||
lockFile: this.lockFile,
|
||||
authToken: this.authToken,
|
||||
log: this.log,
|
||||
});
|
||||
@@ -405,16 +416,9 @@ export class IDEServer {
|
||||
if (this.context) {
|
||||
this.context.environmentVariableCollection.clear();
|
||||
}
|
||||
if (this.portFile) {
|
||||
if (this.lockFile) {
|
||||
try {
|
||||
await fs.unlink(this.portFile);
|
||||
} catch (_err) {
|
||||
// Ignore errors if the file doesn't exist.
|
||||
}
|
||||
}
|
||||
if (this.ppidPortFile) {
|
||||
try {
|
||||
await fs.unlink(this.ppidPortFile);
|
||||
await fs.unlink(this.lockFile);
|
||||
} catch (_err) {
|
||||
// Ignore errors if the file doesn't exist.
|
||||
}
|
||||
|
||||
@@ -54,27 +54,31 @@ export class AcpSessionManager {
|
||||
};
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
// different timeout durations based on methods
|
||||
let timeoutDuration = 60000; // default 60 seconds
|
||||
if (
|
||||
method === AGENT_METHODS.session_prompt ||
|
||||
method === AGENT_METHODS.initialize
|
||||
) {
|
||||
timeoutDuration = 120000; // 2min for session_prompt and initialize
|
||||
}
|
||||
// No timeout for session_prompt as LLM tasks can take 5-10 minutes or longer
|
||||
// The request should always terminate with a stop_reason
|
||||
let timeoutId: NodeJS.Timeout | undefined;
|
||||
let timeoutDuration: number | undefined;
|
||||
|
||||
const timeoutId = setTimeout(() => {
|
||||
pendingRequests.delete(id);
|
||||
reject(new Error(`Request ${method} timed out`));
|
||||
}, timeoutDuration);
|
||||
if (method !== AGENT_METHODS.session_prompt) {
|
||||
// Set timeout for other methods
|
||||
timeoutDuration = method === AGENT_METHODS.initialize ? 120000 : 60000;
|
||||
timeoutId = setTimeout(() => {
|
||||
pendingRequests.delete(id);
|
||||
reject(new Error(`Request ${method} timed out`));
|
||||
}, timeoutDuration);
|
||||
}
|
||||
|
||||
const pendingRequest: PendingRequest<T> = {
|
||||
resolve: (value: T) => {
|
||||
clearTimeout(timeoutId);
|
||||
if (timeoutId) {
|
||||
clearTimeout(timeoutId);
|
||||
}
|
||||
resolve(value);
|
||||
},
|
||||
reject: (error: Error) => {
|
||||
clearTimeout(timeoutId);
|
||||
if (timeoutId) {
|
||||
clearTimeout(timeoutId);
|
||||
}
|
||||
reject(error);
|
||||
},
|
||||
timeoutId,
|
||||
|
||||
@@ -144,10 +144,7 @@ export const InputForm: React.FC<InputFormProps> = ({
|
||||
: '';
|
||||
|
||||
return (
|
||||
<div
|
||||
className="p-1 px-4 pb-4 absolute bottom-0 left-0 right-0"
|
||||
style={{ backgroundColor: 'var(--app-primary-background)' }}
|
||||
>
|
||||
<div className="p-1 px-4 pb-4 absolute bottom-0 left-0 right-0 bg-gradient-to-b from-transparent to-[var(--app-primary-background)]">
|
||||
<div className="block">
|
||||
<form className="composer-form" onSubmit={onSubmit}>
|
||||
{/* Inner background layer */}
|
||||
|
||||
@@ -48,5 +48,5 @@
|
||||
}
|
||||
|
||||
.assistant-message-container.assistant-message-loading::after {
|
||||
display: none
|
||||
display: none;
|
||||
}
|
||||
|
||||
@@ -172,7 +172,8 @@
|
||||
|
||||
/* Loading animation for toolcall header */
|
||||
@keyframes toolcallHeaderPulse {
|
||||
0%, 100% {
|
||||
0%,
|
||||
100% {
|
||||
opacity: 1;
|
||||
}
|
||||
50% {
|
||||
|
||||
@@ -152,6 +152,24 @@ export class SessionMessageHandler extends BaseMessageHandler {
|
||||
this.currentStreamContent = '';
|
||||
}
|
||||
|
||||
/**
|
||||
* Notify the webview that streaming has finished.
|
||||
*/
|
||||
private sendStreamEnd(reason?: string): void {
|
||||
const data: { timestamp: number; reason?: string } = {
|
||||
timestamp: Date.now(),
|
||||
};
|
||||
|
||||
if (reason) {
|
||||
data.reason = reason;
|
||||
}
|
||||
|
||||
this.sendToWebView({
|
||||
type: 'streamEnd',
|
||||
data,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Prompt user to login and invoke the registered login handler/command.
|
||||
* Returns true if a login was initiated.
|
||||
@@ -373,10 +391,7 @@ export class SessionMessageHandler extends BaseMessageHandler {
|
||||
);
|
||||
}
|
||||
|
||||
this.sendToWebView({
|
||||
type: 'streamEnd',
|
||||
data: { timestamp: Date.now() },
|
||||
});
|
||||
this.sendStreamEnd();
|
||||
} catch (error) {
|
||||
console.error('[SessionMessageHandler] Error sending message:', error);
|
||||
|
||||
@@ -398,10 +413,7 @@ export class SessionMessageHandler extends BaseMessageHandler {
|
||||
if (isAbortLike) {
|
||||
// Do not show VS Code error popup for intentional cancellations.
|
||||
// Ensure the webview knows the stream ended due to user action.
|
||||
this.sendToWebView({
|
||||
type: 'streamEnd',
|
||||
data: { timestamp: Date.now(), reason: 'user_cancelled' },
|
||||
});
|
||||
this.sendStreamEnd('user_cancelled');
|
||||
return;
|
||||
}
|
||||
// Check for session not found error and handle it appropriately
|
||||
@@ -423,12 +435,39 @@ export class SessionMessageHandler extends BaseMessageHandler {
|
||||
type: 'sessionExpired',
|
||||
data: { message: 'Session expired. Please login again.' },
|
||||
});
|
||||
this.sendStreamEnd('session_expired');
|
||||
} else {
|
||||
vscode.window.showErrorMessage(`Error sending message: ${error}`);
|
||||
this.sendToWebView({
|
||||
type: 'error',
|
||||
data: { message: errorMsg },
|
||||
});
|
||||
const isTimeoutError =
|
||||
lower.includes('timeout') || lower.includes('timed out');
|
||||
if (isTimeoutError) {
|
||||
// Note: session_prompt no longer has a timeout, so this should rarely occur
|
||||
// This path may still be hit for other methods (initialize, etc.) or network-level timeouts
|
||||
console.warn(
|
||||
'[SessionMessageHandler] Request timed out; suppressing popup',
|
||||
);
|
||||
|
||||
const timeoutMessage: ChatMessage = {
|
||||
role: 'assistant',
|
||||
content:
|
||||
'Request timed out. This may be due to a network issue. Please try again.',
|
||||
timestamp: Date.now(),
|
||||
};
|
||||
|
||||
// Send a timeout message to the WebView
|
||||
this.sendToWebView({
|
||||
type: 'message',
|
||||
data: timeoutMessage,
|
||||
});
|
||||
this.sendStreamEnd('timeout');
|
||||
} else {
|
||||
// Handling of Non-Timeout Errors
|
||||
vscode.window.showErrorMessage(`Error sending message: ${error}`);
|
||||
this.sendToWebView({
|
||||
type: 'error',
|
||||
data: { message: errorMsg },
|
||||
});
|
||||
this.sendStreamEnd('error');
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -15,6 +15,14 @@ import type { ToolCallUpdate } from '../../types/chatTypes.js';
|
||||
import type { ApprovalModeValue } from '../../types/approvalModeValueTypes.js';
|
||||
import type { PlanEntry } from '../../types/chatTypes.js';
|
||||
|
||||
const FORCE_CLEAR_STREAM_END_REASONS = new Set([
|
||||
'user_cancelled',
|
||||
'cancelled',
|
||||
'timeout',
|
||||
'error',
|
||||
'session_expired',
|
||||
]);
|
||||
|
||||
interface UseWebViewMessagesProps {
|
||||
// Session management
|
||||
sessionManagement: {
|
||||
@@ -364,12 +372,12 @@ export const useWebViewMessages = ({
|
||||
).toLowerCase();
|
||||
|
||||
/**
|
||||
* Handle different types of stream end reasons:
|
||||
* - 'user_cancelled': User explicitly cancelled operation
|
||||
* - 'cancelled': General cancellation
|
||||
* For these cases, immediately clear all active states
|
||||
* Handle different types of stream end reasons that require a full reset:
|
||||
* - 'user_cancelled' / 'cancelled': user explicitly cancelled
|
||||
* - 'timeout' / 'error' / 'session_expired': request failed unexpectedly
|
||||
* For these cases, immediately clear all active states.
|
||||
*/
|
||||
if (reason === 'user_cancelled' || reason === 'cancelled') {
|
||||
if (FORCE_CLEAR_STREAM_END_REASONS.has(reason)) {
|
||||
// Clear active execution tool call tracking, reset state
|
||||
activeExecToolCallsRef.current.clear();
|
||||
// Clear waiting response state to ensure UI returns to normal
|
||||
@@ -393,6 +401,9 @@ export const useWebViewMessages = ({
|
||||
}
|
||||
|
||||
case 'error':
|
||||
handlers.messageHandling.endStreaming();
|
||||
handlers.messageHandling.clearThinking();
|
||||
activeExecToolCallsRef.current.clear();
|
||||
handlers.messageHandling.clearWaitingForResponse();
|
||||
break;
|
||||
|
||||
|
||||
@@ -43,7 +43,7 @@
|
||||
|
||||
/* Composer: form wrapper */
|
||||
.composer-form {
|
||||
@apply relative flex flex-col max-w-[680px] mx-auto rounded-large border shadow-sm transition-colors duration-200;
|
||||
@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);
|
||||
border-color: var(--app-input-border);
|
||||
color: var(--app-input-foreground);
|
||||
@@ -51,7 +51,8 @@
|
||||
.composer-form:focus-within {
|
||||
/* match existing highlight behavior */
|
||||
border-color: var(--app-input-highlight);
|
||||
box-shadow: 0 1px 2px color-mix(in srgb, var(--app-input-highlight), transparent 80%);
|
||||
box-shadow: 0 1px 2px
|
||||
color-mix(in srgb, var(--app-input-highlight), transparent 80%);
|
||||
}
|
||||
|
||||
/* Composer: input editable area */
|
||||
@@ -66,7 +67,7 @@
|
||||
The data attribute is needed because some browsers insert a <br> in
|
||||
contentEditable, which breaks :empty matching. */
|
||||
.composer-input:empty:before,
|
||||
.composer-input[data-empty="true"]::before {
|
||||
.composer-input[data-empty='true']::before {
|
||||
content: attr(data-placeholder);
|
||||
color: var(--app-input-placeholder-foreground);
|
||||
pointer-events: none;
|
||||
@@ -80,7 +81,7 @@
|
||||
outline: none;
|
||||
}
|
||||
.composer-input:disabled,
|
||||
.composer-input[contenteditable="false"] {
|
||||
.composer-input[contenteditable='false'] {
|
||||
color: #999;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user