diff --git a/.github/workflows/release-sdk.yml b/.github/workflows/release-sdk.yml index ccdc24b77..c7e2e3619 100644 --- a/.github/workflows/release-sdk.yml +++ b/.github/workflows/release-sdk.yml @@ -348,15 +348,32 @@ jobs: CLI_SOURCE_DESC="CLI built from source (same branch/ref as SDK)" fi - # Create release notes with CLI version info - NOTES="## Bundled CLI Version\n\nThis SDK release bundles CLI version: \`${CLI_VERSION}\`\n\nSource: ${CLI_SOURCE_DESC}\n\n---\n\n" + # Create release notes file + NOTES_FILE=$(mktemp) + { + echo "## Bundled CLI Version" + echo "" + echo "This SDK release bundles CLI version: ${CLI_VERSION}" + echo "" + echo "Source: ${CLI_SOURCE_DESC}" + echo "" + echo "---" + echo "" + } > "${NOTES_FILE}" + # Get previous release notes if available + PREVIOUS_NOTES=$(gh release view "sdk-typescript-${PREVIOUS_RELEASE_TAG}" --json body -q '.body' 2>/dev/null || echo 'See commit history for changes.') + printf '%s\n' "${PREVIOUS_NOTES}" >> "${NOTES_FILE}" + + # Create GitHub release gh release create "sdk-typescript-${RELEASE_TAG}" \ --target "${TARGET}" \ --title "SDK TypeScript Release ${RELEASE_TAG}" \ - --notes-start-tag "sdk-typescript-${PREVIOUS_RELEASE_TAG}" \ - --notes "${NOTES}$(gh release view "sdk-typescript-${PREVIOUS_RELEASE_TAG}" --json body -q '.body' 2>/dev/null || echo 'See commit history for changes.')" \ - "${PRERELEASE_FLAG}" + --notes-file "${NOTES_FILE}" \ + ${PRERELEASE_FLAG} + + # Cleanup + rm -f "${NOTES_FILE}" - name: 'Create PR to merge release branch into main' if: |- diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index ffcda3dc0..617cf9553 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -206,13 +206,22 @@ jobs: RELEASE_BRANCH: '${{ steps.release_branch.outputs.BRANCH_NAME }}' RELEASE_TAG: '${{ steps.version.outputs.RELEASE_TAG }}' PREVIOUS_RELEASE_TAG: '${{ steps.version.outputs.PREVIOUS_RELEASE_TAG }}' + IS_NIGHTLY: '${{ steps.vars.outputs.is_nightly }}' + IS_PREVIEW: '${{ steps.vars.outputs.is_preview }}' run: |- + # Set prerelease flag for nightly and preview releases + PRERELEASE_FLAG="" + if [[ "${IS_NIGHTLY}" == "true" || "${IS_PREVIEW}" == "true" ]]; then + PRERELEASE_FLAG="--prerelease" + fi + gh release create "${RELEASE_TAG}" \ dist/cli.js \ --target "$RELEASE_BRANCH" \ --title "Release ${RELEASE_TAG}" \ --notes-start-tag "$PREVIOUS_RELEASE_TAG" \ - --generate-notes + --generate-notes \ + ${PRERELEASE_FLAG} - name: 'Create Issue on Failure' if: |- diff --git a/.gitignore b/.gitignore index 0e7dc1528..cd7c11a11 100644 --- a/.gitignore +++ b/.gitignore @@ -52,6 +52,9 @@ packages/vscode-ide-companion/*.vsix # Qwen Code Configs +.qwen/ +!.qwen/commands/ +!.qwen/skills/ logs/ # GHA credentials gha-creds-*.json diff --git a/README.md b/README.md index 4c9d28179..ab598666c 100644 --- a/README.md +++ b/README.md @@ -18,6 +18,8 @@ +> 🎉 **News (2026-02-16)**: Qwen3.5-Plus is now live! Sign in via Qwen OAuth to use it directly, or get an API key from [Alibaba Cloud ModelStudio](https://modelstudio.console.alibabacloud.com?tab=doc#/doc/?type=model&url=2840914_2&modelId=group-qwen3.5-plus) to access it through the OpenAI-compatible API. + 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. ![](https://gw.alicdn.com/imgextra/i1/O1CN01D2DviS1wwtEtMwIzJ_!!6000000006373-2-tps-1600-900.png) @@ -123,7 +125,231 @@ Use this if you want more flexibility over which provider and model to use. Supp - **Anthropic**: Claude models - **Google GenAI**: Gemini models -For full details (including `modelProviders` configuration, `.env` file loading, environment variable priorities, and security notes), see the [authentication guide](https://qwenlm.github.io/qwen-code-docs/en/users/configuration/auth/). +The **recommended** way to configure models and providers is by editing `~/.qwen/settings.json` (create it if it doesn't exist). This file lets you define all available models, API keys, and default settings in one place. + +##### Quick Setup in 3 Steps + +**Step 1:** Create or edit `~/.qwen/settings.json` + +Here is a complete example: + +```json +{ + "modelProviders": { + "openai": [ + { + "id": "qwen3-coder-plus", + "name": "qwen3-coder-plus", + "baseUrl": "https://dashscope.aliyuncs.com/compatible-mode/v1", + "description": "Qwen3-Coder via Dashscope", + "envKey": "DASHSCOPE_API_KEY" + } + ] + }, + "env": { + "DASHSCOPE_API_KEY": "sk-xxxxxxxxxxxxx" + }, + "security": { + "auth": { + "selectedType": "openai" + } + }, + "model": { + "name": "qwen3-coder-plus" + } +} +``` + +**Step 2:** Understand each field + +| Field | What it does | +| ---------------------------- | ------------------------------------------------------------------------------------------------------------------------------------- | +| `modelProviders` | Declares which models are available and how to connect to them. Keys like `openai`, `anthropic`, `gemini` represent the API protocol. | +| `modelProviders[].id` | The model ID sent to the API (e.g. `qwen3-coder-plus`, `gpt-4o`). | +| `modelProviders[].envKey` | The name of the environment variable that holds your API key. | +| `modelProviders[].baseUrl` | The API endpoint URL (required for non-default endpoints). | +| `env` | A fallback place to store API keys (lowest priority; prefer `.env` files or `export` for sensitive keys). | +| `security.auth.selectedType` | The protocol to use on startup (`openai`, `anthropic`, `gemini`, `vertex-ai`). | +| `model.name` | The default model to use when Qwen Code starts. | + +**Step 3:** Start Qwen Code — your configuration takes effect automatically: + +```bash +qwen +``` + +Use the `/model` command at any time to switch between all configured models. + +##### More Examples + +
+Coding Plan (Alibaba Cloud Bailian) — fixed monthly fee, higher quotas + +```json +{ + "modelProviders": { + "openai": [ + { + "id": "qwen3.5-plus", + "name": "qwen3.5-plus (Coding Plan)", + "baseUrl": "https://coding.dashscope.aliyuncs.com/v1", + "description": "qwen3.5-plus with thinking enabled from Bailian Coding Plan", + "envKey": "BAILIAN_CODING_PLAN_API_KEY", + "generationConfig": { + "extra_body": { + "enable_thinking": true + } + } + }, + { + "id": "qwen3-coder-plus", + "name": "qwen3-coder-plus (Coding Plan)", + "baseUrl": "https://coding.dashscope.aliyuncs.com/v1", + "description": "qwen3-coder-plus from Bailian Coding Plan", + "envKey": "BAILIAN_CODING_PLAN_API_KEY" + }, + { + "id": "qwen3-coder-next", + "name": "qwen3-coder-next (Coding Plan)", + "baseUrl": "https://coding.dashscope.aliyuncs.com/v1", + "description": "qwen3-coder-next with thinking enabled from Bailian Coding Plan", + "envKey": "BAILIAN_CODING_PLAN_API_KEY", + "generationConfig": { + "extra_body": { + "enable_thinking": true + } + } + }, + { + "id": "glm-4.7", + "name": "glm-4.7 (Coding Plan)", + "baseUrl": "https://coding.dashscope.aliyuncs.com/v1", + "description": "glm-4.7 with thinking enabled from Bailian Coding Plan", + "envKey": "BAILIAN_CODING_PLAN_API_KEY", + "generationConfig": { + "extra_body": { + "enable_thinking": true + } + } + }, + { + "id": "kimi-k2.5", + "name": "kimi-k2.5 (Coding Plan)", + "baseUrl": "https://coding.dashscope.aliyuncs.com/v1", + "description": "kimi-k2.5 with thinking enabled from Bailian Coding Plan", + "envKey": "BAILIAN_CODING_PLAN_API_KEY", + "generationConfig": { + "extra_body": { + "enable_thinking": true + } + } + } + ] + }, + "env": { + "BAILIAN_CODING_PLAN_API_KEY": "sk-xxxxxxxxxxxxx" + }, + "security": { + "auth": { + "selectedType": "openai" + } + }, + "model": { + "name": "qwen3-coder-plus" + } +} +``` + +> Subscribe to the Coding Plan and get your API key at [Alibaba Cloud Bailian](https://modelstudio.console.aliyun.com/?tab=dashboard#/efm/coding_plan). + +
+ +
+Multiple providers (OpenAI + Anthropic + Gemini) + +```json +{ + "modelProviders": { + "openai": [ + { + "id": "gpt-4o", + "name": "GPT-4o", + "envKey": "OPENAI_API_KEY", + "baseUrl": "https://api.openai.com/v1" + } + ], + "anthropic": [ + { + "id": "claude-sonnet-4-20250514", + "name": "Claude Sonnet 4", + "envKey": "ANTHROPIC_API_KEY" + } + ], + "gemini": [ + { + "id": "gemini-2.5-pro", + "name": "Gemini 2.5 Pro", + "envKey": "GEMINI_API_KEY" + } + ] + }, + "env": { + "OPENAI_API_KEY": "sk-xxxxxxxxxxxxx", + "ANTHROPIC_API_KEY": "sk-ant-xxxxxxxxxxxxx", + "GEMINI_API_KEY": "AIzaxxxxxxxxxxxxx" + }, + "security": { + "auth": { + "selectedType": "openai" + } + }, + "model": { + "name": "gpt-4o" + } +} +``` + +
+ +
+Enable thinking mode (for supported models like qwen3.5-plus) + +```json +{ + "modelProviders": { + "openai": [ + { + "id": "qwen3.5-plus", + "name": "qwen3.5-plus (thinking)", + "envKey": "DASHSCOPE_API_KEY", + "baseUrl": "https://dashscope.aliyuncs.com/compatible-mode/v1", + "generationConfig": { + "extra_body": { + "enable_thinking": true + } + } + } + ] + }, + "env": { + "DASHSCOPE_API_KEY": "sk-xxxxxxxxxxxxx" + }, + "security": { + "auth": { + "selectedType": "openai" + } + }, + "model": { + "name": "qwen3.5-plus" + } +} +``` + +
+ +> **Tip:** You can also set API keys via `export` in your shell or `.env` files, which take higher priority than `settings.json` → `env`. See the [authentication guide](https://qwenlm.github.io/qwen-code-docs/en/users/configuration/auth/) for full details. + +> **Security note:** Never commit API keys to version control. The `~/.qwen/settings.json` file is in your home directory and should stay private. ## Usage @@ -191,10 +417,21 @@ Build on top of Qwen Code with the TypeScript SDK: Qwen Code can be configured via `settings.json`, environment variables, and CLI flags. -- **User settings**: `~/.qwen/settings.json` -- **Project settings**: `.qwen/settings.json` +| File | Scope | Description | +| ----------------------- | ------------- | --------------------------------------------------------------------------------------- | +| `~/.qwen/settings.json` | User (global) | Applies to all your Qwen Code sessions. **Recommended for `modelProviders` and `env`.** | +| `.qwen/settings.json` | Project | Applies only when running Qwen Code in this project. Overrides user settings. | -See [settings](https://qwenlm.github.io/qwen-code-docs/en/users/configuration/settings/) for available options and precedence. +The most commonly used top-level fields in `settings.json`: + +| Field | Description | +| ---------------------------- | ---------------------------------------------------------------------------------------------------- | +| `modelProviders` | Define available models per protocol (`openai`, `anthropic`, `gemini`, `vertex-ai`). | +| `env` | Fallback environment variables (e.g. API keys). Lower priority than shell `export` and `.env` files. | +| `security.auth.selectedType` | The protocol to use on startup (e.g. `openai`). | +| `model.name` | The default model to use when Qwen Code starts. | + +> See the [Authentication](#api-key-flexible) section above for complete `settings.json` examples, and the [settings reference](https://qwenlm.github.io/qwen-code-docs/en/users/configuration/settings/) for all available options. ## Benchmark Results diff --git a/SECURITY.md b/SECURITY.md index 4e7d8ce79..d4ae9df9e 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -1,5 +1,9 @@ -# Reporting Security Issues +# Security Policy -Please report any security issue or Higress crash report to [ASRC](https://security.alibaba.com/) (Alibaba Security Response Center) where the issue will be triaged appropriately. +## Reporting a Vulnerability -Thank you for helping keep our project secure. +If you believe you have discovered a security vulnerability, please report it to us through the following portal: [Report Security Issue](https://yundun.console.aliyun.com/?p=xznew#/taskmanagement/tasks/detail/151) + +> **Note:** This channel is strictly for reporting security-related issues. Non-security vulnerabilities or general bug reports will not be addressed here. + +We sincerely appreciate your responsible disclosure and your contribution to helping us keep our project secure. diff --git a/docs/developers/roadmap.md b/docs/developers/roadmap.md index 125a4d36e..83cd42355 100644 --- a/docs/developers/roadmap.md +++ b/docs/developers/roadmap.md @@ -2,13 +2,13 @@ > **Objective**: Catch up with Claude Code's product functionality, continuously refine details, and enhance user experience. -| Category | Phase 1 | Phase 2 | -| ------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------- | -| User Experience | ✅ Terminal UI
✅ Support OpenAI Protocol
✅ Settings
✅ OAuth
✅ Cache Control
✅ Memory
✅ Compress
✅ Theme | Better UI
OnBoarding
LogView
✅ Session
Permission
🔄 Cross-platform Compatibility | -| Coding Workflow | ✅ Slash Commands
✅ MCP
✅ PlanMode
✅ TodoWrite
✅ SubAgent
✅ Multi Model
✅ Chat Management
✅ Tools (WebFetch, Bash, TextSearch, FileReadFile, EditFile) | 🔄 Hooks
SubAgent (enhanced)
✅ Skill
✅ Headless Mode
✅ Tools (WebSearch) | -| Building Open Capabilities | ✅ Custom Commands | ✅ QwenCode SDK
Extension | -| Integrating Community Ecosystem | | ✅ VSCode Plugin
🔄 ACP/Zed
✅ GHA | -| Administrative Capabilities | ✅ Stats
✅ Feedback | Costs
Dashboard | +| Category | Phase 1 | Phase 2 | +| ------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| User Experience | ✅ Terminal UI
✅ Support OpenAI Protocol
✅ Settings
✅ OAuth
✅ Cache Control
✅ Memory
✅ Compress
✅ Theme | Better UI
OnBoarding
LogView
✅ Session
Permission
🔄 Cross-platform Compatibility
✅ Coding Plan
✅ Anthropic Provider
✅ Multimodal Input
✅ Unified WebUI | +| Coding Workflow | ✅ Slash Commands
✅ MCP
✅ PlanMode
✅ TodoWrite
✅ SubAgent
✅ Multi Model
✅ Chat Management
✅ Tools (WebFetch, Bash, TextSearch, FileReadFile, EditFile) | 🔄 Hooks
✅ Skill
✅ Headless Mode
✅ Tools (WebSearch)
✅ LSP Support
✅ Concurrent Runner | +| Building Open Capabilities | ✅ Custom Commands | ✅ QwenCode SDK
✅ Extension System | +| Integrating Community Ecosystem | | ✅ VSCode Plugin
✅ ACP/Zed
✅ GHA | +| Administrative Capabilities | ✅ Stats
✅ Feedback | Costs
Dashboard
✅ User Feedback Dialog | > For more details, please see the list below. @@ -16,39 +16,48 @@ #### Completed Features -| Feature | Version | Description | Category | -| ----------------------- | --------- | ------------------------------------------------------- | ------------------------------- | -| Skill | `V0.6.0` | Extensible custom AI skills | Coding Workflow | -| Github Actions | `V0.5.0` | qwen-code-action and automation | Integrating Community Ecosystem | -| VSCode Plugin | `V0.5.0` | VSCode extension plugin | Integrating Community Ecosystem | -| QwenCode SDK | `V0.4.0` | Open SDK for third-party integration | Building Open Capabilities | -| Session | `V0.4.0` | Enhanced session management | User Experience | -| i18n | `V0.3.0` | Internationalization and multilingual support | User Experience | -| Headless Mode | `V0.3.0` | Headless mode (non-interactive) | Coding Workflow | -| ACP/Zed | `V0.2.0` | ACP and Zed editor integration | Integrating Community Ecosystem | -| Terminal UI | `V0.1.0+` | Interactive terminal user interface | User Experience | -| Settings | `V0.1.0+` | Configuration management system | User Experience | -| Theme | `V0.1.0+` | Multi-theme support | User Experience | -| Support OpenAI Protocol | `V0.1.0+` | Support for OpenAI API protocol | User Experience | -| Chat Management | `V0.1.0+` | Session management (save, restore, browse) | Coding Workflow | -| MCP | `V0.1.0+` | Model Context Protocol integration | Coding Workflow | -| Multi Model | `V0.1.0+` | Multi-model support and switching | Coding Workflow | -| Slash Commands | `V0.1.0+` | Slash command system | Coding Workflow | -| Tool: Bash | `V0.1.0+` | Shell command execution tool (with is_background param) | Coding Workflow | -| Tool: FileRead/EditFile | `V0.1.0+` | File read/write and edit tools | Coding Workflow | -| Custom Commands | `V0.1.0+` | Custom command loading | Building Open Capabilities | -| Feedback | `V0.1.0+` | Feedback mechanism (/bug command) | Administrative Capabilities | -| Stats | `V0.1.0+` | Usage statistics and quota display | Administrative Capabilities | -| Memory | `V0.0.9+` | Project-level and global memory management | User Experience | -| Cache Control | `V0.0.9+` | Prompt caching control (Anthropic, DashScope) | User Experience | -| PlanMode | `V0.0.14` | Task planning mode | Coding Workflow | -| Compress | `V0.0.11` | Chat compression mechanism | User Experience | -| SubAgent | `V0.0.11` | Dedicated sub-agent system | Coding Workflow | -| TodoWrite | `V0.0.10` | Task management and progress tracking | Coding Workflow | -| Tool: TextSearch | `V0.0.8+` | Text search tool (grep, supports .qwenignore) | Coding Workflow | -| Tool: WebFetch | `V0.0.7+` | Web content fetching tool | Coding Workflow | -| Tool: WebSearch | `V0.0.7+` | Web search tool (using Tavily API) | Coding Workflow | -| OAuth | `V0.0.5+` | OAuth login authentication (Qwen OAuth) | User Experience | +| Feature | Version | Description | Category | Phase | +| ----------------------- | --------- | ------------------------------------------------------- | ------------------------------- | ----- | +| **Coding Plan** | `V0.10.0` | Bailian Coding Plan authentication & models | User Experience | 2 | +| Unified WebUI | `V0.9.0` | Shared WebUI component library for VSCode/CLI | User Experience | 2 | +| Export Chat | `V0.8.0` | Export sessions to Markdown/HTML/JSON/JSONL | User Experience | 2 | +| Extension System | `V0.8.0` | Full extension management with slash commands | Building Open Capabilities | 2 | +| LSP Support | `V0.7.0` | Experimental LSP service (`--experimental-lsp`) | Coding Workflow | 2 | +| Anthropic Provider | `V0.7.0` | Anthropic API provider support | User Experience | 2 | +| User Feedback Dialog | `V0.7.0` | In-app feedback collection with fatigue mechanism | Administrative Capabilities | 2 | +| Concurrent Runner | `V0.6.0` | Batch CLI execution with Git integration | Coding Workflow | 2 | +| Multimodal Input | `V0.6.0` | Image, PDF, audio, video input support | User Experience | 2 | +| Skill | `V0.6.0` | Extensible custom AI skills (experimental) | Coding Workflow | 2 | +| Github Actions | `V0.5.0` | qwen-code-action and automation | Integrating Community Ecosystem | 1 | +| VSCode Plugin | `V0.5.0` | VSCode extension plugin | Integrating Community Ecosystem | 1 | +| QwenCode SDK | `V0.4.0` | Open SDK for third-party integration | Building Open Capabilities | 1 | +| Session | `V0.4.0` | Enhanced session management | User Experience | 1 | +| i18n | `V0.3.0` | Internationalization and multilingual support | User Experience | 1 | +| Headless Mode | `V0.3.0` | Headless mode (non-interactive) | Coding Workflow | 1 | +| ACP/Zed | `V0.2.0` | ACP and Zed editor integration | Integrating Community Ecosystem | 1 | +| Terminal UI | `V0.1.0+` | Interactive terminal user interface | User Experience | 1 | +| Settings | `V0.1.0+` | Configuration management system | User Experience | 1 | +| Theme | `V0.1.0+` | Multi-theme support | User Experience | 1 | +| Support OpenAI Protocol | `V0.1.0+` | Support for OpenAI API protocol | User Experience | 1 | +| Chat Management | `V0.1.0+` | Session management (save, restore, browse) | Coding Workflow | 1 | +| MCP | `V0.1.0+` | Model Context Protocol integration | Coding Workflow | 1 | +| Multi Model | `V0.1.0+` | Multi-model support and switching | Coding Workflow | 1 | +| Slash Commands | `V0.1.0+` | Slash command system | Coding Workflow | 1 | +| Tool: Bash | `V0.1.0+` | Shell command execution tool (with is_background param) | Coding Workflow | 1 | +| Tool: FileRead/EditFile | `V0.1.0+` | File read/write and edit tools | Coding Workflow | 1 | +| Custom Commands | `V0.1.0+` | Custom command loading | Building Open Capabilities | 1 | +| Feedback | `V0.1.0+` | Feedback mechanism (/bug command) | Administrative Capabilities | 1 | +| Stats | `V0.1.0+` | Usage statistics and quota display | Administrative Capabilities | 1 | +| Memory | `V0.0.9+` | Project-level and global memory management | User Experience | 1 | +| Cache Control | `V0.0.9+` | Prompt caching control (Anthropic, DashScope) | User Experience | 1 | +| PlanMode | `V0.0.14` | Task planning mode | Coding Workflow | 1 | +| Compress | `V0.0.11` | Chat compression mechanism | User Experience | 1 | +| SubAgent | `V0.0.11` | Dedicated sub-agent system | Coding Workflow | 1 | +| TodoWrite | `V0.0.10` | Task management and progress tracking | Coding Workflow | 1 | +| Tool: TextSearch | `V0.0.8+` | Text search tool (grep, supports .qwenignore) | Coding Workflow | 1 | +| Tool: WebFetch | `V0.0.7+` | Web content fetching tool | Coding Workflow | 1 | +| Tool: WebSearch | `V0.0.7+` | Web search tool (using Tavily API) | Coding Workflow | 1 | +| OAuth | `V0.0.5+` | OAuth login authentication (Qwen OAuth) | User Experience | 1 | #### Features to Develop @@ -60,7 +69,6 @@ | Cross-platform Compatibility | P1 | In Progress | Windows/Linux/macOS compatibility | User Experience | | LogView | P2 | Planned | Log viewing and debugging feature | User Experience | | Hooks | P2 | In Progress | Extension hooks system | Coding Workflow | -| Extension | P2 | Planned | Extension system | Building Open Capabilities | | Costs | P2 | Planned | Cost tracking and analysis | Administrative Capabilities | | Dashboard | P2 | Planned | Management dashboard | Administrative Capabilities | diff --git a/docs/users/configuration/auth.md b/docs/users/configuration/auth.md index 0a5b700ea..1d5d30240 100644 --- a/docs/users/configuration/auth.md +++ b/docs/users/configuration/auth.md @@ -31,6 +31,52 @@ qwen Use this if you want more flexibility over which provider and model to use. Supports multiple protocols and providers, including OpenAI, Anthropic, Google GenAI, Alibaba Cloud Bailian, Azure OpenAI, OpenRouter, ModelScope, or a self-hosted compatible endpoint. +### Recommended: One-file setup via `settings.json` + +The simplest way to get started with API-KEY authentication is to put everything in a single `~/.qwen/settings.json` file. Here's a complete, ready-to-use example: + +```json +{ + "modelProviders": { + "openai": [ + { + "id": "qwen3-coder-plus", + "name": "qwen3-coder-plus", + "baseUrl": "https://dashscope.aliyuncs.com/compatible-mode/v1", + "description": "Qwen3-Coder via Dashscope", + "envKey": "DASHSCOPE_API_KEY" + } + ] + }, + "env": { + "DASHSCOPE_API_KEY": "sk-xxxxxxxxxxxxx" + }, + "security": { + "auth": { + "selectedType": "openai" + } + }, + "model": { + "name": "qwen3-coder-plus" + } +} +``` + +What each field does: + +| Field | Description | +| ---------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------- | +| `modelProviders` | Declares which models are available and how to connect to them. Keys (`openai`, `anthropic`, `gemini`, `vertex-ai`) represent the API protocol. | +| `env` | Stores API keys directly in `settings.json` as a fallback (lowest priority — shell `export` and `.env` files take precedence). | +| `security.auth.selectedType` | Tells Qwen Code which protocol to use on startup (e.g. `openai`, `anthropic`, `gemini`). Without this, you'd need to run `/auth` interactively. | +| `model.name` | The default model to activate when Qwen Code starts. Must match one of the `id` values in your `modelProviders`. | + +After saving the file, just run `qwen` — no interactive `/auth` setup needed. + +> [!tip] +> +> The sections below explain each part in more detail. If the quick example above works for you, feel free to skip ahead to [Security notes](#security-notes). + ### Option1: Coding Plan(Aliyun Bailian) Use this if you want predictable costs with higher usage quotas for the qwen3-coder-plus model. @@ -48,10 +94,45 @@ After entering, select `Coding Plan`: ![](https://gw.alicdn.com/imgextra/i4/O1CN01Irk0AD1ebfop69o0r_!!6000000003890-2-tps-2308-830.png) -Enter your `sk-sp-xxxxxxxxx` key, then use the `/model` command to switch between all Bailian `Coding Plan` supported models: +Enter your `sk-sp-xxxxxxxxx` key, then use the `/model` command to switch between all Bailian `Coding Plan` supported models (including qwen3.5-plus, qwen3-coder-plus, qwen3-coder-next, qwen3-max, glm-4.7, and kimi-k2.5): ![](https://gw.alicdn.com/imgextra/i4/O1CN01fWArmf1kaCEgSmPln_!!6000000004699-2-tps-2304-1374.png) +**Alternative: configure Coding Plan via `settings.json`** + +If you prefer to skip the interactive `/auth` flow, add the following to `~/.qwen/settings.json`: + +```json +{ + "modelProviders": { + "openai": [ + { + "id": "qwen3-coder-plus", + "name": "qwen3-coder-plus (Coding Plan)", + "baseUrl": "https://coding.dashscope.aliyuncs.com/v1", + "description": "qwen3-coder-plus from Bailian Coding Plan", + "envKey": "BAILIAN_CODING_PLAN_API_KEY" + } + ] + }, + "env": { + "BAILIAN_CODING_PLAN_API_KEY": "sk-sp-xxxxxxxxx" + }, + "security": { + "auth": { + "selectedType": "openai" + } + }, + "model": { + "name": "qwen3-coder-plus" + } +} +``` + +> [!note] +> +> The Coding Plan uses a dedicated endpoint (`https://coding.dashscope.aliyuncs.com/v1`) that is different from the standard Dashscope endpoint. Make sure to use the correct `baseUrl`. + ### Option2: Third-party API-KEY Use this if you want to connect to third-party providers such as OpenAI, Anthropic, Google, Azure OpenAI, OpenRouter, ModelScope, or a self-hosted endpoint. @@ -67,7 +148,7 @@ The key concept is **Model Providers** (`modelProviders`): Qwen Code supports mu | Google GenAI | `gemini` | `GEMINI_API_KEY`, `GEMINI_MODEL` | Google Gemini | | Google Vertex AI | `vertex-ai` | `GOOGLE_API_KEY`, `GOOGLE_MODEL` | Google Vertex AI | -#### Step 1: Configure `modelProviders` in `~/.qwen/settings.json` +#### Step 1: Configure models and providers in `~/.qwen/settings.json` Define which models are available for each protocol. Each model entry requires at minimum an `id` and an `envKey` (the environment variable name that holds your API key). @@ -75,7 +156,7 @@ Define which models are available for each protocol. Each model entry requires a > > It is recommended to define `modelProviders` in the user-scope `~/.qwen/settings.json` to avoid merge conflicts between project and user settings. -Edit `~/.qwen/settings.json` (create it if it doesn't exist): +Edit `~/.qwen/settings.json` (create it if it doesn't exist). You can mix multiple protocols in a single file — here is a multi-provider example showing just the `modelProviders` section: ```json { @@ -106,7 +187,11 @@ Edit `~/.qwen/settings.json` (create it if it doesn't exist): } ``` -You can mix multiple protocols and models in a single configuration. The `ModelConfig` fields are: +> [!tip] +> +> Don't forget to also set `env`, `security.auth.selectedType`, and `model.name` alongside `modelProviders` — see the [complete example above](#recommended-one-file-setup-via-settingsjson) for reference. + +**`ModelConfig` fields (each entry inside `modelProviders`):** | Field | Required | Description | | ------------------ | -------- | -------------------------------------------------------------------- | @@ -118,9 +203,9 @@ You can mix multiple protocols and models in a single configuration. The `ModelC > [!note] > -> Credentials are **never** stored in `settings.json`. The runtime reads them from the environment variable specified in `envKey`. +> When using the `env` field in `settings.json`, credentials are stored in plain text. For better security, prefer `.env` files or shell `export` — see [Step 2](#step-2-set-environment-variables). -For the full `modelProviders` schema and advanced options like `generationConfig`, `customHeaders`, and `extra_body`, see [Settings Reference → modelProviders](settings.md#modelproviders). +For the full `modelProviders` schema and advanced options like `generationConfig`, `customHeaders`, and `extra_body`, see [Model Providers Reference](model-providers.md). #### Step 2: Set environment variables @@ -165,25 +250,19 @@ If nothing is found, it falls back to your **home directory**: **3. `settings.json` → `env` field (lowest priority)** -You can also define environment variables directly in `~/.qwen/settings.json` under the `env` key. These are loaded as the **lowest-priority fallback** — only applied when a variable is not already set by the system environment or `.env` files. +You can also define API keys directly in `~/.qwen/settings.json` under the `env` key. These are loaded as the **lowest-priority fallback** — only applied when a variable is not already set by the system environment or `.env` files. ```json { "env": { - "DASHSCOPE_API_KEY":"sk-...", + "DASHSCOPE_API_KEY": "sk-...", "OPENAI_API_KEY": "sk-...", - "ANTHROPIC_API_KEY": "sk-ant-...", - "GEMINI_API_KEY": "AIza..." - }, - "modelProviders": { - ... + "ANTHROPIC_API_KEY": "sk-ant-..." } } ``` -> [!note] -> -> This is useful when you want to keep all configuration (providers + credentials) in a single file. However, be mindful that `settings.json` may be shared or synced — prefer `.env` files for sensitive secrets. +This is the approach used in the [one-file setup example](#recommended-one-file-setup-via-settingsjson) above. It's convenient for keeping everything in one place, but be mindful that `settings.json` may be shared or synced — prefer `.env` files for sensitive secrets. **Priority summary:** diff --git a/docs/users/configuration/model-providers.md b/docs/users/configuration/model-providers.md new file mode 100644 index 000000000..2e6265917 --- /dev/null +++ b/docs/users/configuration/model-providers.md @@ -0,0 +1,521 @@ +# Model Providers + +Qwen Code allows you to configure multiple model providers through the `modelProviders` setting in your `settings.json`. This enables you to switch between different AI models and providers using the `/model` command. + +## Overview + +Use `modelProviders` to declare curated model lists per auth type that the `/model` picker can switch between. Keys must be valid auth types (`openai`, `anthropic`, `gemini`, `vertex-ai`, etc.). Each entry requires an `id` and **must include `envKey`**, with optional `name`, `description`, `baseUrl`, and `generationConfig`. Credentials are never persisted in settings; the runtime reads them from `process.env[envKey]`. Qwen OAuth models remain hard-coded and cannot be overridden. + +> [!note] +> Only the `/model` command exposes non-default auth types. Anthropic, Gemini, Vertex AI, etc., must be defined via `modelProviders`. The `/auth` command intentionally lists only the built-in Qwen OAuth and OpenAI flows. + +> [!warning] +> **Duplicate model IDs within the same authType:** Defining multiple models with the same `id` under a single `authType` (e.g., two entries with `"id": "gpt-4o"` in `openai`) is currently not supported. If duplicates exist, **the first occurrence wins** and subsequent duplicates are skipped with a warning. Note that the `id` field is used both as the configuration identifier and as the actual model name sent to the API, so using unique IDs (e.g., `gpt-4o-creative`, `gpt-4o-balanced`) is not a viable workaround. This is a known limitation that we plan to address in a future release. + +## Configuration Examples by Auth Type + +Below are comprehensive configuration examples for different authentication types, showing the available parameters and their combinations. + +### Supported Auth Types + +The `modelProviders` object keys must be valid `authType` values. Currently supported auth types are: + +| Auth Type | Description | +| ------------ | --------------------------------------------------------------------------------------- | +| `openai` | OpenAI-compatible APIs (OpenAI, Azure OpenAI, local inference servers like vLLM/Ollama) | +| `anthropic` | Anthropic Claude API | +| `gemini` | Google Gemini API | +| `vertex-ai` | Google Vertex AI | +| `qwen-oauth` | Qwen OAuth (hard-coded, cannot be overridden in `modelProviders`) | + +> [!warning] +> If an invalid auth type key is used (e.g., a typo like `"openai-custom"`), the configuration will be **silently skipped** and the models will not appear in the `/model` picker. Always use one of the supported auth type values listed above. + +### SDKs Used for API Requests + +Qwen Code uses the following official SDKs to send requests to each provider: + +| Auth Type | SDK Package | +| ---------------------- | ----------------------------------------------------------------------------------------------- | +| `openai` | [`openai`](https://www.npmjs.com/package/openai) - Official OpenAI Node.js SDK | +| `anthropic` | [`@anthropic-ai/sdk`](https://www.npmjs.com/package/@anthropic-ai/sdk) - Official Anthropic SDK | +| `gemini` / `vertex-ai` | [`@google/genai`](https://www.npmjs.com/package/@google/genai) - Official Google GenAI SDK | +| `qwen-oauth` | [`openai`](https://www.npmjs.com/package/openai) with custom provider (DashScope-compatible) | + +This means the `baseUrl` you configure should be compatible with the corresponding SDK's expected API format. For example, when using `openai` auth type, the endpoint must accept OpenAI API format requests. + +### OpenAI-compatible providers (`openai`) + +This auth type supports not only OpenAI's official API but also any OpenAI-compatible endpoint, including aggregated model providers like OpenRouter. + +```json +{ + "modelProviders": { + "openai": [ + { + "id": "gpt-4o", + "name": "GPT-4o", + "envKey": "OPENAI_API_KEY", + "baseUrl": "https://api.openai.com/v1", + "generationConfig": { + "timeout": 60000, + "maxRetries": 3, + "enableCacheControl": true, + "contextWindowSize": 128000, + "customHeaders": { + "X-Client-Request-ID": "req-123" + }, + "extra_body": { + "enable_thinking": true, + "service_tier": "priority" + }, + "samplingParams": { + "temperature": 0.2, + "top_p": 0.8, + "max_tokens": 4096, + "presence_penalty": 0.1, + "frequency_penalty": 0.1 + } + } + }, + { + "id": "gpt-4o-mini", + "name": "GPT-4o Mini", + "envKey": "OPENAI_API_KEY", + "baseUrl": "https://api.openai.com/v1", + "generationConfig": { + "timeout": 30000, + "samplingParams": { + "temperature": 0.5, + "max_tokens": 2048 + } + } + }, + { + "id": "openai/gpt-4o", + "name": "GPT-4o (via OpenRouter)", + "envKey": "OPENROUTER_API_KEY", + "baseUrl": "https://openrouter.ai/api/v1", + "generationConfig": { + "timeout": 120000, + "maxRetries": 3, + "samplingParams": { + "temperature": 0.7 + } + } + } + ] + } +} +``` + +### Anthropic (`anthropic`) + +```json +{ + "modelProviders": { + "anthropic": [ + { + "id": "claude-3-5-sonnet", + "name": "Claude 3.5 Sonnet", + "envKey": "ANTHROPIC_API_KEY", + "baseUrl": "https://api.anthropic.com/v1", + "generationConfig": { + "timeout": 120000, + "maxRetries": 3, + "contextWindowSize": 200000, + "samplingParams": { + "temperature": 0.7, + "max_tokens": 8192, + "top_p": 0.9 + } + } + }, + { + "id": "claude-3-opus", + "name": "Claude 3 Opus", + "envKey": "ANTHROPIC_API_KEY", + "baseUrl": "https://api.anthropic.com/v1", + "generationConfig": { + "timeout": 180000, + "samplingParams": { + "temperature": 0.3, + "max_tokens": 4096 + } + } + } + ] + } +} +``` + +### Google Gemini (`gemini`) + +```json +{ + "modelProviders": { + "gemini": [ + { + "id": "gemini-2.0-flash", + "name": "Gemini 2.0 Flash", + "envKey": "GEMINI_API_KEY", + "baseUrl": "https://generativelanguage.googleapis.com", + "capabilities": { + "vision": true + }, + "generationConfig": { + "timeout": 60000, + "maxRetries": 2, + "contextWindowSize": 1000000, + "schemaCompliance": "auto", + "samplingParams": { + "temperature": 0.4, + "top_p": 0.95, + "max_tokens": 8192, + "top_k": 40 + } + } + } + ] + } +} +``` + +### Google Vertex AI (`vertex-ai`) + +```json +{ + "modelProviders": { + "vertex-ai": [ + { + "id": "gemini-1.5-pro-vertex", + "name": "Gemini 1.5 Pro (Vertex AI)", + "envKey": "GOOGLE_API_KEY", + "baseUrl": "https://generativelanguage.googleapis.com", + "generationConfig": { + "timeout": 90000, + "contextWindowSize": 2000000, + "samplingParams": { + "temperature": 0.2, + "max_tokens": 8192 + } + } + } + ] + } +} +``` + +### Local Self-Hosted Models (via OpenAI-compatible API) + +Most local inference servers (vLLM, Ollama, LM Studio, etc.) provide an OpenAI-compatible API endpoint. Configure them using the `openai` auth type with a local `baseUrl`: + +```json +{ + "modelProviders": { + "openai": [ + { + "id": "qwen2.5-7b", + "name": "Qwen2.5 7B (Ollama)", + "envKey": "OLLAMA_API_KEY", + "baseUrl": "http://localhost:11434/v1", + "generationConfig": { + "timeout": 300000, + "maxRetries": 1, + "contextWindowSize": 32768, + "samplingParams": { + "temperature": 0.7, + "top_p": 0.9, + "max_tokens": 4096 + } + } + }, + { + "id": "llama-3.1-8b", + "name": "Llama 3.1 8B (vLLM)", + "envKey": "VLLM_API_KEY", + "baseUrl": "http://localhost:8000/v1", + "generationConfig": { + "timeout": 120000, + "maxRetries": 2, + "contextWindowSize": 128000, + "samplingParams": { + "temperature": 0.6, + "max_tokens": 8192 + } + } + }, + { + "id": "local-model", + "name": "Local Model (LM Studio)", + "envKey": "LMSTUDIO_API_KEY", + "baseUrl": "http://localhost:1234/v1", + "generationConfig": { + "timeout": 60000, + "samplingParams": { + "temperature": 0.5 + } + } + } + ] + } +} +``` + +For local servers that don't require authentication, you can use any placeholder value for the API key: + +```bash +# For Ollama (no auth required) +export OLLAMA_API_KEY="ollama" + +# For vLLM (if no auth is configured) +export VLLM_API_KEY="not-needed" +``` + +> [!note] +> The `extra_body` parameter is **only supported for OpenAI-compatible providers** (`openai`, `qwen-oauth`). It is ignored for Anthropic, Gemini, and Vertex AI providers. + +## Bailian Coding Plan + +Bailian Coding Plan provides a pre-configured set of Qwen models optimized for coding tasks. This feature is available for users with Bailian API access and offers a simplified setup experience with automatic model configuration updates. + +### Overview + +When you authenticate with a Bailian Coding Plan API key using the `/auth` command, Qwen Code automatically configures the following models: + +| Model ID | Name | Description | +| ---------------------- | -------------------- | -------------------------------------- | +| `qwen3.5-plus` | qwen3.5-plus | Advanced model with thinking enabled | +| `qwen3-coder-plus` | qwen3-coder-plus | Optimized for coding tasks | +| `qwen3-max-2026-01-23` | qwen3-max-2026-01-23 | Latest max model with thinking enabled | + +### Setup + +1. Obtain a Bailian Coding Plan API key: + - **China**: + - **International**: +2. Run the `/auth` command in Qwen Code +3. Select the API-KEY authentication method +4. Select your region (China or Global/International) +5. Enter your API key when prompted + +The models will be automatically configured and added to your `/model` picker. + +### Regions + +Bailian Coding Plan supports two regions: + +| Region | Endpoint | Description | +| -------------------- | ----------------------------------------------- | ----------------------- | +| China | `https://coding.dashscope.aliyuncs.com/v1` | Mainland China endpoint | +| Global/International | `https://coding-intl.dashscope.aliyuncs.com/v1` | International endpoint | + +The region is selected during authentication and stored in `settings.json` under `codingPlan.region`. To switch regions, re-run the `/auth` command and select a different region. + +### API Key Storage + +When you configure Coding Plan through the `/auth` command, the API key is stored using the reserved environment variable name `BAILIAN_CODING_PLAN_API_KEY`. By default, it is stored in the `settings.env` field of your `settings.json` file. + +> [!warning] +> **Security Recommendation**: For better security, it is recommended to move the API key from `settings.json` to a separate `.env` file and load it as an environment variable. For example: +> +> ```bash +> # ~/.qwen/.env +> BAILIAN_CODING_PLAN_API_KEY=your-api-key-here +> ``` +> +> Then ensure this file is added to your `.gitignore` if you're using project-level settings. + +### Automatic Updates + +Coding Plan model configurations are versioned. When Qwen Code detects a newer version of the model template, you will be prompted to update. Accepting the update will: + +- Replace the existing Coding Plan model configurations with the latest versions +- Preserve any custom model configurations you've added manually +- Automatically switch to the first model in the updated configuration + +The update process ensures you always have access to the latest model configurations and features without manual intervention. + +### Manual Configuration (Advanced) + +If you prefer to manually configure Coding Plan models, you can add them to your `settings.json` like any OpenAI-compatible provider: + +```json +{ + "modelProviders": { + "openai": [ + { + "id": "qwen3-coder-plus", + "name": "qwen3-coder-plus", + "description": "Qwen3-Coder via Bailian Coding Plan", + "envKey": "YOUR_CUSTOM_ENV_KEY", + "baseUrl": "https://coding.dashscope.aliyuncs.com/v1" + } + ] + } +} +``` + +> [!note] +> When using manual configuration: + +> - You can use any environment variable name for `envKey` +> - You do not need to configure `codingPlan.*` +> - **Automatic updates will not apply** to manually configured Coding Plan models + +> [!warning] +> If you also use automatic Coding Plan configuration, automatic updates may overwrite your manual configurations if they use the same `envKey` and `baseUrl` as the automatic configuration. To avoid this, ensure your manual configuration uses a different `envKey` if possible. + +## Resolution Layers and Atomicity + +The effective auth/model/credential values are chosen per field using the following precedence (first present wins). You can combine `--auth-type` with `--model` to point directly at a provider entry; these CLI flags run before other layers. + +| Layer (highest → lowest) | authType | model | apiKey | baseUrl | apiKeyEnvKey | proxy | +| -------------------------- | ----------------------------------- | ----------------------------------------------- | --------------------------------------------------- | ---------------------------------------------------- | ---------------------- | --------------------------------- | +| Programmatic overrides | `/auth` | `/auth` input | `/auth` input | `/auth` input | — | — | +| Model provider selection | — | `modelProvider.id` | `env[modelProvider.envKey]` | `modelProvider.baseUrl` | `modelProvider.envKey` | — | +| CLI arguments | `--auth-type` | `--model` | `--openaiApiKey` (or provider-specific equivalents) | `--openaiBaseUrl` (or provider-specific equivalents) | — | — | +| Environment variables | — | Provider-specific mapping (e.g. `OPENAI_MODEL`) | Provider-specific mapping (e.g. `OPENAI_API_KEY`) | Provider-specific mapping (e.g. `OPENAI_BASE_URL`) | — | — | +| Settings (`settings.json`) | `security.auth.selectedType` | `model.name` | `security.auth.apiKey` | `security.auth.baseUrl` | — | — | +| Default / computed | Falls back to `AuthType.QWEN_OAUTH` | Built-in default (OpenAI ⇒ `qwen3-coder-plus`) | — | — | — | `Config.getProxy()` if configured | + +\*When present, CLI auth flags override settings. Otherwise, `security.auth.selectedType` or the implicit default determine the auth type. Qwen OAuth and OpenAI are the only auth types surfaced without extra configuration. + +> [!warning] +> **Deprecation of `security.auth.apiKey` and `security.auth.baseUrl`:** Directly configuring API credentials via `security.auth.apiKey` and `security.auth.baseUrl` in `settings.json` is deprecated. These settings were used in historical versions for credentials entered through the UI, but the credential input flow was removed in version 0.10.1. These fields will be fully removed in a future release. **It is strongly recommended to migrate to `modelProviders`** for all model and credential configurations. Use `envKey` in `modelProviders` to reference environment variables for secure credential management instead of hardcoding credentials in settings files. + +## Generation Config Layering: The Impermeable Provider Layer + +The configuration resolution follows a strict layering model with one crucial rule: **the modelProvider layer is impermeable**. + +### How it works + +1. **When a modelProvider model IS selected** (e.g., via `/model` command choosing a provider-configured model): + - The entire `generationConfig` from the provider is applied **atomically** + - **The provider layer is completely impermeable** — lower layers (CLI, env, settings) do not participate in generationConfig resolution at all + - All fields defined in `modelProviders[].generationConfig` use the provider's values + - All fields **not defined** by the provider are set to `undefined` (not inherited from settings) + - This ensures provider configurations act as a complete, self-contained "sealed package" + +2. **When NO modelProvider model is selected** (e.g., using `--model` with a raw model ID, or using CLI/env/settings directly): + - The resolution falls through to lower layers + - Fields are populated from CLI → env → settings → defaults + - This creates a **Runtime Model** (see next section) + +### Per-field precedence for `generationConfig` + +| Priority | Source | Behavior | +| -------- | --------------------------------------------- | -------------------------------------------------------------------------------------------------------- | +| 1 | Programmatic overrides | Runtime `/model`, `/auth` changes | +| 2 | `modelProviders[authType][].generationConfig` | **Impermeable layer** - completely replaces all generationConfig fields; lower layers do not participate | +| 3 | `settings.model.generationConfig` | Only used for **Runtime Models** (when no provider model is selected) | +| 4 | Content-generator defaults | Provider-specific defaults (e.g., OpenAI vs Gemini) - only for Runtime Models | + +### Atomic field treatment + +The following fields are treated as atomic objects - provider values completely replace the entire object, no merging occurs: + +- `samplingParams` - Temperature, top_p, max_tokens, etc. +- `customHeaders` - Custom HTTP headers +- `extra_body` - Extra request body parameters + +### Example + +```json +// User settings (~/.qwen/settings.json) +{ + "model": { + "generationConfig": { + "timeout": 30000, + "samplingParams": { "temperature": 0.5, "max_tokens": 1000 } + } + } +} + +// modelProviders configuration +{ + "modelProviders": { + "openai": [{ + "id": "gpt-4o", + "envKey": "OPENAI_API_KEY", + "generationConfig": { + "timeout": 60000, + "samplingParams": { "temperature": 0.2 } + } + }] + } +} +``` + +When `gpt-4o` is selected from modelProviders: + +- `timeout` = 60000 (from provider, overrides settings) +- `samplingParams.temperature` = 0.2 (from provider, completely replaces settings object) +- `samplingParams.max_tokens` = **undefined** (not defined in provider, and provider layer does not inherit from settings — fields are explicitly set to undefined if not provided) + +When using a raw model via `--model gpt-4` (not from modelProviders, creates a Runtime Model): + +- `timeout` = 30000 (from settings) +- `samplingParams.temperature` = 0.5 (from settings) +- `samplingParams.max_tokens` = 1000 (from settings) + +The merge strategy for `modelProviders` itself is REPLACE: the entire `modelProviders` from project settings will override the corresponding section in user settings, rather than merging the two. + +## Provider Models vs Runtime Models + +Qwen Code distinguishes between two types of model configurations: + +### Provider Model + +- Defined in `modelProviders` configuration +- Has a complete, atomic configuration package +- When selected, its configuration is applied as an impermeable layer +- Appears in `/model` command list with full metadata (name, description, capabilities) +- Recommended for multi-model workflows and team consistency + +### Runtime Model + +- Created dynamically when using raw model IDs via CLI (`--model`), environment variables, or settings +- Not defined in `modelProviders` +- Configuration is built by "projecting" through resolution layers (CLI → env → settings → defaults) +- Automatically captured as a **RuntimeModelSnapshot** when a complete configuration is detected +- Allows reuse without re-entering credentials + +### RuntimeModelSnapshot lifecycle + +When you configure a model without using `modelProviders`, Qwen Code automatically creates a RuntimeModelSnapshot to preserve your configuration: + +```bash +# This creates a RuntimeModelSnapshot with ID: $runtime|openai|my-custom-model +qwen --auth-type openai --model my-custom-model --openaiApiKey $KEY --openaiBaseUrl https://api.example.com/v1 +``` + +The snapshot: + +- Captures model ID, API key, base URL, and generation config +- Persists across sessions (stored in memory during runtime) +- Appears in the `/model` command list as a runtime option +- Can be switched to using `/model $runtime|openai|my-custom-model` + +### Key differences + +| Aspect | Provider Model | Runtime Model | +| ----------------------- | --------------------------------- | ------------------------------------------ | +| Configuration source | `modelProviders` in settings | CLI, env, settings layers | +| Configuration atomicity | Complete, impermeable package | Layered, each field resolved independently | +| Reusability | Always available in `/model` list | Captured as snapshot, appears if complete | +| Team sharing | Yes (via committed settings) | No (user-local) | +| Credential storage | Reference via `envKey` only | May capture actual key in snapshot | + +### When to use each + +- **Use Provider Models** when: You have standard models shared across a team, need consistent configurations, or want to prevent accidental overrides +- **Use Runtime Models** when: Quickly testing a new model, using temporary credentials, or working with ad-hoc endpoints + +## Selection Persistence and Recommendations + +> [!important] +> Define `modelProviders` in the user-scope `~/.qwen/settings.json` whenever possible and avoid persisting credential overrides in any scope. Keeping the provider catalog in user settings prevents merge/override conflicts between project and user scopes and ensures `/auth` and `/model` updates always write back to a consistent scope. + +- `/model` and `/auth` persist `model.name` (where applicable) and `security.auth.selectedType` to the closest writable scope that already defines `modelProviders`; otherwise they fall back to the user scope. This keeps workspace/user files in sync with the active provider catalog. +- Without `modelProviders`, the resolver mixes CLI/env/settings layers, creating Runtime Models. This is fine for single-provider setups but cumbersome when frequently switching. Define provider catalogs whenever multi-model workflows are common so that switches stay atomic, source-attributed, and debuggable. diff --git a/docs/users/configuration/settings.md b/docs/users/configuration/settings.md index 0094f411d..82db2b319 100644 --- a/docs/users/configuration/settings.md +++ b/docs/users/configuration/settings.md @@ -148,8 +148,7 @@ Settings are organized into categories. All settings should be placed within the "contextWindowSize": 128000, "enableCacheControl": true, "customHeaders": { - "X-Request-ID": "req-123", - "X-User-ID": "user-456" + "X-Client-Request-ID": "req-123" }, "extra_body": { "enable_thinking": true @@ -180,102 +179,6 @@ The `extra_body` field allows you to add custom parameters to the request body s - `"./custom-logs"` - Logs to `./custom-logs` relative to current directory - `"/tmp/openai-logs"` - Logs to absolute path `/tmp/openai-logs` -#### modelProviders - -Use `modelProviders` to declare curated model lists per auth type that the `/model` picker can switch between. Keys must be valid auth types (`openai`, `anthropic`, `gemini`, `vertex-ai`, etc.). Each entry requires an `id` and **must include `envKey`**, with optional `name`, `description`, `baseUrl`, and `generationConfig`. Credentials are never persisted in settings; the runtime reads them from `process.env[envKey]`. Qwen OAuth models remain hard-coded and cannot be overridden. - -##### Example - -```json -{ - "modelProviders": { - "openai": [ - { - "id": "gpt-4o", - "name": "GPT-4o", - "envKey": "OPENAI_API_KEY", - "baseUrl": "https://api.openai.com/v1", - "generationConfig": { - "timeout": 60000, - "maxRetries": 3, - "customHeaders": { - "X-Model-Version": "v1.0", - "X-Request-Priority": "high" - }, - "extra_body": { - "enable_thinking": true - }, - "samplingParams": { "temperature": 0.2 } - } - } - ], - "anthropic": [ - { - "id": "claude-3-5-sonnet", - "envKey": "ANTHROPIC_API_KEY", - "baseUrl": "https://api.anthropic.com/v1" - } - ], - "gemini": [ - { - "id": "gemini-2.0-flash", - "name": "Gemini 2.0 Flash", - "envKey": "GEMINI_API_KEY", - "baseUrl": "https://generativelanguage.googleapis.com" - } - ], - "vertex-ai": [ - { - "id": "gemini-1.5-pro-vertex", - "envKey": "GOOGLE_API_KEY", - "baseUrl": "https://generativelanguage.googleapis.com" - } - ] - } -} -``` - -> [!note] -> Only the `/model` command exposes non-default auth types. Anthropic, Gemini, Vertex AI, etc., must be defined via `modelProviders`. The `/auth` command intentionally lists only the built-in Qwen OAuth and OpenAI flows. - -##### Resolution layers and atomicity - -The effective auth/model/credential values are chosen per field using the following precedence (first present wins). You can combine `--auth-type` with `--model` to point directly at a provider entry; these CLI flags run before other layers. - -| Layer (highest → lowest) | authType | model | apiKey | baseUrl | apiKeyEnvKey | proxy | -| -------------------------- | ----------------------------------- | ----------------------------------------------- | --------------------------------------------------- | ---------------------------------------------------- | ---------------------- | --------------------------------- | -| Programmatic overrides | `/auth ` | `/auth` input | `/auth` input | `/auth` input | — | — | -| Model provider selection | — | `modelProvider.id` | `env[modelProvider.envKey]` | `modelProvider.baseUrl` | `modelProvider.envKey` | — | -| CLI arguments | `--auth-type` | `--model` | `--openaiApiKey` (or provider-specific equivalents) | `--openaiBaseUrl` (or provider-specific equivalents) | — | — | -| Environment variables | — | Provider-specific mapping (e.g. `OPENAI_MODEL`) | Provider-specific mapping (e.g. `OPENAI_API_KEY`) | Provider-specific mapping (e.g. `OPENAI_BASE_URL`) | — | — | -| Settings (`settings.json`) | `security.auth.selectedType` | `model.name` | `security.auth.apiKey` | `security.auth.baseUrl` | — | — | -| Default / computed | Falls back to `AuthType.QWEN_OAUTH` | Built-in default (OpenAI ⇒ `qwen3-coder-plus`) | — | — | — | `Config.getProxy()` if configured | - -\*When present, CLI auth flags override settings. Otherwise, `security.auth.selectedType` or the implicit default determine the auth type. Qwen OAuth and OpenAI are the only auth types surfaced without extra configuration. - -Model-provider sourced values are applied atomically: once a provider model is active, every field it defines is protected from lower layers until you manually clear credentials via `/auth`. The final `generationConfig` is the projection across all layers—lower layers only fill gaps left by higher ones, and the provider layer remains impenetrable. - -The merge strategy for `modelProviders` is REPLACE: the entire `modelProviders` from project settings will override the corresponding section in user settings, rather than merging the two. - -##### Generation config layering - -Per-field precedence for `generationConfig`: - -1. Programmatic overrides (e.g. runtime `/model`, `/auth` changes) -2. `modelProviders[authType][].generationConfig` -3. `settings.model.generationConfig` -4. Content-generator defaults (`getDefaultGenerationConfig` for OpenAI, `getParameterValue` for Gemini, etc.) - -`samplingParams`, `customHeaders`, and `extra_body` are all treated atomically; provider values replace the entire object. If `modelProviders[].generationConfig` defines these fields, they are used directly; otherwise, values from `model.generationConfig` are used. No merging occurs between provider and global configuration levels. Defaults from the content generator apply last so each provider retains its tuned baseline. - -##### Selection persistence and recommendations - -> [!important] -> Define `modelProviders` in the user-scope `~/.qwen/settings.json` whenever possible and avoid persisting credential overrides in any scope. Keeping the provider catalog in user settings prevents merge/override conflicts between project and user scopes and ensures `/auth` and `/model` updates always write back to a consistent scope. - -- `/model` and `/auth` persist `model.name` (where applicable) and `security.auth.selectedType` to the closest writable scope that already defines `modelProviders`; otherwise they fall back to the user scope. This keeps workspace/user files in sync with the active provider catalog. -- Without `modelProviders`, the resolver mixes CLI/env/settings layers, which is fine for single-provider setups but cumbersome when frequently switching. Define provider catalogs whenever multi-model workflows are common so that switches stay atomic, source-attributed, and debuggable. - #### context | Setting | Type | Description | Default | diff --git a/docs/users/reference/keyboard-shortcuts.md b/docs/users/reference/keyboard-shortcuts.md index fc2f86286..f0cbd7b16 100644 --- a/docs/users/reference/keyboard-shortcuts.md +++ b/docs/users/reference/keyboard-shortcuts.md @@ -42,7 +42,7 @@ This document lists the available keyboard shortcuts in Qwen Code. | `Ctrl+R` | Reverse search through input/shell history. | | `Ctrl+Right Arrow` / `Meta+Right Arrow` / `Meta+F` | Move the cursor one word to the right. | | `Ctrl+U` | Delete from the cursor to the beginning of the line. | -| `Ctrl+V` | Paste clipboard content. If the clipboard contains an image, it will be saved and a reference to it will be inserted in the prompt. | +| `Ctrl+V` (Windows: `Alt+V`) | Paste clipboard content. If the clipboard contains an image, it will be saved and a reference to it will be inserted in the prompt. | | `Ctrl+W` / `Meta+Backspace` / `Ctrl+Backspace` | Delete the word to the left of the cursor. | | `Ctrl+X` / `Meta+Enter` | Open the current input in an external editor. | diff --git a/esbuild.config.js b/esbuild.config.js index 12ab39d58..2b532b44e 100644 --- a/esbuild.config.js +++ b/esbuild.config.js @@ -33,6 +33,13 @@ const external = [ '@lydell/node-pty-linux-x64', '@lydell/node-pty-win32-arm64', '@lydell/node-pty-win32-x64', + '@teddyzhu/clipboard', + '@teddyzhu/clipboard-darwin-arm64', + '@teddyzhu/clipboard-darwin-x64', + '@teddyzhu/clipboard-linux-x64-gnu', + '@teddyzhu/clipboard-linux-arm64-gnu', + '@teddyzhu/clipboard-win32-x64-msvc', + '@teddyzhu/clipboard-win32-arm64-msvc', ]; esbuild diff --git a/integration-tests/acp-integration.test.ts b/integration-tests/acp-integration.test.ts index 35397da26..93389d605 100644 --- a/integration-tests/acp-integration.test.ts +++ b/integration-tests/acp-integration.test.ts @@ -648,6 +648,101 @@ function setupAcpTest( } }); + it('blocks write tools in plan mode (issue #1806)', async () => { + const rig = new TestRig(); + rig.setup('acp plan mode enforcement'); + + const toolCallEvents: Array<{ + toolName: string; + status: string; + error?: string; + }> = []; + + const { sendRequest, cleanup, stderr, sessionUpdates } = setupAcpTest(rig, { + permissionHandler: () => ({ optionId: 'proceed_once' }), + }); + + try { + await sendRequest('initialize', { + protocolVersion: 1, + clientCapabilities: { fs: { readTextFile: true, writeTextFile: true } }, + }); + await sendRequest('authenticate', { methodId: 'openai' }); + + const newSession = (await sendRequest('session/new', { + cwd: rig.testDir!, + mcpServers: [], + })) as { sessionId: string }; + + // Set mode to 'plan' + const setModeResult = (await sendRequest('session/set_mode', { + sessionId: newSession.sessionId, + modeId: 'plan', + })) as { modeId: string }; + expect(setModeResult.modeId).toBe('plan'); + + // Try to create a file - this should be blocked by plan mode + const promptResult = await sendRequest('session/prompt', { + sessionId: newSession.sessionId, + prompt: [ + { + type: 'text', + text: 'Create a file called test.txt with content "Hello World"', + }, + ], + }); + expect(promptResult).toBeDefined(); + + // Give time for tool calls to be processed + await delay(2000); + + // Collect tool call events from session updates + sessionUpdates.forEach((update) => { + if (update.update?.sessionUpdate === 'tool_call_update') { + const toolUpdate = update.update as { + sessionUpdate: string; + toolName?: string; + status?: string; + error?: { message?: string }; + }; + if (toolUpdate.toolName) { + toolCallEvents.push({ + toolName: toolUpdate.toolName, + status: toolUpdate.status ?? 'unknown', + error: toolUpdate.error?.message, + }); + } + } + }); + + // Verify that if write_file was attempted, it was blocked + const writeFileEvents = toolCallEvents.filter( + (e) => e.toolName === 'write_file', + ); + + // If the LLM tried to call write_file in plan mode, it should have been blocked + if (writeFileEvents.length > 0) { + const blockedEvent = writeFileEvents.find( + (e) => e.status === 'error' && e.error?.includes('Plan mode'), + ); + expect(blockedEvent).toBeDefined(); + expect(blockedEvent?.error).toContain('Plan mode is active'); + } + + // Verify the file was NOT created + const fs = await import('fs'); + const path = await import('path'); + const testFilePath = path.join(rig.testDir!, 'test.txt'); + const fileExists = fs.existsSync(testFilePath); + expect(fileExists).toBe(false); + } catch (e) { + if (stderr.length) console.error('Agent stderr:', stderr.join('')); + throw e; + } finally { + await cleanup(); + } + }); + it('receives usage metadata in agent_message_chunk updates', async () => { const rig = new TestRig(); rig.setup('acp usage metadata'); diff --git a/integration-tests/concurrent-runner/config.example.json b/integration-tests/concurrent-runner/config.example.json index 7042e7eb6..f1937fe07 100644 --- a/integration-tests/concurrent-runner/config.example.json +++ b/integration-tests/concurrent-runner/config.example.json @@ -31,5 +31,9 @@ ] } ], - "models": ["claude-3-5-sonnet-20241022", "qwen3-coder-plus"] + "models": [ + "qwen3-coder-plus", + { "name": "glm-4.7", "auth_type": "anthropic" }, + { "name": "claude-4-5-sonnet-20260219", "auth_type": "anthropic" } + ] } diff --git a/integration-tests/concurrent-runner/runner.py b/integration-tests/concurrent-runner/runner.py index c27a221e0..6eb2b8e0f 100644 --- a/integration-tests/concurrent-runner/runner.py +++ b/integration-tests/concurrent-runner/runner.py @@ -50,11 +50,18 @@ class Task: prompts: List[str] +@dataclass +class ModelSpec: + """One model to run: name and optional auth_type (e.g. anthropic).""" + name: str + auth_type: Optional[str] = None + + @dataclass class RunConfig: """Configuration for the concurrent execution.""" tasks: List[Task] - models: List[str] + models: List[ModelSpec] # name + optional auth_type per model concurrency: int = 4 yolo: bool = True source_repo: Path = field(default_factory=lambda: Path.cwd()) @@ -84,6 +91,7 @@ class RunRecord: task_name: str model: str status: RunStatus + auth_type: Optional[str] = None # e.g. "anthropic" for qwen --auth-type worktree_path: Optional[str] = None output_dir: Optional[str] = None logs_dir: Optional[str] = None @@ -104,6 +112,7 @@ class RunRecord: "task_name": self.task_name, "model": self.model, "status": self.status.value, + "auth_type": self.auth_type, "worktree_path": self.worktree_path, "output_dir": self.output_dir, "logs_dir": self.logs_dir, @@ -136,6 +145,7 @@ class RunRecord: task_name=data["task_name"], model=data["model"], status=RunStatus(data["status"]), + auth_type=data.get("auth_type"), worktree_path=data.get("worktree_path"), output_dir=data.get("output_dir"), logs_dir=data.get("logs_dir"), @@ -806,6 +816,10 @@ class QwenRunner: # Add model cmd.extend(["--model", run.model]) + # Add auth-type when model uses non-OpenAI protocol (e.g. anthropic for glm-4.7) + if run.auth_type: + cmd.extend(["--auth-type", run.auth_type]) + # Add yolo if enabled if self.config.yolo: cmd.append("--yolo") @@ -829,27 +843,41 @@ def generate_run_matrix(config: RunConfig) -> List[RunRecord]: runs = [] for task in config.tasks: for model in config.models: - run_id = str(uuid.uuid4())[:8] runs.append(RunRecord( - run_id=run_id, + run_id=str(uuid.uuid4())[:8], task_id=task.id, task_name=task.name, - model=model, + model=model.name, status=RunStatus.QUEUED, + auth_type=model.auth_type, )) return runs +def _parse_models(data_models: List[Any]) -> List[ModelSpec]: + """Parse models: string or {name, auth_type/authType}; returns list of ModelSpec.""" + specs: List[ModelSpec] = [] + for item in data_models or []: + if isinstance(item, str): + name, auth = item, None + elif isinstance(item, dict) and item.get("name"): + name = item["name"] + auth = item.get("auth_type") or item.get("authType") + else: + continue + specs.append(ModelSpec(name=name, auth_type=auth)) + return specs + + def load_config(config_path: Path) -> RunConfig: """Load configuration from JSON file.""" with open(config_path, 'r') as f: data = json.load(f) - tasks = [Task(**t) for t in data.get("tasks", [])] - + models = _parse_models(data.get("models", [])) return RunConfig( tasks=tasks, - models=data.get("models", []), + models=models, concurrency=data.get("concurrency", 4), yolo=data.get("yolo", True), source_repo=Path(data.get("source_repo", ".")).resolve(), diff --git a/integration-tests/sdk-typescript/session-id.test.ts b/integration-tests/sdk-typescript/session-id.test.ts index 6b9136503..7a2ab435d 100644 --- a/integration-tests/sdk-typescript/session-id.test.ts +++ b/integration-tests/sdk-typescript/session-id.test.ts @@ -377,8 +377,8 @@ describe('Session ID Support (E2E)', () => { describe('Session ID Duplicate Detection', () => { it('should reject duplicate sessionId with error', async () => { - // Valid UUID v4 - const customSessionId = 'dddddddd-eeee-4fff-aaaa-bbbbbbbbbbbb'; + // Generate a unique UUID for this test + const customSessionId = crypto.randomUUID(); // First query: create a session with the custom session ID const q1 = query({ @@ -387,7 +387,9 @@ describe('Session ID Support (E2E)', () => { ...SHARED_TEST_OPTIONS, cwd: testDir, sessionId: customSessionId, - debug: false, + env: { + SANDBOX_SET_UID_GID: 'true', + }, }, }); @@ -409,7 +411,9 @@ describe('Session ID Support (E2E)', () => { ...SHARED_TEST_OPTIONS, cwd: testDir, sessionId: customSessionId, - debug: false, + env: { + SANDBOX_SET_UID_GID: 'true', + }, }, }); @@ -426,8 +430,8 @@ describe('Session ID Support (E2E)', () => { }); it('should throw error when CLI exits with non-zero code', async () => { - // Valid UUID v4 - const customSessionId = 'eeeeeeee-ffff-4aaa-bbbb-cccccccccccc'; + // Generate a unique UUID for this test + const customSessionId = crypto.randomUUID(); // First query: create a session and properly close it after completion const q1 = query({ @@ -436,7 +440,9 @@ describe('Session ID Support (E2E)', () => { ...SHARED_TEST_OPTIONS, cwd: testDir, sessionId: customSessionId, - debug: false, + env: { + SANDBOX_SET_UID_GID: 'true', + }, }, }); @@ -456,7 +462,9 @@ describe('Session ID Support (E2E)', () => { ...SHARED_TEST_OPTIONS, cwd: testDir, sessionId: customSessionId, - debug: false, + env: { + SANDBOX_SET_UID_GID: 'true', + }, }, }); diff --git a/package-lock.json b/package-lock.json index 9a49d849e..5a2359a5d 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "@qwen-code/qwen-code", - "version": "0.10.1", + "version": "0.10.5", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@qwen-code/qwen-code", - "version": "0.10.1", + "version": "0.10.5", "workspaces": [ "packages/*" ], @@ -3834,6 +3834,119 @@ "node": ">=6" } }, + "node_modules/@teddyzhu/clipboard": { + "version": "0.0.5", + "resolved": "https://registry.npmjs.org/@teddyzhu/clipboard/-/clipboard-0.0.5.tgz", + "integrity": "sha512-XA6MG7nLPZzj51agCwDYaVnVVrt0ByJ3G9rl3ar6N4GETAjUKKup6u76SLp2C5yHRWYV9hwMYDn04OGLar0MVg==", + "license": "MIT", + "engines": { + "node": ">= 10.16.0 < 11 || >= 11.8.0 < 12 || >= 12.0.0" + }, + "optionalDependencies": { + "@teddyzhu/clipboard-darwin-arm64": "0.0.5", + "@teddyzhu/clipboard-darwin-x64": "0.0.5", + "@teddyzhu/clipboard-linux-arm64-gnu": "0.0.5", + "@teddyzhu/clipboard-linux-x64-gnu": "0.0.5", + "@teddyzhu/clipboard-win32-arm64-msvc": "0.0.5", + "@teddyzhu/clipboard-win32-x64-msvc": "0.0.5" + } + }, + "node_modules/@teddyzhu/clipboard-darwin-arm64": { + "version": "0.0.5", + "resolved": "https://registry.npmjs.org/@teddyzhu/clipboard-darwin-arm64/-/clipboard-darwin-arm64-0.0.5.tgz", + "integrity": "sha512-FB3yykRAcw0VLmSjIGFddgew2t20UnLp80NZvi5e/lbsy/3mruHibMHkxHWqzCncuZsHdRsRXS/FmR/ggepW9A==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 10.16.0 < 11 || >= 11.8.0 < 12 || >= 12.0.0" + } + }, + "node_modules/@teddyzhu/clipboard-darwin-x64": { + "version": "0.0.5", + "resolved": "https://registry.npmjs.org/@teddyzhu/clipboard-darwin-x64/-/clipboard-darwin-x64-0.0.5.tgz", + "integrity": "sha512-tiDazMpLf2dS7BZUif3da3DLJima8E/CnexB3CNgjQf12CFJ+D1cPcj/CgfvMYZgFQSsYyACpQNfXn4hmVbymA==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 10.16.0 < 11 || >= 11.8.0 < 12 || >= 12.0.0" + } + }, + "node_modules/@teddyzhu/clipboard-linux-arm64-gnu": { + "version": "0.0.5", + "resolved": "https://registry.npmjs.org/@teddyzhu/clipboard-linux-arm64-gnu/-/clipboard-linux-arm64-gnu-0.0.5.tgz", + "integrity": "sha512-qcokM+BaXn4iG4o4nYGHdfC04pr54S2F7x2o5osFhG3hMVYHZLR/8NKcYDKELnebpH612nW2bNRoWWy14lM45g==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10.16.0 < 11 || >= 11.8.0 < 12 || >= 12.0.0" + } + }, + "node_modules/@teddyzhu/clipboard-linux-x64-gnu": { + "version": "0.0.5", + "resolved": "https://registry.npmjs.org/@teddyzhu/clipboard-linux-x64-gnu/-/clipboard-linux-x64-gnu-0.0.5.tgz", + "integrity": "sha512-Ogh4zYM9s537WJszSvKrPAoKQZ2grnY7Xy6szyJp2+84uQKWNbvZkATODAsRUn48zr9gqL3PZeUqkIBaz8sCpQ==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10.16.0 < 11 || >= 11.8.0 < 12 || >= 12.0.0" + } + }, + "node_modules/@teddyzhu/clipboard-win32-arm64-msvc": { + "version": "0.0.5", + "resolved": "https://registry.npmjs.org/@teddyzhu/clipboard-win32-arm64-msvc/-/clipboard-win32-arm64-msvc-0.0.5.tgz", + "integrity": "sha512-TuU+7e8qYc0T++sIArHTmqr+nfqiTfJ6gdrb1e8yDJb6MM3EFxCd2VonTqLQL1YpUdfcH+/rdMarG2rvCwvEhQ==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10.16.0 < 11 || >= 11.8.0 < 12 || >= 12.0.0" + } + }, + "node_modules/@teddyzhu/clipboard-win32-x64-msvc": { + "version": "0.0.5", + "resolved": "https://registry.npmjs.org/@teddyzhu/clipboard-win32-x64-msvc/-/clipboard-win32-x64-msvc-0.0.5.tgz", + "integrity": "sha512-f1Br5bI+INNDifjkOI1woZsIxsoW0rRej/4kaaJvZcMxxkSG9TMT2LYOjTF2g+DtXw32lsGvWICN6c3JiHeG7Q==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10.16.0 < 11 || >= 11.8.0 < 12 || >= 12.0.0" + } + }, "node_modules/@testing-library/dom": { "version": "10.4.1", "resolved": "https://registry.npmjs.org/@testing-library/dom/-/dom-10.4.1.tgz", @@ -18655,12 +18768,13 @@ }, "packages/cli": { "name": "@qwen-code/qwen-code", - "version": "0.10.1", + "version": "0.10.5", "dependencies": { "@google/genai": "1.30.0", "@iarna/toml": "^2.2.5", "@modelcontextprotocol/sdk": "^1.25.1", "@qwen-code/qwen-code-core": "file:../core", + "@teddyzhu/clipboard": "^0.0.5", "@types/update-notifier": "^6.0.8", "ansi-regex": "^6.2.2", "command-exists": "^1.2.9", @@ -19274,7 +19388,7 @@ }, "packages/core": { "name": "@qwen-code/qwen-code-core", - "version": "0.10.1", + "version": "0.10.5", "hasInstallScript": true, "dependencies": { "@anthropic-ai/sdk": "^0.36.1", @@ -22754,7 +22868,7 @@ }, "packages/test-utils": { "name": "@qwen-code/qwen-code-test-utils", - "version": "0.10.1", + "version": "0.10.5", "dev": true, "license": "Apache-2.0", "devDependencies": { @@ -22766,7 +22880,7 @@ }, "packages/vscode-ide-companion": { "name": "qwen-code-vscode-ide-companion", - "version": "0.10.1", + "version": "0.10.5", "license": "LICENSE", "dependencies": { "@modelcontextprotocol/sdk": "^1.25.1", @@ -23013,7 +23127,7 @@ }, "packages/webui": { "name": "@qwen-code/webui", - "version": "0.10.1", + "version": "0.10.5", "license": "MIT", "dependencies": { "markdown-it": "^14.1.0" diff --git a/package.json b/package.json index 1605e1aeb..f6b3fa51c 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@qwen-code/qwen-code", - "version": "0.10.1", + "version": "0.10.5", "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.10.1" + "sandboxImageUri": "ghcr.io/qwenlm/qwen-code:0.10.5" }, "scripts": { "start": "cross-env node scripts/start.js", diff --git a/packages/cli/package.json b/packages/cli/package.json index 7ec5da972..153a51376 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -1,6 +1,6 @@ { "name": "@qwen-code/qwen-code", - "version": "0.10.1", + "version": "0.10.5", "description": "Qwen Code", "repository": { "type": "git", @@ -34,7 +34,7 @@ "dist" ], "config": { - "sandboxImageUri": "ghcr.io/qwenlm/qwen-code:0.10.1" + "sandboxImageUri": "ghcr.io/qwenlm/qwen-code:0.10.5" }, "dependencies": { "@google/genai": "1.30.0", @@ -81,12 +81,12 @@ "@types/diff": "^7.0.2", "@types/dotenv": "^6.1.1", "@types/node": "^20.11.24", + "@types/prompts": "^2.4.9", "@types/react": "^19.1.8", "@types/react-dom": "^19.1.6", "@types/semver": "^7.7.0", "@types/shell-quote": "^1.7.5", "@types/yargs": "^17.0.32", - "@types/prompts": "^2.4.9", "archiver": "^7.0.1", "ink-testing-library": "^4.0.0", "jsdom": "^26.1.0", @@ -95,6 +95,15 @@ "typescript": "^5.3.3", "vitest": "^3.1.1" }, + "optionalDependencies": { + "@teddyzhu/clipboard": "^0.0.5", + "@teddyzhu/clipboard-darwin-arm64": "0.0.5", + "@teddyzhu/clipboard-darwin-x64": "0.0.5", + "@teddyzhu/clipboard-linux-x64-gnu": "0.0.5", + "@teddyzhu/clipboard-linux-arm64-gnu": "0.0.5", + "@teddyzhu/clipboard-win32-x64-msvc": "0.0.5", + "@teddyzhu/clipboard-win32-arm64-msvc": "0.0.5" + }, "engines": { "node": ">=20" } diff --git a/packages/cli/src/acp-integration/service/filesystem.ts b/packages/cli/src/acp-integration/service/filesystem.ts index 17a0cdbcf..2afae0457 100644 --- a/packages/cli/src/acp-integration/service/filesystem.ts +++ b/packages/cli/src/acp-integration/service/filesystem.ts @@ -84,7 +84,8 @@ export class AcpFileSystemService implements FileSystemService { limit: 1, }); // Check if content starts with BOM character (U+FEFF) - return response.content.charCodeAt(0) === 0xfeff; + // Use codePointAt for better Unicode support and check content length first + return response.content.length > 0 && response.content.codePointAt(0) === 0xfeff; } catch { // Fall through to fallback if ACP read fails } diff --git a/packages/cli/src/acp-integration/session/Session.ts b/packages/cli/src/acp-integration/session/Session.ts index d7a5e7395..702f66a07 100644 --- a/packages/cli/src/acp-integration/session/Session.ts +++ b/packages/cli/src/acp-integration/session/Session.ts @@ -516,6 +516,18 @@ export class Session implements SessionContext { ? await invocation.shouldConfirmExecute(abortSignal) : false; + // Check for plan mode enforcement - block non-read-only tools + const isPlanMode = this.config.getApprovalMode() === ApprovalMode.PLAN; + if (isPlanMode && !isExitPlanModeTool && confirmationDetails) { + // In plan mode, block any tool that requires confirmation (write operations) + return errorResponse( + new Error( + `Plan mode is active. The tool "${fc.name}" cannot be executed because it modifies the system. ` + + 'Please use the exit_plan_mode tool to present your plan and exit plan mode before making changes.', + ), + ); + } + if (confirmationDetails) { const content: acp.ToolCallContent[] = []; diff --git a/packages/cli/src/config/keyBindings.ts b/packages/cli/src/config/keyBindings.ts index 8737866ea..226727c5b 100644 --- a/packages/cli/src/config/keyBindings.ts +++ b/packages/cli/src/config/keyBindings.ts @@ -78,6 +78,7 @@ export interface KeyBinding { command?: boolean; /** Paste operation requirement: true=must be paste, false=must not be paste, undefined=ignore */ paste?: boolean; + meta?: boolean; } /** @@ -152,7 +153,16 @@ export const defaultKeyBindings: KeyBindingConfig = { { key: 'x', ctrl: true }, { sequence: '\x18', ctrl: true }, ], - [Command.PASTE_CLIPBOARD_IMAGE]: [{ key: 'v', ctrl: true }], + [Command.PASTE_CLIPBOARD_IMAGE]: + process.platform === 'win32' + ? [ + { key: 'v', command: true }, + { key: 'v', meta: true }, + ] + : [ + { key: 'v', ctrl: true }, + { key: 'v', command: true }, + ], // App level bindings [Command.TOGGLE_TOOL_DESCRIPTIONS]: [{ key: 't', ctrl: true }], diff --git a/packages/cli/src/constants/codingPlan.ts b/packages/cli/src/constants/codingPlan.ts index e55aeb93d..72e7fc1b0 100644 --- a/packages/cli/src/constants/codingPlan.ts +++ b/packages/cli/src/constants/codingPlan.ts @@ -7,6 +7,14 @@ import { createHash } from 'node:crypto'; import type { ProviderModelConfig as ModelConfig } from '@qwen-code/qwen-code-core'; +/** + * Coding plan regions + */ +export enum CodingPlanRegion { + CHINA = 'china', + GLOBAL = 'global', +} + /** * Coding plan template - array of model configurations * When user provides an api-key, these configs will be cloned with envKey pointing to the stored api-key @@ -14,48 +22,282 @@ import type { ProviderModelConfig as ModelConfig } from '@qwen-code/qwen-code-co export type CodingPlanTemplate = ModelConfig[]; /** - * Environment variable key for storing the coding plan API key + * Environment variable key for storing the coding plan API key. + * Unified key for both regions since they are mutually exclusive. */ export const CODING_PLAN_ENV_KEY = 'BAILIAN_CODING_PLAN_API_KEY'; -/** - * CODING_PLAN_MODELS defines the model configurations for coding-plan mode. - */ -export const CODING_PLAN_MODELS: CodingPlanTemplate = [ - { - id: 'qwen3-coder-plus', - name: 'qwen3-coder-plus', - baseUrl: 'https://coding.dashscope.aliyuncs.com/v1', - description: 'qwen3-coder-plus model from Bailian Coding Plan', - envKey: CODING_PLAN_ENV_KEY, - }, - { - id: 'qwen3-max-2026-01-23', - name: 'qwen3-max-2026-01-23', - description: - 'qwen3-max model with thinking enabled from Bailian Coding Plan', - baseUrl: 'https://coding.dashscope.aliyuncs.com/v1', - envKey: CODING_PLAN_ENV_KEY, - generationConfig: { - extra_body: { - enable_thinking: true, - }, - }, - }, -]; - /** * Computes the version hash for the coding plan template. * Uses SHA256 of the JSON-serialized template for deterministic versioning. + * @param template - The template to compute version for * @returns Hexadecimal string representing the template version */ -export function computeCodingPlanVersion(): string { - const templateString = JSON.stringify(CODING_PLAN_MODELS); +export function computeCodingPlanVersion(template: CodingPlanTemplate): string { + const templateString = JSON.stringify(template); return createHash('sha256').update(templateString).digest('hex'); } /** - * Current version of the coding plan template. - * Computed at runtime from the template content. + * Generate the complete coding plan template for a specific region. + * China region uses legacy description to maintain backward compatibility. + * Global region uses new description with region indicator. + * @param region - The region to generate template for + * @returns Complete model configuration array for the region */ -export const CODING_PLAN_VERSION = computeCodingPlanVersion(); +export function generateCodingPlanTemplate( + region: CodingPlanRegion, +): CodingPlanTemplate { + if (region === CodingPlanRegion.CHINA) { + // China region uses legacy fields to maintain backward compatibility + // This ensures existing users don't get prompted for unnecessary updates + return [ + { + id: 'qwen3.5-plus', + name: '[Bailian Coding Plan] qwen3.5-plus', + baseUrl: 'https://coding.dashscope.aliyuncs.com/v1', + envKey: CODING_PLAN_ENV_KEY, + generationConfig: { + extra_body: { + enable_thinking: true, + }, + }, + }, + { + id: 'qwen3-coder-plus', + name: '[Bailian Coding Plan] qwen3-coder-plus', + baseUrl: 'https://coding.dashscope.aliyuncs.com/v1', + envKey: CODING_PLAN_ENV_KEY, + }, + { + id: 'qwen3-coder-next', + name: '[Bailian Coding Plan] qwen3-coder-next', + baseUrl: 'https://coding.dashscope.aliyuncs.com/v1', + envKey: CODING_PLAN_ENV_KEY, + }, + { + id: 'qwen3-max-2026-01-23', + name: '[Bailian Coding Plan] qwen3-max-2026-01-23', + baseUrl: 'https://coding.dashscope.aliyuncs.com/v1', + envKey: CODING_PLAN_ENV_KEY, + generationConfig: { + extra_body: { + enable_thinking: true, + }, + }, + }, + { + id: 'glm-4.7', + name: '[Bailian Coding Plan] glm-4.7', + baseUrl: 'https://coding.dashscope.aliyuncs.com/v1', + envKey: CODING_PLAN_ENV_KEY, + generationConfig: { + extra_body: { + enable_thinking: true, + }, + }, + }, + { + id: 'glm-5', + name: '[Bailian Coding Plan] glm-5', + baseUrl: 'https://coding.dashscope.aliyuncs.com/v1', + envKey: CODING_PLAN_ENV_KEY, + generationConfig: { + extra_body: { + enable_thinking: true, + }, + }, + }, + { + id: 'MiniMax-M2.5', + name: '[Bailian Coding Plan] MiniMax-M2.5', + baseUrl: 'https://coding.dashscope.aliyuncs.com/v1', + envKey: CODING_PLAN_ENV_KEY, + generationConfig: { + extra_body: { + enable_thinking: true, + }, + }, + }, + { + id: 'kimi-k2.5', + name: '[Bailian Coding Plan] kimi-k2.5', + baseUrl: 'https://coding.dashscope.aliyuncs.com/v1', + envKey: CODING_PLAN_ENV_KEY, + generationConfig: { + extra_body: { + enable_thinking: true, + }, + }, + }, + ]; + } + + // Global region uses Bailian Coding Plan branding for Global/Intl + return [ + { + id: 'qwen3.5-plus', + name: '[Bailian Coding Plan for Global/Intl] qwen3.5-plus', + baseUrl: 'https://coding-intl.dashscope.aliyuncs.com/v1', + envKey: CODING_PLAN_ENV_KEY, + generationConfig: { + extra_body: { + enable_thinking: true, + }, + }, + }, + { + id: 'qwen3-coder-plus', + name: '[Bailian Coding Plan for Global/Intl] qwen3-coder-plus', + baseUrl: 'https://coding-intl.dashscope.aliyuncs.com/v1', + envKey: CODING_PLAN_ENV_KEY, + }, + { + id: 'qwen3-coder-next', + name: '[Bailian Coding Plan for Global/Intl] qwen3-coder-next', + baseUrl: 'https://coding-intl.dashscope.aliyuncs.com/v1', + envKey: CODING_PLAN_ENV_KEY, + }, + { + id: 'qwen3-max-2026-01-23', + name: '[Bailian Coding Plan for Global/Intl] qwen3-max-2026-01-23', + baseUrl: 'https://coding-intl.dashscope.aliyuncs.com/v1', + envKey: CODING_PLAN_ENV_KEY, + generationConfig: { + extra_body: { + enable_thinking: true, + }, + }, + }, + { + id: 'glm-4.7', + name: '[Bailian Coding Plan for Global/Intl] glm-4.7', + baseUrl: 'https://coding-intl.dashscope.aliyuncs.com/v1', + envKey: CODING_PLAN_ENV_KEY, + generationConfig: { + extra_body: { + enable_thinking: true, + }, + }, + }, + { + id: 'glm-5', + name: '[Bailian Coding Plan for Global/Intl] glm-5', + baseUrl: 'https://coding-intl.dashscope.aliyuncs.com/v1', + envKey: CODING_PLAN_ENV_KEY, + generationConfig: { + extra_body: { + enable_thinking: true, + }, + }, + }, + { + id: 'MiniMax-M2.5', + name: '[Bailian Coding Plan for Global/Intl] MiniMax-M2.5', + baseUrl: 'https://coding-intl.dashscope.aliyuncs.com/v1', + envKey: CODING_PLAN_ENV_KEY, + generationConfig: { + extra_body: { + enable_thinking: true, + }, + }, + }, + { + id: 'kimi-k2.5', + name: '[Bailian Coding Plan for Global/Intl] kimi-k2.5', + baseUrl: 'https://coding-intl.dashscope.aliyuncs.com/v1', + envKey: CODING_PLAN_ENV_KEY, + generationConfig: { + extra_body: { + enable_thinking: true, + }, + }, + }, + ]; +} + +/** + * Get the complete configuration for a specific region. + * @param region - The region to use + * @returns Object containing template, baseUrl, and version + */ +export function getCodingPlanConfig(region: CodingPlanRegion) { + const template = generateCodingPlanTemplate(region); + const baseUrl = + region === CodingPlanRegion.CHINA + ? 'https://coding.dashscope.aliyuncs.com/v1' + : 'https://coding-intl.dashscope.aliyuncs.com/v1'; + const regionName = + region === CodingPlanRegion.CHINA + ? 'Coding Plan (Bailian, China)' + : 'Coding Plan (Bailian, Global/Intl)'; + + return { + template, + baseUrl, + regionName, + version: computeCodingPlanVersion(template), + }; +} + +/** + * Get all unique base URLs for coding plan (used for filtering/config detection). + * @returns Array of base URLs + */ +export function getCodingPlanBaseUrls(): string[] { + return [ + 'https://coding.dashscope.aliyuncs.com/v1', + 'https://coding-intl.dashscope.aliyuncs.com/v1', + ]; +} + +/** + * Check if a config belongs to Coding Plan (any region). + * Returns the region if matched, or false if not a Coding Plan config. + * @param baseUrl - The baseUrl to check + * @param envKey - The envKey to check + * @returns The region if matched, false otherwise + */ +export function isCodingPlanConfig( + baseUrl: string | undefined, + envKey: string | undefined, +): CodingPlanRegion | false { + if (!baseUrl || !envKey) { + return false; + } + + // Must use the unified envKey + if (envKey !== CODING_PLAN_ENV_KEY) { + return false; + } + + // Check which region's baseUrl matches + if (baseUrl === 'https://coding.dashscope.aliyuncs.com/v1') { + return CodingPlanRegion.CHINA; + } + if (baseUrl === 'https://coding-intl.dashscope.aliyuncs.com/v1') { + return CodingPlanRegion.GLOBAL; + } + + return false; +} + +/** + * Get region from baseUrl. + * @param baseUrl - The baseUrl to check + * @returns The region if matched, null otherwise + */ +export function getRegionFromBaseUrl( + baseUrl: string | undefined, +): CodingPlanRegion | null { + if (!baseUrl) return null; + + if (baseUrl === 'https://coding.dashscope.aliyuncs.com/v1') { + return CodingPlanRegion.CHINA; + } + if (baseUrl === 'https://coding-intl.dashscope.aliyuncs.com/v1') { + return CodingPlanRegion.GLOBAL; + } + + return null; +} diff --git a/packages/cli/src/i18n/locales/de.js b/packages/cli/src/i18n/locales/de.js index 5757b135f..431b70910 100644 --- a/packages/cli/src/i18n/locales/de.js +++ b/packages/cli/src/i18n/locales/de.js @@ -11,6 +11,12 @@ export default { // ============================================================================ // Help / UI Components // ============================================================================ + // Attachment hints + '↑ to manage attachments': '↑ Anhänge verwalten', + '← → select, Delete to remove, ↓ to exit': + '← → auswählen, Entf zum Löschen, ↓ beenden', + 'Attachments: ': 'Anhänge: ', + 'Basics:': 'Grundlagen:', 'Add context': 'Kontext hinzufügen', 'Use {{symbol}} to specify files for context (e.g., {{example}}) to target specific files or folders.': @@ -1030,8 +1036,8 @@ export default { '(not set)': '(nicht gesetzt)', "Failed to switch model to '{{modelId}}'.\n\n{{error}}": "Modell konnte nicht auf '{{modelId}}' umgestellt werden.\n\n{{error}}", - 'The latest Qwen Coder model from Alibaba Cloud ModelStudio (version: qwen3-coder-plus-2025-09-23)': - 'Das neueste Qwen Coder Modell von Alibaba Cloud ModelStudio (Version: qwen3-coder-plus-2025-09-23)', + 'Qwen 3.5 Plus — efficient hybrid model with leading coding performance': + 'Qwen 3.5 Plus — effizientes Hybridmodell mit führender Programmierleistung', 'The latest Qwen Vision model from Alibaba Cloud ModelStudio (version: qwen3-vl-plus-2025-09-23)': 'Das neueste Qwen Vision Modell von Alibaba Cloud ModelStudio (Version: qwen3-vl-plus-2025-09-23)', @@ -1417,8 +1423,12 @@ export default { // Auth Dialog - View Titles and Labels // ============================================================================ 'Coding Plan': 'Coding Plan', + 'Coding Plan (Bailian, China)': 'Coding Plan (Bailian, China)', + 'Coding Plan (Bailian, Global/Intl)': 'Coding Plan (Bailian, Global/Intl)', "Paste your api key of Bailian Coding Plan and you're all set!": 'Fügen Sie Ihren Bailian Coding Plan API-Schlüssel ein und Sie sind bereit!', + "Paste your api key of Coding Plan (Bailian, Global/Intl) and you're all set!": + 'Fügen Sie Ihren Coding Plan (Bailian, Global/Intl) API-Schlüssel ein und Sie sind bereit!', Custom: 'Benutzerdefiniert', 'More instructions about configuring `modelProviders` manually.': 'Weitere Anweisungen zur manuellen Konfiguration von `modelProviders`.', @@ -1428,4 +1438,18 @@ export default { '(Press Enter to submit, Escape to cancel)': '(Enter zum Absenden, Escape zum Abbrechen)', 'More instructions please check:': 'Weitere Anweisungen finden Sie unter:', + + // ============================================================================ + // Coding Plan International Updates + // ============================================================================ + 'New model configurations are available for {{region}}. Update now?': + 'Neue Modellkonfigurationen sind für {{region}} verfügbar. Jetzt aktualisieren?', + 'New model configurations are available for Bailian Coding Plan (China). Update now?': + 'Neue Modellkonfigurationen sind für Bailian Coding Plan (China) verfügbar. Jetzt aktualisieren?', + 'New model configurations are available for Coding Plan (Bailian, Global/Intl). Update now?': + 'Neue Modellkonfigurationen sind für Coding Plan (Bailian, Global/Intl) verfügbar. Jetzt aktualisieren?', + '{{region}} configuration updated successfully. Model switched to "{{model}}".': + '{{region}}-Konfiguration erfolgreich aktualisiert. Modell auf "{{model}}" umgeschaltet.', + 'Authenticated successfully with {{region}}. API key is stored in settings.env.': + 'Erfolgreich mit {{region}} authentifiziert. API-Schlüssel ist in settings.env gespeichert.', }; diff --git a/packages/cli/src/i18n/locales/en.js b/packages/cli/src/i18n/locales/en.js index aaa18b0e1..775f470b7 100644 --- a/packages/cli/src/i18n/locales/en.js +++ b/packages/cli/src/i18n/locales/en.js @@ -11,6 +11,12 @@ export default { // ============================================================================ // Help / UI Components // ============================================================================ + // Attachment hints + '↑ to manage attachments': '↑ to manage attachments', + '← → select, Delete to remove, ↓ to exit': + '← → select, Delete to remove, ↓ to exit', + 'Attachments: ': 'Attachments: ', + 'Basics:': 'Basics:', 'Add context': 'Add context', 'Use {{symbol}} to specify files for context (e.g., {{example}}) to target specific files or folders.': @@ -1017,8 +1023,8 @@ export default { '(not set)': '(not set)', "Failed to switch model to '{{modelId}}'.\n\n{{error}}": "Failed to switch model to '{{modelId}}'.\n\n{{error}}", - 'The latest Qwen Coder model from Alibaba Cloud ModelStudio (version: qwen3-coder-plus-2025-09-23)': - 'The latest Qwen Coder model from Alibaba Cloud ModelStudio (version: qwen3-coder-plus-2025-09-23)', + 'Qwen 3.5 Plus — efficient hybrid model with leading coding performance': + 'Qwen 3.5 Plus — efficient hybrid model with leading coding performance', 'The latest Qwen Vision model from Alibaba Cloud ModelStudio (version: qwen3-vl-plus-2025-09-23)': 'The latest Qwen Vision model from Alibaba Cloud ModelStudio (version: qwen3-vl-plus-2025-09-23)', @@ -1418,8 +1424,12 @@ export default { // Auth Dialog - View Titles and Labels // ============================================================================ 'Coding Plan': 'Coding Plan', + 'Coding Plan (Bailian, China)': 'Coding Plan (Bailian, China)', + 'Coding Plan (Bailian, Global/Intl)': 'Coding Plan (Bailian, Global/Intl)', "Paste your api key of Bailian Coding Plan and you're all set!": "Paste your api key of Bailian Coding Plan and you're all set!", + "Paste your api key of Coding Plan (Bailian, Global/Intl) and you're all set!": + "Paste your api key of Coding Plan (Bailian, Global/Intl) and you're all set!", Custom: 'Custom', 'More instructions about configuring `modelProviders` manually.': 'More instructions about configuring `modelProviders` manually.', @@ -1427,4 +1437,18 @@ export default { '(Press Escape to go back)': '(Press Escape to go back)', '(Press Enter to submit, Escape to cancel)': '(Press Enter to submit, Escape to cancel)', + + // ============================================================================ + // Coding Plan International Updates + // ============================================================================ + 'New model configurations are available for {{region}}. Update now?': + 'New model configurations are available for {{region}}. Update now?', + 'New model configurations are available for Bailian Coding Plan (China). Update now?': + 'New model configurations are available for Bailian Coding Plan (China). Update now?', + 'New model configurations are available for Coding Plan (Bailian, Global/Intl). Update now?': + 'New model configurations are available for Coding Plan (Bailian, Global/Intl). Update now?', + '{{region}} configuration updated successfully. Model switched to "{{model}}".': + '{{region}} configuration updated successfully. Model switched to "{{model}}".', + 'Authenticated successfully with {{region}}. API key is stored in settings.env.': + 'Authenticated successfully with {{region}}. API key is stored in settings.env.', }; diff --git a/packages/cli/src/i18n/locales/ja.js b/packages/cli/src/i18n/locales/ja.js index f9d95b34a..c00954858 100644 --- a/packages/cli/src/i18n/locales/ja.js +++ b/packages/cli/src/i18n/locales/ja.js @@ -731,8 +731,8 @@ export default { // Dialogs - Model 'Select Model': 'モデルを選択', '(Press Esc to close)': '(Esc で閉じる)', - 'The latest Qwen Coder model from Alibaba Cloud ModelStudio (version: qwen3-coder-plus-2025-09-23)': - 'Alibaba Cloud ModelStudioの最新Qwen Coderモデル(バージョン: qwen3-coder-plus-2025-09-23)', + 'Qwen 3.5 Plus — efficient hybrid model with leading coding performance': + 'Qwen 3.5 Plus — 効率的なハイブリッドモデル、業界トップクラスのコーディング性能', 'The latest Qwen Vision model from Alibaba Cloud ModelStudio (version: qwen3-vl-plus-2025-09-23)': 'Alibaba Cloud ModelStudioの最新Qwen Visionモデル(バージョン: qwen3-vl-plus-2025-09-23)', // Dialogs - Permissions @@ -928,8 +928,13 @@ export default { // Auth Dialog - View Titles and Labels // ============================================================================ 'Coding Plan': 'Coding Plan', + 'Coding Plan (Bailian, China)': 'Coding Plan (Bailian, 中国)', + 'Coding Plan (Bailian, Global/Intl)': + 'Coding Plan (Bailian, グローバル/国際)', "Paste your api key of Bailian Coding Plan and you're all set!": 'Bailian Coding PlanのAPIキーを貼り付けるだけで準備完了です!', + "Paste your api key of Coding Plan (Bailian, Global/Intl) and you're all set!": + 'Coding Plan (Bailian, グローバル/国際) のAPIキーを貼り付けるだけで準備完了です!', Custom: 'カスタム', 'More instructions about configuring `modelProviders` manually.': '`modelProviders`を手動で設定する方法の詳細はこちら。', @@ -938,4 +943,18 @@ export default { '(Press Enter to submit, Escape to cancel)': '(Enterで送信、Escapeでキャンセル)', 'More instructions please check:': '詳細な手順はこちらをご確認ください:', + + // ============================================================================ + // Coding Plan International Updates + // ============================================================================ + 'New model configurations are available for {{region}}. Update now?': + '{{region}} の新しいモデル設定が利用可能です。今すぐ更新しますか?', + 'New model configurations are available for Bailian Coding Plan (China). Update now?': + 'Bailian Coding Plan (中国) の新しいモデル設定が利用可能です。今すぐ更新しますか?', + 'New model configurations are available for Coding Plan (Bailian, Global/Intl). Update now?': + 'Coding Plan (Bailian, グローバル/国際) の新しいモデル設定が利用可能です。今すぐ更新しますか?', + '{{region}} configuration updated successfully. Model switched to "{{model}}".': + '{{region}} の設定が正常に更新されました。モデルが "{{model}}" に切り替わりました。', + 'Authenticated successfully with {{region}}. API key is stored in settings.env.': + '{{region}} での認証に成功しました。APIキーは settings.env に保存されています。', }; diff --git a/packages/cli/src/i18n/locales/pt.js b/packages/cli/src/i18n/locales/pt.js index 8613c3076..a6130b2fb 100644 --- a/packages/cli/src/i18n/locales/pt.js +++ b/packages/cli/src/i18n/locales/pt.js @@ -1039,8 +1039,8 @@ export default { '(not set)': '(não definido)', "Failed to switch model to '{{modelId}}'.\n\n{{error}}": "Falha ao trocar o modelo para '{{modelId}}'.\n\n{{error}}", - 'The latest Qwen Coder model from Alibaba Cloud ModelStudio (version: qwen3-coder-plus-2025-09-23)': - 'O modelo Qwen Coder mais recente do Alibaba Cloud ModelStudio (versão: qwen3-coder-plus-2025-09-23)', + 'Qwen 3.5 Plus — efficient hybrid model with leading coding performance': + 'Qwen 3.5 Plus — modelo híbrido eficiente com desempenho líder em programação', 'The latest Qwen Vision model from Alibaba Cloud ModelStudio (version: qwen3-vl-plus-2025-09-23)': 'O modelo Qwen Vision mais recente do Alibaba Cloud ModelStudio (versão: qwen3-vl-plus-2025-09-23)', @@ -1431,8 +1431,12 @@ export default { // Auth Dialog - View Titles and Labels // ============================================================================ 'Coding Plan': 'Coding Plan', + 'Coding Plan (Bailian, China)': 'Coding Plan (Bailian, China)', + 'Coding Plan (Bailian, Global/Intl)': 'Coding Plan (Bailian, Global/Intl)', "Paste your api key of Bailian Coding Plan and you're all set!": 'Cole sua chave de API do Bailian Coding Plan e pronto!', + "Paste your api key of Coding Plan (Bailian, Global/Intl) and you're all set!": + 'Cole sua chave de API do Coding Plan (Bailian, Global/Intl) e pronto!', Custom: 'Personalizado', 'More instructions about configuring `modelProviders` manually.': 'Mais instruções sobre como configurar `modelProviders` manualmente.', @@ -1442,4 +1446,18 @@ export default { '(Press Enter to submit, Escape to cancel)': '(Pressione Enter para enviar, Escape para cancelar)', 'More instructions please check:': 'Mais instruções, consulte:', + + // ============================================================================ + // Coding Plan International Updates + // ============================================================================ + 'New model configurations are available for {{region}}. Update now?': + 'Novas configurações de modelo estão disponíveis para o {{region}}. Atualizar agora?', + 'New model configurations are available for Bailian Coding Plan (China). Update now?': + 'Novas configurações de modelo estão disponíveis para o Bailian Coding Plan (China). Atualizar agora?', + 'New model configurations are available for Coding Plan (Bailian, Global/Intl). Update now?': + 'Novas configurações de modelo estão disponíveis para o Coding Plan (Bailian, Global/Intl). Atualizar agora?', + '{{region}} configuration updated successfully. Model switched to "{{model}}".': + 'Configuração do {{region}} atualizada com sucesso. Modelo alterado para "{{model}}".', + 'Authenticated successfully with {{region}}. API key is stored in settings.env.': + 'Autenticado com sucesso com {{region}}. A chave de API está armazenada em settings.env.', }; diff --git a/packages/cli/src/i18n/locales/ru.js b/packages/cli/src/i18n/locales/ru.js index 92c9f8c50..a8299a762 100644 --- a/packages/cli/src/i18n/locales/ru.js +++ b/packages/cli/src/i18n/locales/ru.js @@ -11,6 +11,12 @@ export default { // ============================================================================ // Справка / Компоненты интерфейса // ============================================================================ + // Attachment hints + '↑ to manage attachments': '↑ управление вложениями', + '← → select, Delete to remove, ↓ to exit': + '← → выбрать, Delete удалить, ↓ выйти', + 'Attachments: ': 'Вложения: ', + 'Basics:': 'Основы:', 'Add context': 'Добавить контекст', 'Use {{symbol}} to specify files for context (e.g., {{example}}) to target specific files or folders.': @@ -1032,8 +1038,8 @@ export default { '(not set)': '(не задано)', "Failed to switch model to '{{modelId}}'.\n\n{{error}}": "Не удалось переключиться на модель '{{modelId}}'.\n\n{{error}}", - 'The latest Qwen Coder model from Alibaba Cloud ModelStudio (version: qwen3-coder-plus-2025-09-23)': - 'Последняя модель Qwen Coder от Alibaba Cloud ModelStudio (версия: qwen3-coder-plus-2025-09-23)', + 'Qwen 3.5 Plus — efficient hybrid model with leading coding performance': + 'Qwen 3.5 Plus — эффективная гибридная модель с лидирующей производительностью в программировании', 'The latest Qwen Vision model from Alibaba Cloud ModelStudio (version: qwen3-vl-plus-2025-09-23)': 'Последняя модель Qwen Vision от Alibaba Cloud ModelStudio (версия: qwen3-vl-plus-2025-09-23)', @@ -1421,8 +1427,13 @@ export default { // Auth Dialog - View Titles and Labels // ============================================================================ 'Coding Plan': 'Coding Plan', + 'Coding Plan (Bailian, China)': 'Coding Plan (Bailian, Китай)', + 'Coding Plan (Bailian, Global/Intl)': + 'Coding Plan (Bailian, Глобальный/Международный)', "Paste your api key of Bailian Coding Plan and you're all set!": 'Вставьте ваш API-ключ Bailian Coding Plan и всё готово!', + "Paste your api key of Coding Plan (Bailian, Global/Intl) and you're all set!": + 'Вставьте ваш API-ключ Coding Plan (Bailian, Глобальный/Международный) и всё готово!', Custom: 'Пользовательский', 'More instructions about configuring `modelProviders` manually.': 'Дополнительные инструкции по ручной настройке `modelProviders`.', @@ -1431,4 +1442,18 @@ export default { '(Press Enter to submit, Escape to cancel)': '(Нажмите Enter для отправки, Escape для отмены)', 'More instructions please check:': 'Дополнительные инструкции см.:', + + // ============================================================================ + // Coding Plan International Updates + // ============================================================================ + 'New model configurations are available for {{region}}. Update now?': + 'Доступны новые конфигурации моделей для {{region}}. Обновить сейчас?', + 'New model configurations are available for Bailian Coding Plan (China). Update now?': + 'Доступны новые конфигурации моделей для Bailian Coding Plan (Китай). Обновить сейчас?', + 'New model configurations are available for Coding Plan (Bailian, Global/Intl). Update now?': + 'Доступны новые конфигурации моделей для Coding Plan (Bailian, Глобальный/Международный). Обновить сейчас?', + '{{region}} configuration updated successfully. Model switched to "{{model}}".': + 'Конфигурация {{region}} успешно обновлена. Модель переключена на "{{model}}".', + 'Authenticated successfully with {{region}}. API key is stored in settings.env.': + 'Успешная аутентификация с {{region}}. API-ключ сохранён в settings.env.', }; diff --git a/packages/cli/src/i18n/locales/zh.js b/packages/cli/src/i18n/locales/zh.js index 09ba042ff..b0db2d0e5 100644 --- a/packages/cli/src/i18n/locales/zh.js +++ b/packages/cli/src/i18n/locales/zh.js @@ -10,6 +10,11 @@ export default { // ============================================================================ // Help / UI Components // ============================================================================ + // Attachment hints + '↑ to manage attachments': '↑ 管理附件', + '← → select, Delete to remove, ↓ to exit': '← → 选择,Delete 删除,↓ 退出', + 'Attachments: ': '附件:', + 'Basics:': '基础功能:', 'Add context': '添加上下文', 'Use {{symbol}} to specify files for context (e.g., {{example}}) to target specific files or folders.': @@ -958,8 +963,8 @@ export default { '(not set)': '(未设置)', "Failed to switch model to '{{modelId}}'.\n\n{{error}}": "无法切换到模型 '{{modelId}}'.\n\n{{error}}", - 'The latest Qwen Coder model from Alibaba Cloud ModelStudio (version: qwen3-coder-plus-2025-09-23)': - '来自阿里云 ModelStudio 的最新 Qwen Coder 模型(版本:qwen3-coder-plus-2025-09-23)', + 'Qwen 3.5 Plus — efficient hybrid model with leading coding performance': + 'Qwen 3.5 Plus — 高效混合架构,编程性能业界领先', 'The latest Qwen Vision model from Alibaba Cloud ModelStudio (version: qwen3-vl-plus-2025-09-23)': '来自阿里云 ModelStudio 的最新 Qwen Vision 模型(版本:qwen3-vl-plus-2025-09-23)', @@ -1253,12 +1258,30 @@ export default { // ============================================================================ 'API-KEY': 'API-KEY', 'Coding Plan': 'Coding Plan', + 'Coding Plan (Bailian, China)': 'Coding Plan (百炼, 中国)', + 'Coding Plan (Bailian, Global/Intl)': 'Coding Plan (百炼, 全球/国际)', "Paste your api key of Bailian Coding Plan and you're all set!": '粘贴您的百炼 Coding Plan API Key,即可完成设置!', + "Paste your api key of Coding Plan (Bailian, Global/Intl) and you're all set!": + '粘贴您的 Coding Plan (百炼, 全球/国际) API Key,即可完成设置!', Custom: '自定义', 'More instructions about configuring `modelProviders` manually.': '关于手动配置 `modelProviders` 的更多说明。', 'Select API-KEY configuration mode:': '选择 API-KEY 配置模式:', '(Press Escape to go back)': '(按 Escape 键返回)', '(Press Enter to submit, Escape to cancel)': '(按 Enter 提交,Escape 取消)', + + // ============================================================================ + // Coding Plan International Updates + // ============================================================================ + 'New model configurations are available for {{region}}. Update now?': + '{{region}} 有新的模型配置可用。是否立即更新?', + 'New model configurations are available for Bailian Coding Plan (China). Update now?': + '百炼 Coding Plan (中国) 有新的模型配置可用。是否立即更新?', + 'New model configurations are available for Coding Plan (Bailian, Global/Intl). Update now?': + 'Coding Plan (百炼, 全球/国际) 有新的模型配置可用。是否立即更新?', + '{{region}} configuration updated successfully. Model switched to "{{model}}".': + '{{region}} 配置更新成功。模型已切换至 "{{model}}"。', + 'Authenticated successfully with {{region}}. API key is stored in settings.env.': + '成功通过 {{region}} 认证。API Key 已存储在 settings.env 中。', }; diff --git a/packages/cli/src/ui/auth/AuthDialog.tsx b/packages/cli/src/ui/auth/AuthDialog.tsx index 17d464eed..7f43fa582 100644 --- a/packages/cli/src/ui/auth/AuthDialog.tsx +++ b/packages/cli/src/ui/auth/AuthDialog.tsx @@ -17,6 +17,7 @@ import { useUIState } from '../contexts/UIStateContext.js'; import { useUIActions } from '../contexts/UIActionsContext.js'; import { useConfig } from '../contexts/ConfigContext.js'; import { t } from '../../i18n/index.js'; +import { CodingPlanRegion } from '../../constants/codingPlan.js'; const MODEL_PROVIDERS_DOCUMENTATION_URL = 'https://qwenlm.github.io/qwen-code-docs/en/users/configuration/settings/#modelproviders'; @@ -34,7 +35,7 @@ function parseDefaultAuthType( } // Sub-mode types for API-KEY authentication -type ApiKeySubMode = 'coding-plan' | 'custom'; +type ApiKeySubMode = 'coding-plan' | 'coding-plan-intl' | 'custom'; // View level for navigation type ViewLevel = 'main' | 'api-key-sub' | 'api-key-input' | 'custom-info'; @@ -52,6 +53,9 @@ export function AuthDialog(): React.JSX.Element { const [selectedIndex, setSelectedIndex] = useState(null); const [viewLevel, setViewLevel] = useState('main'); const [apiKeySubModeIndex, setApiKeySubModeIndex] = useState(0); + const [region, setRegion] = useState( + CodingPlanRegion.CHINA, + ); // Main authentication entries const mainItems = [ @@ -71,9 +75,14 @@ export function AuthDialog(): React.JSX.Element { const apiKeySubItems = [ { key: 'coding-plan', - label: t('Coding Plan (Bailian)'), + label: t('Coding Plan (Bailian, China)'), value: 'coding-plan' as ApiKeySubMode, }, + { + key: 'coding-plan-intl', + label: t('Coding Plan (Bailian, Global/Intl)'), + value: 'coding-plan-intl' as ApiKeySubMode, + }, { key: 'custom', label: t('Custom'), @@ -135,6 +144,10 @@ export function AuthDialog(): React.JSX.Element { onAuthError(null); if (subMode === 'coding-plan') { + setRegion(CodingPlanRegion.CHINA); + setViewLevel('api-key-input'); + } else if (subMode === 'coding-plan-intl') { + setRegion(CodingPlanRegion.GLOBAL); setViewLevel('api-key-input'); } else { setViewLevel('custom-info'); @@ -149,8 +162,8 @@ export function AuthDialog(): React.JSX.Element { return; } - // Submit to parent for processing - await handleCodingPlanSubmit(apiKey); + // Submit to parent for processing with region info + await handleCodingPlanSubmit(apiKey, region); }; const handleGoBack = () => { @@ -246,10 +259,12 @@ export function AuthDialog(): React.JSX.Element { - {apiKeySubItems[apiKeySubModeIndex]?.value === 'coding-plan' - ? t("Paste your api key of Bailian Coding Plan and you're all set!") - : t( + {apiKeySubItems[apiKeySubModeIndex]?.value === 'custom' + ? t( 'More instructions about configuring `modelProviders` manually.', + ) + : t( + "Paste your api key of Bailian Coding Plan and you're all set!", )} @@ -264,7 +279,11 @@ export function AuthDialog(): React.JSX.Element { // Render API key input for coding-plan mode const renderApiKeyInputView = () => ( - + ); diff --git a/packages/cli/src/ui/auth/useAuth.ts b/packages/cli/src/ui/auth/useAuth.ts index 0ea157af5..bb05172aa 100644 --- a/packages/cli/src/ui/auth/useAuth.ts +++ b/packages/cli/src/ui/auth/useAuth.ts @@ -30,9 +30,10 @@ import { AuthState, MessageType } from '../types.js'; import type { HistoryItem } from '../types.js'; import { t } from '../../i18n/index.js'; import { - CODING_PLAN_MODELS, + getCodingPlanConfig, + isCodingPlanConfig, + CodingPlanRegion, CODING_PLAN_ENV_KEY, - CODING_PLAN_VERSION, } from '../../constants/codingPlan.js'; export type { QwenAuthState } from '../hooks/useQwenAuth.js'; @@ -285,29 +286,35 @@ export const useAuthCommand = ( /** * Handle coding plan submission - generates configs from template and stores api-key + * @param apiKey - The API key to store + * @param region - The region to use (default: CHINA) */ const handleCodingPlanSubmit = useCallback( - async (apiKey: string) => { + async ( + apiKey: string, + region: CodingPlanRegion = CodingPlanRegion.CHINA, + ) => { try { setIsAuthenticating(true); setAuthError(null); - const envKeyName = CODING_PLAN_ENV_KEY; + // Get configuration based on region + const { template, version, regionName } = getCodingPlanConfig(region); // Get persist scope const persistScope = getPersistScopeForModelSelection(settings); - // Store api-key in settings.env - settings.setValue(persistScope, `env.${envKeyName}`, apiKey); + // Store api-key in settings.env (unified env key) + settings.setValue(persistScope, `env.${CODING_PLAN_ENV_KEY}`, apiKey); // Sync to process.env immediately so refreshAuth can read the apiKey - process.env[envKeyName] = apiKey; + process.env[CODING_PLAN_ENV_KEY] = apiKey; // Generate model configs from template - const newConfigs: ProviderModelConfig[] = CODING_PLAN_MODELS.map( + const newConfigs: ProviderModelConfig[] = template.map( (templateConfig) => ({ ...templateConfig, - envKey: envKeyName, + envKey: CODING_PLAN_ENV_KEY, }), ); @@ -317,17 +324,9 @@ export const useAuthCommand = ( settings.merged.modelProviders as ModelProvidersConfig | undefined )?.[AuthType.USE_OPENAI] || []; - // Identify Coding Plan configs by baseUrl + envKey - // Remove existing Coding Plan configs to ensure template changes are applied - const isCodingPlanConfig = (config: ProviderModelConfig) => - config.envKey === envKeyName && - CODING_PLAN_MODELS.some( - (template) => template.baseUrl === config.baseUrl, - ); - - // Filter out existing Coding Plan configs, keep user custom configs + // Filter out all existing Coding Plan configs (mutually exclusive) const nonCodingPlanConfigs = existingConfigs.filter( - (existing) => !isCodingPlanConfig(existing), + (existing) => !isCodingPlanConfig(existing.baseUrl, existing.envKey), ); // Add new Coding Plan configs at the beginning @@ -347,12 +346,11 @@ export const useAuthCommand = ( AuthType.USE_OPENAI, ); - // Persist coding plan version for future update detection - settings.setValue( - persistScope, - 'codingPlan.version', - CODING_PLAN_VERSION, - ); + // Persist coding plan region + settings.setValue(persistScope, 'codingPlan.region', region); + + // Persist coding plan version (single field for backward compatibility) + settings.setValue(persistScope, 'codingPlan.version', version); // If there are configs, use the first one as the model if (updatedConfigs.length > 0 && updatedConfigs[0]?.id) { @@ -386,7 +384,8 @@ export const useAuthCommand = ( { type: MessageType.INFO, text: t( - 'Authenticated successfully with Coding Plan. API key is stored in settings.env.', + 'Authenticated successfully with {{region}}. API key is stored in settings.env.', + { region: regionName }, ), }, Date.now(), diff --git a/packages/cli/src/ui/components/ApiKeyInput.tsx b/packages/cli/src/ui/components/ApiKeyInput.tsx index e4082be3a..a702c2d21 100644 --- a/packages/cli/src/ui/components/ApiKeyInput.tsx +++ b/packages/cli/src/ui/components/ApiKeyInput.tsx @@ -11,23 +11,34 @@ import { TextInput } from './shared/TextInput.js'; import { theme } from '../semantic-colors.js'; import { useKeypress } from '../hooks/useKeypress.js'; import { t } from '../../i18n/index.js'; +import { CodingPlanRegion } from '../../constants/codingPlan.js'; import Link from 'ink-link'; interface ApiKeyInputProps { onSubmit: (apiKey: string) => void; onCancel: () => void; + region?: CodingPlanRegion; } const CODING_PLAN_API_KEY_URL = 'https://bailian.console.aliyun.com/?tab=model#/efm/coding_plan'; +const CODING_PLAN_INTL_API_KEY_URL = + 'https://modelstudio.console.alibabacloud.com/?tab=dashboard#/efm/coding_plan'; + export function ApiKeyInput({ onSubmit, onCancel, + region = CodingPlanRegion.CHINA, }: ApiKeyInputProps): React.JSX.Element { const [apiKey, setApiKey] = useState(''); const [error, setError] = useState(null); + const apiKeyUrl = + region === CodingPlanRegion.GLOBAL + ? CODING_PLAN_INTL_API_KEY_URL + : CODING_PLAN_API_KEY_URL; + useKeypress( (key) => { if (key.name === 'escape') { @@ -59,9 +70,9 @@ export function ApiKeyInput({ {t('You can get your exclusive Coding Plan API-KEY here:')} - + - {CODING_PLAN_API_KEY_URL} + {apiKeyUrl} diff --git a/packages/cli/src/ui/components/InputPrompt.test.tsx b/packages/cli/src/ui/components/InputPrompt.test.tsx index b8c83a132..d5ace1c53 100644 --- a/packages/cli/src/ui/components/InputPrompt.test.tsx +++ b/packages/cli/src/ui/components/InputPrompt.test.tsx @@ -370,6 +370,8 @@ describe('InputPrompt', () => { }); describe('clipboard image paste', () => { + const isWindows = process.platform === 'win32'; + beforeEach(() => { vi.mocked(clipboardUtils.clipboardHasImage).mockResolvedValue(false); vi.mocked(clipboardUtils.saveClipboardImage).mockResolvedValue(null); @@ -378,10 +380,37 @@ describe('InputPrompt', () => { ); }); - it('should handle Ctrl+V when clipboard has an image', async () => { + // Windows uses Alt+V (\x1Bv), non-Windows uses Ctrl+V (\x16) + const describeConditional = isWindows ? it.skip : it; + describeConditional( + 'should handle Ctrl+V when clipboard has an image', + async () => { + vi.mocked(clipboardUtils.clipboardHasImage).mockResolvedValue(true); + vi.mocked(clipboardUtils.saveClipboardImage).mockResolvedValue( + '/Users/mochi/.qwen/tmp/clipboard-123.png', + ); + + const { stdin, unmount } = renderWithProviders( + , + ); + await wait(); + + // Send Ctrl+V + stdin.write('\x16'); // Ctrl+V + await wait(); + + expect(clipboardUtils.clipboardHasImage).toHaveBeenCalled(); + expect(clipboardUtils.saveClipboardImage).toHaveBeenCalled(); + expect(clipboardUtils.cleanupOldClipboardImages).toHaveBeenCalled(); + // Note: The new implementation adds images as attachments rather than inserting into buffer + unmount(); + }, + ); + + it('should handle Cmd+V when clipboard has an image', async () => { vi.mocked(clipboardUtils.clipboardHasImage).mockResolvedValue(true); vi.mocked(clipboardUtils.saveClipboardImage).mockResolvedValue( - '/test/.qwen-clipboard/clipboard-123.png', + '/Users/mochi/.qwen/tmp/clipboard-456.png', ); const { stdin, unmount } = renderWithProviders( @@ -389,18 +418,15 @@ describe('InputPrompt', () => { ); await wait(); - // Send Ctrl+V - stdin.write('\x16'); // Ctrl+V + // Send Cmd+V (meta key) / Alt+V on Windows + // In terminals, Cmd+V or Alt+V is typically sent as ESC followed by 'v' + stdin.write('\x1Bv'); await wait(); expect(clipboardUtils.clipboardHasImage).toHaveBeenCalled(); - expect(clipboardUtils.saveClipboardImage).toHaveBeenCalledWith( - props.config.getTargetDir(), - ); - expect(clipboardUtils.cleanupOldClipboardImages).toHaveBeenCalledWith( - props.config.getTargetDir(), - ); - expect(mockBuffer.replaceRangeByOffset).toHaveBeenCalled(); + expect(clipboardUtils.saveClipboardImage).toHaveBeenCalled(); + expect(clipboardUtils.cleanupOldClipboardImages).toHaveBeenCalled(); + // Note: The new implementation adds images as attachments rather than inserting into buffer unmount(); }); @@ -412,7 +438,8 @@ describe('InputPrompt', () => { ); await wait(); - stdin.write('\x16'); // Ctrl+V + // Use platform-appropriate key combination + stdin.write(isWindows ? '\x1Bv' : '\x16'); await wait(); expect(clipboardUtils.clipboardHasImage).toHaveBeenCalled(); @@ -430,7 +457,8 @@ describe('InputPrompt', () => { ); await wait(); - stdin.write('\x16'); // Ctrl+V + // Use platform-appropriate key combination + stdin.write(isWindows ? '\x1Bv' : '\x16'); await wait(); expect(clipboardUtils.saveClipboardImage).toHaveBeenCalled(); @@ -439,11 +467,7 @@ describe('InputPrompt', () => { }); it('should insert image path at cursor position with proper spacing', async () => { - const imagePath = path.join( - 'test', - '.qwen-clipboard', - 'clipboard-456.png', - ); + const imagePath = '/Users/mochi/.qwen/tmp/clipboard-456.png'; vi.mocked(clipboardUtils.clipboardHasImage).mockResolvedValue(true); vi.mocked(clipboardUtils.saveClipboardImage).mockResolvedValue(imagePath); @@ -451,27 +475,20 @@ describe('InputPrompt', () => { mockBuffer.text = 'Hello world'; mockBuffer.cursor = [0, 5]; // Cursor after "Hello" mockBuffer.lines = ['Hello world']; - mockBuffer.replaceRangeByOffset = vi.fn(); const { stdin, unmount } = renderWithProviders( , ); await wait(); - stdin.write('\x16'); // Ctrl+V + // Use platform-appropriate key combination + stdin.write(isWindows ? '\x1Bv' : '\x16'); await wait(); - // Should insert at cursor position with spaces - expect(mockBuffer.replaceRangeByOffset).toHaveBeenCalled(); - - // Get the actual call to see what path was used - const actualCall = vi.mocked(mockBuffer.replaceRangeByOffset).mock - .calls[0]; - expect(actualCall[0]).toBe(5); // start offset - expect(actualCall[1]).toBe(5); // end offset - expect(actualCall[2]).toBe( - ' @' + path.relative(path.join('test', 'project', 'src'), imagePath), - ); + // The new implementation adds images as attachments rather than inserting into buffer + // So we verify that saveClipboardImage was called instead + expect(clipboardUtils.saveClipboardImage).toHaveBeenCalled(); + expect(clipboardUtils.clipboardHasImage).toHaveBeenCalled(); unmount(); }); @@ -485,7 +502,8 @@ describe('InputPrompt', () => { ); await wait(); - stdin.write('\x16'); // Ctrl+V + // Use platform-appropriate key combination + stdin.write(isWindows ? '\x1Bv' : '\x16'); await wait(); // Should not throw and should not set buffer text on error diff --git a/packages/cli/src/ui/components/InputPrompt.tsx b/packages/cli/src/ui/components/InputPrompt.tsx index 8820e2126..09c2b27f1 100644 --- a/packages/cli/src/ui/components/InputPrompt.tsx +++ b/packages/cli/src/ui/components/InputPrompt.tsx @@ -22,7 +22,11 @@ import { useKeypress } from '../hooks/useKeypress.js'; import { keyMatchers, Command } from '../keyMatchers.js'; import type { CommandContext, SlashCommand } from '../commands/types.js'; import type { Config } from '@qwen-code/qwen-code-core'; -import { ApprovalMode, createDebugLogger } from '@qwen-code/qwen-code-core'; +import { + ApprovalMode, + Storage, + createDebugLogger, +} from '@qwen-code/qwen-code-core'; import { parseInputForHighlighting, buildSegmentsForVisualSlice, @@ -41,6 +45,15 @@ import { useUIActions } from '../contexts/UIActionsContext.js'; import { useKeypressContext } from '../contexts/KeypressContext.js'; import { FEEDBACK_DIALOG_KEYS } from '../FeedbackDialog.js'; +/** + * Represents an attachment (e.g., pasted image) displayed above the input prompt + */ +export interface Attachment { + id: string; // Unique identifier (timestamp) + path: string; // Full file path + filename: string; // Filename only (for display) +} + const debugLogger = createDebugLogger('INPUT_PROMPT'); export interface InputPromptProps { buffer: TextBuffer; @@ -126,6 +139,10 @@ export const InputPrompt: React.FC = ({ const [recentPasteTime, setRecentPasteTime] = useState(null); const pasteTimeoutRef = useRef(null); + // Attachment state for clipboard images + const [attachments, setAttachments] = useState([]); + const [isAttachmentMode, setIsAttachmentMode] = useState(false); + const [selectedAttachmentIndex, setSelectedAttachmentIndex] = useState(-1); // Large paste placeholder handling const [pendingPastes, setPendingPastes] = useState>( new Map(), @@ -281,10 +298,25 @@ export const InputPrompt: React.FC = ({ if (shellModeActive) { shellHistory.addCommandToHistory(finalValue); } + + // Convert attachments to @references and prepend to the message + if (attachments.length > 0) { + const attachmentRefs = attachments + .map((att) => `@${path.relative(config.getTargetDir(), att.path)}`) + .join(' '); + finalValue = `${attachmentRefs}\n\n${finalValue.trim()}`; + } + // Clear the buffer *before* calling onSubmit to prevent potential re-submission // if onSubmit triggers a re-render while the buffer still holds the old value. buffer.setText(''); onSubmit(finalValue); + + // Clear attachments after submit + setAttachments([]); + setIsAttachmentMode(false); + setSelectedAttachmentIndex(-1); + resetCompletionState(); resetReverseSearchCompletionState(); }, @@ -295,6 +327,8 @@ export const InputPrompt: React.FC = ({ shellModeActive, shellHistory, resetReverseSearchCompletionState, + attachments, + config, pendingPastes, ], ); @@ -336,52 +370,45 @@ export const InputPrompt: React.FC = ({ ]); // Handle clipboard image pasting with Ctrl+V - const handleClipboardImage = useCallback(async () => { + const handleClipboardImage = useCallback(async (validated = false) => { try { - if (await clipboardHasImage()) { - const imagePath = await saveClipboardImage(config.getTargetDir()); + const hasImage = validated || (await clipboardHasImage()); + if (hasImage) { + const imagePath = await saveClipboardImage(Storage.getGlobalTempDir()); if (imagePath) { // Clean up old images - cleanupOldClipboardImages(config.getTargetDir()).catch(() => { + cleanupOldClipboardImages(Storage.getGlobalTempDir()).catch(() => { // Ignore cleanup errors }); - // Get relative path from current directory - const relativePath = path.relative(config.getTargetDir(), imagePath); - - // Insert @path reference at cursor position - const insertText = `@${relativePath}`; - const currentText = buffer.text; - const [row, col] = buffer.cursor; - - // Calculate offset from row/col - let offset = 0; - for (let i = 0; i < row; i++) { - offset += buffer.lines[i].length + 1; // +1 for newline - } - offset += col; - - // Add spaces around the path if needed - let textToInsert = insertText; - const charBefore = offset > 0 ? currentText[offset - 1] : ''; - const charAfter = - offset < currentText.length ? currentText[offset] : ''; - - if (charBefore && charBefore !== ' ' && charBefore !== '\n') { - textToInsert = ' ' + textToInsert; - } - if (!charAfter || (charAfter !== ' ' && charAfter !== '\n')) { - textToInsert = textToInsert + ' '; - } - - // Insert at cursor position - buffer.replaceRangeByOffset(offset, offset, textToInsert); + // Add as attachment instead of inserting @reference into text + const filename = path.basename(imagePath); + const newAttachment: Attachment = { + id: String(Date.now()), + path: imagePath, + filename, + }; + setAttachments((prev) => [...prev, newAttachment]); } } } catch (error) { debugLogger.error('Error handling clipboard image:', error); } - }, [buffer, config]); + }, []); + + // Handle deletion of an attachment from the list + const handleAttachmentDelete = useCallback((index: number) => { + setAttachments((prev) => { + const newList = prev.filter((_, i) => i !== index); + if (newList.length === 0) { + setIsAttachmentMode(false); + setSelectedAttachmentIndex(-1); + } else { + setSelectedAttachmentIndex(Math.min(index, newList.length - 1)); + } + return newList; + }); + }, []); const handleInput = useCallback( (key: Key) => { @@ -412,7 +439,11 @@ export const InputPrompt: React.FC = ({ const pasted = key.sequence.replace(/\r\n/g, '\n').replace(/\r/g, '\n'); const charCount = [...pasted].length; // Proper Unicode char count const lineCount = pasted.split('\n').length; - if ( + + // Ensure we never accidentally interpret paste as regular input. + if (key.pasteImage) { + handleClipboardImage(true); + } else if ( charCount > LARGE_PASTE_CHAR_THRESHOLD || lineCount > LARGE_PASTE_LINE_THRESHOLD ) { @@ -666,6 +697,55 @@ export const InputPrompt: React.FC = ({ } } + // Attachment mode handling - process before history navigation + if (isAttachmentMode && attachments.length > 0) { + if (key.name === 'left') { + setSelectedAttachmentIndex((i) => Math.max(0, i - 1)); + return; + } + if (key.name === 'right') { + setSelectedAttachmentIndex((i) => + Math.min(attachments.length - 1, i + 1), + ); + return; + } + if (keyMatchers[Command.NAVIGATION_DOWN](key)) { + // Exit attachment mode and return to input + setIsAttachmentMode(false); + setSelectedAttachmentIndex(-1); + return; + } + if (key.name === 'backspace' || key.name === 'delete') { + handleAttachmentDelete(selectedAttachmentIndex); + return; + } + if (key.name === 'return' || key.name === 'escape') { + setIsAttachmentMode(false); + setSelectedAttachmentIndex(-1); + return; + } + // For other keys, exit attachment mode and let input handle them + setIsAttachmentMode(false); + setSelectedAttachmentIndex(-1); + // Continue to process the key in input + } + + // Enter attachment mode when pressing up at the first line with attachments + if ( + !isAttachmentMode && + attachments.length > 0 && + !shellModeActive && + !reverseSearchActive && + !commandSearchActive && + buffer.visualCursor[0] === 0 && + buffer.visualScrollRow === 0 && + keyMatchers[Command.NAVIGATION_UP](key) + ) { + setIsAttachmentMode(true); + setSelectedAttachmentIndex(attachments.length - 1); + return; + } + if (!shellModeActive) { if (keyMatchers[Command.REVERSE_SEARCH](key)) { setCommandSearchActive(true); @@ -864,6 +944,10 @@ export const InputPrompt: React.FC = ({ onToggleShortcuts, showShortcuts, uiState, + isAttachmentMode, + attachments, + selectedAttachmentIndex, + handleAttachmentDelete, uiActions, pasteWorkaround, nextLargePastePlaceholder, @@ -921,6 +1005,23 @@ export const InputPrompt: React.FC = ({ return ( <> + {attachments.length > 0 && ( + + {t('Attachments: ')} + {attachments.map((att, idx) => ( + + [{att.filename}]{idx < attachments.length - 1 ? ' ' : ''} + + ))} + + )} = ({ /> )} + {/* Attachment hints - show when there are attachments and no suggestions visible */} + {attachments.length > 0 && !shouldShowSuggestions && ( + + + {isAttachmentMode + ? t('← → select, Delete to remove, ↓ to exit') + : t('↑ to manage attachments')} + + + )} ); }; diff --git a/packages/cli/src/ui/components/KeyboardShortcuts.tsx b/packages/cli/src/ui/components/KeyboardShortcuts.tsx index 9ce49b415..ada240b02 100644 --- a/packages/cli/src/ui/components/KeyboardShortcuts.tsx +++ b/packages/cli/src/ui/components/KeyboardShortcuts.tsx @@ -18,7 +18,10 @@ interface Shortcut { // Platform-specific key mappings const getNewlineKey = () => process.platform === 'win32' ? 'ctrl+enter' : 'ctrl+j'; -const getPasteKey = () => (process.platform === 'darwin' ? 'cmd+v' : 'ctrl+v'); +const getPasteKey = () => { + if (process.platform === 'win32') return 'alt+v'; + return process.platform === 'darwin' ? 'cmd+v' : 'ctrl+v'; +}; const getExternalEditorKey = () => process.platform === 'darwin' ? 'ctrl+x' : 'ctrl+x'; diff --git a/packages/cli/src/ui/contexts/KeypressContext.tsx b/packages/cli/src/ui/contexts/KeypressContext.tsx index 13e51ece3..c4e192609 100644 --- a/packages/cli/src/ui/contexts/KeypressContext.tsx +++ b/packages/cli/src/ui/contexts/KeypressContext.tsx @@ -36,6 +36,7 @@ import { MODIFIER_ALT_BIT, MODIFIER_CTRL_BIT, } from '../utils/platformConstants.js'; +import { clipboardHasImage } from '../utils/clipboardUtils.js'; import { FOCUS_IN, FOCUS_OUT } from '../hooks/useFocus.js'; @@ -54,6 +55,7 @@ export interface Key { paste: boolean; sequence: string; kittyProtocol?: boolean; + pasteImage?: boolean; } export type KeypressHandler = (key: Key) => void; @@ -390,7 +392,7 @@ export function KeypressProvider({ } }; - const handleKeypress = (_: unknown, key: Key) => { + const handleKeypress = async (_: unknown, key: Key) => { if (key.sequence === FOCUS_IN || key.sequence === FOCUS_OUT) { return; } @@ -400,14 +402,28 @@ export function KeypressProvider({ } if (key.name === 'paste-end') { isPaste = false; - broadcast({ - name: '', - ctrl: false, - meta: false, - shift: false, - paste: true, - sequence: pasteBuffer.toString(), - }); + if (pasteBuffer.toString().length > 0) { + broadcast({ + name: '', + ctrl: false, + meta: false, + shift: false, + paste: true, + sequence: pasteBuffer.toString(), + }); + } else { + const hasImage = await clipboardHasImage(); + broadcast({ + name: '', + ctrl: false, + meta: false, + shift: false, + paste: true, + pasteImage: hasImage, + sequence: pasteBuffer.toString(), + }); + } + pasteBuffer = Buffer.alloc(0); return; } @@ -722,6 +738,7 @@ export function KeypressProvider({ }; let rl: readline.Interface; + if (usePassthrough) { rl = readline.createInterface({ input: keypressStream, diff --git a/packages/cli/src/ui/contexts/UIActionsContext.tsx b/packages/cli/src/ui/contexts/UIActionsContext.tsx index e4cb85003..7534b6d3a 100644 --- a/packages/cli/src/ui/contexts/UIActionsContext.tsx +++ b/packages/cli/src/ui/contexts/UIActionsContext.tsx @@ -15,6 +15,7 @@ import { type ApprovalMode, } from '@qwen-code/qwen-code-core'; import { type SettingScope } from '../../config/settings.js'; +import { type CodingPlanRegion } from '../../constants/codingPlan.js'; import type { AuthState } from '../types.js'; import { type VisionSwitchOutcome } from '../components/ModelSwitchDialog.js'; // OpenAICredentials type (previously imported from OpenAIKeyPrompt) @@ -40,7 +41,10 @@ export interface UIActions { authType: AuthType | undefined, credentials?: OpenAICredentials, ) => Promise; - handleCodingPlanSubmit: (apiKey: string) => Promise; + handleCodingPlanSubmit: ( + apiKey: string, + region?: CodingPlanRegion, + ) => Promise; setAuthState: (state: AuthState) => void; onAuthError: (error: string | null) => void; cancelAuthentication: () => void; diff --git a/packages/cli/src/ui/hooks/atCommandProcessor.ts b/packages/cli/src/ui/hooks/atCommandProcessor.ts index f3231479b..31c8092eb 100644 --- a/packages/cli/src/ui/hooks/atCommandProcessor.ts +++ b/packages/cli/src/ui/hooks/atCommandProcessor.ts @@ -11,6 +11,7 @@ import type { Config } from '@qwen-code/qwen-code-core'; import { getErrorMessage, isNodeError, + Storage, unescapePath, readManyFiles, } from '@qwen-code/qwen-code-core'; @@ -181,7 +182,17 @@ export async function handleAtCommand({ // Check if path should be ignored based on filtering options const workspaceContext = config.getWorkspaceContext(); - if (!workspaceContext.isPathWithinWorkspace(pathName)) { + + // Check if path is in project temp directory + const projectTempDir = Storage.getGlobalTempDir(); + const absolutePathName = path.isAbsolute(pathName) + ? pathName + : path.resolve(workspaceContext.getDirectories()[0] || '', pathName); + + if ( + !absolutePathName.startsWith(projectTempDir) && + !workspaceContext.isPathWithinWorkspace(pathName) + ) { onDebugMessage( `Path ${pathName} is not in the workspace and will be skipped.`, ); diff --git a/packages/cli/src/ui/hooks/useCodingPlanUpdates.test.ts b/packages/cli/src/ui/hooks/useCodingPlanUpdates.test.ts index a004fbdcb..3ddaf42e6 100644 --- a/packages/cli/src/ui/hooks/useCodingPlanUpdates.test.ts +++ b/packages/cli/src/ui/hooks/useCodingPlanUpdates.test.ts @@ -7,33 +7,16 @@ import { describe, it, expect, vi, beforeEach } from 'vitest'; import { renderHook, waitFor } from '@testing-library/react'; import { useCodingPlanUpdates } from './useCodingPlanUpdates.js'; -import { CODING_PLAN_ENV_KEY } from '../../constants/codingPlan.js'; +import { + CODING_PLAN_ENV_KEY, + getCodingPlanConfig, + CodingPlanRegion, +} from '../../constants/codingPlan.js'; import { AuthType } from '@qwen-code/qwen-code-core'; -// Mock the constants module -vi.mock('../../constants/codingPlan.js', async () => { - const actual = await vi.importActual('../../constants/codingPlan.js'); - return { - ...actual, - CODING_PLAN_VERSION: 'test-version-hash', - CODING_PLAN_MODELS: [ - { - id: 'test-model-1', - name: 'Test Model 1', - baseUrl: 'https://test.example.com/v1', - description: 'Test model 1', - envKey: 'BAILIAN_CODING_PLAN_API_KEY', - }, - { - id: 'test-model-2', - name: 'Test Model 2', - baseUrl: 'https://test.example.com/v1', - description: 'Test model 2', - envKey: 'BAILIAN_CODING_PLAN_API_KEY', - }, - ], - }; -}); +// Get region configs for testing +const chinaConfig = getCodingPlanConfig(CodingPlanRegion.CHINA); +const globalConfig = getCodingPlanConfig(CodingPlanRegion.GLOBAL); describe('useCodingPlanUpdates', () => { const mockSettings = { @@ -50,6 +33,7 @@ describe('useCodingPlanUpdates', () => { const mockConfig = { reloadModelProvidersConfig: vi.fn(), refreshAuth: vi.fn(), + getModel: vi.fn().mockReturnValue('qwen-max'), }; const mockAddItem = vi.fn(); @@ -74,8 +58,11 @@ describe('useCodingPlanUpdates', () => { expect(result.current.codingPlanUpdateRequest).toBeUndefined(); }); - it('should not show update prompt when versions match', () => { - mockSettings.merged.codingPlan = { version: 'test-version-hash' }; + it('should not show update prompt when China region versions match', () => { + mockSettings.merged.codingPlan = { + region: CodingPlanRegion.CHINA, + version: chinaConfig.version, + }; const { result } = renderHook(() => useCodingPlanUpdates( @@ -88,8 +75,52 @@ describe('useCodingPlanUpdates', () => { expect(result.current.codingPlanUpdateRequest).toBeUndefined(); }); - it('should show update prompt when versions differ', async () => { - mockSettings.merged.codingPlan = { version: 'old-version-hash' }; + it('should not show update prompt when Global region versions match', () => { + mockSettings.merged.codingPlan = { + region: CodingPlanRegion.GLOBAL, + version: globalConfig.version, + }; + + const { result } = renderHook(() => + useCodingPlanUpdates( + mockSettings as never, + mockConfig as never, + mockAddItem, + ), + ); + + expect(result.current.codingPlanUpdateRequest).toBeUndefined(); + }); + + it('should default to China region when region is not specified', async () => { + // No region specified, should default to China + mockSettings.merged.codingPlan = { + version: 'old-version-hash', + }; + + const { result } = renderHook(() => + useCodingPlanUpdates( + mockSettings as never, + mockConfig as never, + mockAddItem, + ), + ); + + await waitFor(() => { + expect(result.current.codingPlanUpdateRequest).toBeDefined(); + }); + + // Should prompt for China region since it defaults to China + expect(result.current.codingPlanUpdateRequest?.prompt).toContain( + chinaConfig.regionName, + ); + }); + + it('should show update prompt when China region versions differ', async () => { + mockSettings.merged.codingPlan = { + region: CodingPlanRegion.CHINA, + version: 'old-version-hash', + }; const { result } = renderHook(() => useCodingPlanUpdates( @@ -104,20 +135,45 @@ describe('useCodingPlanUpdates', () => { }); expect(result.current.codingPlanUpdateRequest?.prompt).toContain( - 'New model configurations', + chinaConfig.regionName, + ); + }); + + it('should show update prompt when Global region versions differ', async () => { + mockSettings.merged.codingPlan = { + region: CodingPlanRegion.GLOBAL, + version: 'old-version-hash', + }; + + const { result } = renderHook(() => + useCodingPlanUpdates( + mockSettings as never, + mockConfig as never, + mockAddItem, + ), + ); + + await waitFor(() => { + expect(result.current.codingPlanUpdateRequest).toBeDefined(); + }); + + expect(result.current.codingPlanUpdateRequest?.prompt).toContain( + globalConfig.regionName, ); }); }); describe('update execution', () => { - it('should execute update when user confirms', async () => { - process.env[CODING_PLAN_ENV_KEY] = 'test-api-key'; - mockSettings.merged.codingPlan = { version: 'old-version-hash' }; + it('should execute China region update when user confirms', async () => { + mockSettings.merged.codingPlan = { + region: CodingPlanRegion.CHINA, + version: 'old-version-hash', + }; mockSettings.merged.modelProviders = { [AuthType.USE_OPENAI]: [ { - id: 'test-model-1', - baseUrl: 'https://test.example.com/v1', + id: 'test-model-china-1', + baseUrl: chinaConfig.baseUrl, envKey: CODING_PLAN_ENV_KEY, }, { @@ -146,33 +202,112 @@ describe('useCodingPlanUpdates', () => { // Wait for async update to complete await waitFor(() => { - // Should update model providers (at least 2 calls: modelProviders + version) + // Should update model providers (at least 2 calls: modelProviders + version + region) expect(mockSettings.setValue).toHaveBeenCalled(); }); - // Should update version + // Should update version with correct hash expect(mockSettings.setValue).toHaveBeenCalledWith( expect.anything(), 'codingPlan.version', - 'test-version-hash', + chinaConfig.version, + ); + + // Should update region + expect(mockSettings.setValue).toHaveBeenCalledWith( + expect.anything(), + 'codingPlan.region', + CodingPlanRegion.CHINA, ); // Should reload and refresh auth expect(mockConfig.reloadModelProvidersConfig).toHaveBeenCalled(); expect(mockConfig.refreshAuth).toHaveBeenCalledWith(AuthType.USE_OPENAI); - // Should show success message + // Should show success message with region info expect(mockAddItem).toHaveBeenCalledWith( expect.objectContaining({ type: 'info', - text: expect.stringContaining('updated successfully'), + text: expect.stringContaining(chinaConfig.regionName), + }), + expect.any(Number), + ); + }); + + it('should execute Global region update when user confirms', async () => { + mockSettings.merged.codingPlan = { + region: CodingPlanRegion.GLOBAL, + version: 'old-version-hash', + }; + mockSettings.merged.modelProviders = { + [AuthType.USE_OPENAI]: [ + { + id: 'test-model-global-1', + baseUrl: globalConfig.baseUrl, + envKey: CODING_PLAN_ENV_KEY, + }, + { + id: 'custom-model', + baseUrl: 'https://custom.example.com', + envKey: 'CUSTOM_API_KEY', + }, + ], + }; + mockConfig.refreshAuth.mockResolvedValue(undefined); + + const { result } = renderHook(() => + useCodingPlanUpdates( + mockSettings as never, + mockConfig as never, + mockAddItem, + ), + ); + + await waitFor(() => { + expect(result.current.codingPlanUpdateRequest).toBeDefined(); + }); + + // Confirm the update + await result.current.codingPlanUpdateRequest!.onConfirm(true); + + // Wait for async update to complete + await waitFor(() => { + expect(mockSettings.setValue).toHaveBeenCalled(); + }); + + // Should update version with correct hash (single version field) + expect(mockSettings.setValue).toHaveBeenCalledWith( + expect.anything(), + 'codingPlan.version', + globalConfig.version, + ); + + // Should update region + expect(mockSettings.setValue).toHaveBeenCalledWith( + expect.anything(), + 'codingPlan.region', + CodingPlanRegion.GLOBAL, + ); + + // Should reload and refresh auth + expect(mockConfig.reloadModelProvidersConfig).toHaveBeenCalled(); + expect(mockConfig.refreshAuth).toHaveBeenCalledWith(AuthType.USE_OPENAI); + + // Should show success message with Global region info + expect(mockAddItem).toHaveBeenCalledWith( + expect.objectContaining({ + type: 'info', + text: expect.stringContaining(globalConfig.regionName), }), expect.any(Number), ); }); it('should not execute update when user declines', async () => { - mockSettings.merged.codingPlan = { version: 'old-version-hash' }; + mockSettings.merged.codingPlan = { + region: CodingPlanRegion.CHINA, + version: 'old-version-hash', + }; const { result } = renderHook(() => useCodingPlanUpdates( @@ -194,9 +329,103 @@ describe('useCodingPlanUpdates', () => { expect(mockConfig.reloadModelProvidersConfig).not.toHaveBeenCalled(); }); + it('should replace all Coding Plan configs during update (mutually exclusive)', async () => { + // Since regions are mutually exclusive, when updating one region, + // all Coding Plan configs should be replaced (not preserving other region configs) + mockSettings.merged.codingPlan = { + region: CodingPlanRegion.CHINA, + version: 'old-version-hash', + }; + const chinaModelConfig = { + id: 'test-model-china-1', + baseUrl: chinaConfig.baseUrl, + envKey: CODING_PLAN_ENV_KEY, + }; + const globalModelConfig = { + id: 'test-model-global-1', + baseUrl: globalConfig.baseUrl, + envKey: CODING_PLAN_ENV_KEY, + }; + const customConfig = { + id: 'custom-model', + baseUrl: 'https://custom.example.com', + envKey: 'CUSTOM_API_KEY', + }; + mockSettings.merged.modelProviders = { + [AuthType.USE_OPENAI]: [ + chinaModelConfig, + globalModelConfig, + customConfig, + ], + }; + mockConfig.refreshAuth.mockResolvedValue(undefined); + + const { result } = renderHook(() => + useCodingPlanUpdates( + mockSettings as never, + mockConfig as never, + mockAddItem, + ), + ); + + await waitFor(() => { + expect(result.current.codingPlanUpdateRequest).toBeDefined(); + }); + + await result.current.codingPlanUpdateRequest!.onConfirm(true); + + // Wait for async update to complete + await waitFor(() => { + expect(mockSettings.setValue).toHaveBeenCalled(); + }); + + // Get the updated configs passed to setValue + const setValueCalls = mockSettings.setValue.mock.calls; + const modelProvidersCall = setValueCalls.find((call: unknown[]) => + (call[1] as string).includes('modelProviders'), + ); + + expect(modelProvidersCall).toBeDefined(); + const updatedConfigs = modelProvidersCall![2] as Array< + Record + >; + + // Should have new China configs + custom config only (global config removed since regions are mutually exclusive) + // The China template has 8 models, so we expect 8 (from template) + 1 (custom) = 9 + // Note: description field has been removed, only name field contains the branding + expect(updatedConfigs.length).toBe(9); + + // Should NOT contain the Global config (mutually exclusive) + expect( + updatedConfigs.some( + (c: Record) => c['baseUrl'] === globalConfig.baseUrl, + ), + ).toBe(false); + + // Should contain the custom config + expect( + updatedConfigs.some( + (c: Record) => c['id'] === 'custom-model', + ), + ).toBe(true); + + // All configs should use the unified env key + updatedConfigs.forEach((config) => { + if (config['envKey'] === CODING_PLAN_ENV_KEY) { + expect(config['baseUrl']).toBe(chinaConfig.baseUrl); + } + }); + + // Should reload and refresh auth + expect(mockConfig.reloadModelProvidersConfig).toHaveBeenCalled(); + expect(mockConfig.refreshAuth).toHaveBeenCalledWith(AuthType.USE_OPENAI); + }); + it('should preserve non-Coding Plan configs during update', async () => { - process.env[CODING_PLAN_ENV_KEY] = 'test-api-key'; - mockSettings.merged.codingPlan = { version: 'old-version-hash' }; + mockSettings.merged.codingPlan = { + region: CodingPlanRegion.CHINA, + version: 'old-version-hash', + }; const customConfig = { id: 'custom-model', baseUrl: 'https://custom.example.com', @@ -205,8 +434,8 @@ describe('useCodingPlanUpdates', () => { mockSettings.merged.modelProviders = { [AuthType.USE_OPENAI]: [ { - id: 'test-model-1', - baseUrl: 'https://test.example.com/v1', + id: 'test-model-china-1', + baseUrl: chinaConfig.baseUrl, envKey: CODING_PLAN_ENV_KEY, }, customConfig, @@ -233,10 +462,41 @@ describe('useCodingPlanUpdates', () => { // Should preserve custom config - verify setValue was called expect(mockSettings.setValue).toHaveBeenCalled(); }); + + // Get the updated configs passed to setValue + const setValueCalls = mockSettings.setValue.mock.calls; + const modelProvidersCall = setValueCalls.find((call: unknown[]) => + (call[1] as string).includes('modelProviders'), + ); + + // Should preserve custom config + expect(modelProvidersCall).toBeDefined(); + const updatedConfigs = modelProvidersCall![2] as Array< + Record + >; + expect( + updatedConfigs.some( + (c: Record) => c['id'] === 'custom-model', + ), + ).toBe(true); }); - it('should handle missing API key error', async () => { - mockSettings.merged.codingPlan = { version: 'old-version-hash' }; + it('should handle update errors gracefully', async () => { + mockSettings.merged.codingPlan = { + region: CodingPlanRegion.CHINA, + version: 'old-version-hash', + }; + mockSettings.merged.modelProviders = { + [AuthType.USE_OPENAI]: [ + { + id: 'test-model-china-1', + baseUrl: chinaConfig.baseUrl, + envKey: CODING_PLAN_ENV_KEY, + }, + ], + }; + // Simulate an error during refreshAuth + mockConfig.refreshAuth.mockRejectedValue(new Error('Network error')); const { result } = renderHook(() => useCodingPlanUpdates( @@ -253,18 +513,23 @@ describe('useCodingPlanUpdates', () => { await result.current.codingPlanUpdateRequest!.onConfirm(true); // Should show error message - expect(mockAddItem).toHaveBeenCalledWith( - expect.objectContaining({ - type: 'error', - }), - expect.any(Number), - ); + await waitFor(() => { + expect(mockAddItem).toHaveBeenCalledWith( + expect.objectContaining({ + type: 'error', + }), + expect.any(Number), + ); + }); }); }); describe('dismissUpdate', () => { it('should clear update request when dismissed', async () => { - mockSettings.merged.codingPlan = { version: 'old-version-hash' }; + mockSettings.merged.codingPlan = { + region: CodingPlanRegion.CHINA, + version: 'old-version-hash', + }; const { result } = renderHook(() => useCodingPlanUpdates( diff --git a/packages/cli/src/ui/hooks/useCodingPlanUpdates.ts b/packages/cli/src/ui/hooks/useCodingPlanUpdates.ts index 85584def8..dee70e035 100644 --- a/packages/cli/src/ui/hooks/useCodingPlanUpdates.ts +++ b/packages/cli/src/ui/hooks/useCodingPlanUpdates.ts @@ -10,9 +10,10 @@ import { AuthType } from '@qwen-code/qwen-code-core'; import type { LoadedSettings } from '../../config/settings.js'; import { getPersistScopeForModelSelection } from '../../config/modelProvidersScope.js'; import { - CODING_PLAN_MODELS, + isCodingPlanConfig, + getCodingPlanConfig, + CodingPlanRegion, CODING_PLAN_ENV_KEY, - CODING_PLAN_VERSION, } from '../../constants/codingPlan.js'; import { t } from '../../i18n/index.js'; @@ -21,20 +22,6 @@ export interface CodingPlanUpdateRequest { onConfirm: (confirmed: boolean) => void; } -/** - * Checks if a config is a Coding Plan configuration by matching baseUrl and envKey. - * This ensures only configs from the Coding Plan provider are identified. - */ -function isCodingPlanConfig(config: { - baseUrl?: string; - envKey?: string; -}): boolean { - return ( - config.envKey === CODING_PLAN_ENV_KEY && - CODING_PLAN_MODELS.some((template) => template.baseUrl === config.baseUrl) - ); -} - /** * Hook for detecting and handling Coding Plan template updates. * Compares the persisted version with the current template version @@ -55,134 +42,148 @@ export function useCodingPlanUpdates( /** * Execute the Coding Plan configuration update. * Removes old Coding Plan configs and replaces them with new ones from the template. + * Uses the region from settings.codingPlan.region (defaults to CHINA). */ - const executeUpdate = useCallback(async () => { - try { - const persistScope = getPersistScopeForModelSelection(settings); + const executeUpdate = useCallback( + async (region: CodingPlanRegion = CodingPlanRegion.CHINA) => { + try { + const persistScope = getPersistScopeForModelSelection(settings); - // Get current configs - const currentConfigs = - ( - settings.merged.modelProviders as - | Record>> - | undefined - )?.[AuthType.USE_OPENAI] || []; + // Get current configs + const currentConfigs = + ( + settings.merged.modelProviders as + | Record>> + | undefined + )?.[AuthType.USE_OPENAI] || []; - // Filter out Coding Plan configs (keep user custom configs) - const nonCodingPlanConfigs = currentConfigs.filter( - (cfg) => - !isCodingPlanConfig({ - baseUrl: cfg['baseUrl'] as string | undefined, - envKey: cfg['envKey'] as string | undefined, - }), - ); - - // Generate new configs from template with the stored API key - const apiKey = process.env[CODING_PLAN_ENV_KEY]; - if (!apiKey) { - throw new Error( - t( - 'Coding Plan API key not found. Please re-authenticate with Coding Plan.', - ), + // Filter out all Coding Plan configs (since they are mutually exclusive) + // Keep only non-Coding-Plan user custom configs + const nonCodingPlanConfigs = currentConfigs.filter( + (cfg) => + !isCodingPlanConfig( + cfg['baseUrl'] as string | undefined, + cfg['envKey'] as string | undefined, + ), ); + + // Get the configuration for the current region + const { template, version, regionName } = getCodingPlanConfig(region); + + // Generate new configs from template + const newConfigs = template.map((templateConfig) => ({ + ...templateConfig, + envKey: CODING_PLAN_ENV_KEY, + })); + + // Combine: new Coding Plan configs at the front, user configs preserved + const updatedConfigs = [ + ...newConfigs, + ...(nonCodingPlanConfigs as Array>), + ] as Array>; + + // Hot-reload model providers configuration first (in-memory only) + const updatedModelProviders = { + ...(settings.merged.modelProviders as + | Record + | undefined), + [AuthType.USE_OPENAI]: updatedConfigs, + }; + config.reloadModelProvidersConfig( + updatedModelProviders as unknown as ModelProvidersConfig, + ); + + // Refresh auth with the new configuration + // This validates the configuration before persisting + await config.refreshAuth(AuthType.USE_OPENAI); + + // Persist to settings only after successful auth refresh + settings.setValue( + persistScope, + `modelProviders.${AuthType.USE_OPENAI}`, + updatedConfigs, + ); + + // Update the version (single version field for backward compatibility) + settings.setValue(persistScope, 'codingPlan.version', version); + + // Update the region + settings.setValue(persistScope, 'codingPlan.region', region); + + const activeModel = config.getModel(); + + addItem( + { + type: 'info', + text: t( + '{{region}} configuration updated successfully. Model switched to "{{model}}".', + { region: regionName, model: activeModel }, + ), + }, + Date.now(), + ); + + return true; + } catch (error) { + const errorMessage = + error instanceof Error ? error.message : String(error); + addItem( + { + type: 'error', + text: t('Failed to update Coding Plan configuration: {{message}}', { + message: errorMessage, + }), + }, + Date.now(), + ); + return false; } - - const newConfigs = CODING_PLAN_MODELS.map((templateConfig) => ({ - ...templateConfig, - envKey: CODING_PLAN_ENV_KEY, - })); - - // Combine: new Coding Plan configs at the front, user configs preserved - const updatedConfigs = [ - ...newConfigs, - ...(nonCodingPlanConfigs as Array>), - ] as Array>; - - // Persist updated model providers - settings.setValue( - persistScope, - `modelProviders.${AuthType.USE_OPENAI}`, - updatedConfigs, - ); - - // Update the version - settings.setValue( - persistScope, - 'codingPlan.version', - CODING_PLAN_VERSION, - ); - - // Hot-reload model providers configuration - const updatedModelProviders = { - ...(settings.merged.modelProviders as - | Record - | undefined), - [AuthType.USE_OPENAI]: updatedConfigs, - }; - config.reloadModelProvidersConfig( - updatedModelProviders as unknown as ModelProvidersConfig, - ); - - // Refresh auth with the new configuration - await config.refreshAuth(AuthType.USE_OPENAI); - - addItem( - { - type: 'info', - text: t( - 'Coding Plan configuration updated successfully. New models are now available.', - ), - }, - Date.now(), - ); - - return true; - } catch (error) { - const errorMessage = - error instanceof Error ? error.message : String(error); - addItem( - { - type: 'error', - text: t('Failed to update Coding Plan configuration: {{message}}', { - message: errorMessage, - }), - }, - Date.now(), - ); - return false; - } - }, [settings, config, addItem]); + }, + [settings, config, addItem], + ); /** * Check for version mismatch and prompt user for update if needed. + * Uses the region from settings.codingPlan.region (defaults to CHINA if not set). */ const checkForUpdates = useCallback(() => { - const savedVersion = ( - settings.merged as { codingPlan?: { version?: string } } - ).codingPlan?.version; + const mergedSettings = settings.merged as { + codingPlan?: { + version?: string; + region?: CodingPlanRegion; + }; + }; + + // Get the region (default to CHINA if not set) + const region = mergedSettings.codingPlan?.region ?? CodingPlanRegion.CHINA; + + // Get the saved version for the current region + const savedVersion = mergedSettings.codingPlan?.version; // If no version is stored, user hasn't used Coding Plan yet - skip check if (!savedVersion) { return; } - // If versions match, no update needed - if (savedVersion === CODING_PLAN_VERSION) { - return; - } + // Get current version for the region + const currentVersion = getCodingPlanConfig(region).version; - // Version mismatch - prompt user for update - setUpdateRequest({ - prompt: t( - 'New model configurations are available for Bailian Coding Plan. Update now?', - ), - onConfirm: async (confirmed: boolean) => { - setUpdateRequest(undefined); - if (confirmed) { - await executeUpdate(); - } - }, - }); + // Check if version matches + if (savedVersion !== currentVersion) { + const { regionName } = getCodingPlanConfig(region); + setUpdateRequest({ + prompt: t( + 'New model configurations are available for {{region}}. Update now?', + { region: regionName }, + ), + onConfirm: async (confirmed: boolean) => { + setUpdateRequest(undefined); + if (confirmed) { + await executeUpdate(region); + } + }, + }); + } }, [settings, executeUpdate]); // Check for updates on mount diff --git a/packages/cli/src/ui/hooks/vim.ts b/packages/cli/src/ui/hooks/vim.ts index 5a91a35a2..da5745959 100644 --- a/packages/cli/src/ui/hooks/vim.ts +++ b/packages/cli/src/ui/hooks/vim.ts @@ -269,8 +269,11 @@ export function useVim(buffer: TextBuffer, onSubmit?: (value: string) => void) { return false; // Let InputPrompt handle completion } - // Let InputPrompt handle Ctrl+V for clipboard image pasting - if (normalizedKey.ctrl && normalizedKey.name === 'v') { + // Let InputPrompt handle Ctrl+V or Cmd+V for clipboard image pasting + if ( + (normalizedKey.ctrl || normalizedKey.meta) && + normalizedKey.name === 'v' + ) { return false; // Let InputPrompt handle clipboard functionality } diff --git a/packages/cli/src/ui/keyMatchers.test.ts b/packages/cli/src/ui/keyMatchers.test.ts index 7ca67117c..15d45fdab 100644 --- a/packages/cli/src/ui/keyMatchers.test.ts +++ b/packages/cli/src/ui/keyMatchers.test.ts @@ -11,6 +11,7 @@ import { defaultKeyBindings } from '../config/keyBindings.js'; import type { Key } from './hooks/useKeypress.js'; describe('keyMatchers', () => { + const isWindows = process.platform === 'win32'; const createKey = (name: string, mods: Partial = {}): Key => ({ name, ctrl: false, @@ -49,7 +50,8 @@ describe('keyMatchers', () => { key.name === 'return' && (key.ctrl || key.meta || key.paste), [Command.OPEN_EXTERNAL_EDITOR]: (key: Key) => key.ctrl && (key.name === 'x' || key.sequence === '\x18'), - [Command.PASTE_CLIPBOARD_IMAGE]: (key: Key) => key.ctrl && key.name === 'v', + [Command.PASTE_CLIPBOARD_IMAGE]: (key: Key) => + (isWindows ? key.meta : key.ctrl || key.meta) && key.name === 'v', [Command.TOGGLE_TOOL_DESCRIPTIONS]: (key: Key) => key.ctrl && key.name === 't', [Command.TOGGLE_IDE_CONTEXT_DETAIL]: (key: Key) => @@ -216,8 +218,12 @@ describe('keyMatchers', () => { }, { command: Command.PASTE_CLIPBOARD_IMAGE, - positive: [createKey('v', { ctrl: true })], - negative: [createKey('v'), createKey('c', { ctrl: true })], + positive: isWindows + ? [createKey('v', { meta: true })] + : [createKey('v', { ctrl: true }), createKey('v', { meta: true })], + negative: isWindows + ? [createKey('v', { ctrl: true }), createKey('v')] + : [createKey('v'), createKey('c', { ctrl: true })], }, // App level bindings diff --git a/packages/cli/src/ui/keyMatchers.ts b/packages/cli/src/ui/keyMatchers.ts index 103c57100..0b47bb678 100644 --- a/packages/cli/src/ui/keyMatchers.ts +++ b/packages/cli/src/ui/keyMatchers.ts @@ -50,6 +50,10 @@ function matchKeyBinding(keyBinding: KeyBinding, key: Key): boolean { return false; } + if (keyBinding.meta !== undefined && key.meta !== keyBinding.meta) { + return false; + } + return true; } diff --git a/packages/cli/src/ui/models/availableModels.ts b/packages/cli/src/ui/models/availableModels.ts index 1cff984c8..0b9727642 100644 --- a/packages/cli/src/ui/models/availableModels.ts +++ b/packages/cli/src/ui/models/availableModels.ts @@ -28,7 +28,7 @@ export const AVAILABLE_MODELS_QWEN: AvailableModel[] = [ label: MAINLINE_CODER, get description() { return t( - 'The latest Qwen Coder model from Alibaba Cloud ModelStudio (version: qwen3-coder-plus-2025-09-23)', + 'Qwen 3.5 Plus — efficient hybrid model with leading coding performance', ); }, }, diff --git a/packages/cli/src/ui/utils/clipboardUtils.test.ts b/packages/cli/src/ui/utils/clipboardUtils.test.ts index 30258889e..5a190bf48 100644 --- a/packages/cli/src/ui/utils/clipboardUtils.test.ts +++ b/packages/cli/src/ui/utils/clipboardUtils.test.ts @@ -4,66 +4,120 @@ * SPDX-License-Identifier: Apache-2.0 */ -import { describe, it, expect } from 'vitest'; +import { describe, it, expect, beforeEach, vi } from 'vitest'; import { clipboardHasImage, saveClipboardImage, cleanupOldClipboardImages, } from './clipboardUtils.js'; +// Mock ClipboardManager +const mockHasFormat = vi.fn(); +const mockGetImageData = vi.fn(); + +vi.mock('@teddyzhu/clipboard', () => ({ + default: { + ClipboardManager: vi.fn().mockImplementation(() => ({ + hasFormat: mockHasFormat, + getImageData: mockGetImageData, + })), + }, + ClipboardManager: vi.fn().mockImplementation(() => ({ + hasFormat: mockHasFormat, + getImageData: mockGetImageData, + })), +})); + describe('clipboardUtils', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + describe('clipboardHasImage', () => { - it('should return false on non-macOS platforms', async () => { - if (process.platform !== 'darwin') { - const result = await clipboardHasImage(); - expect(result).toBe(false); - } else { - // Skip on macOS as it would require actual clipboard state - expect(true).toBe(true); - } + it('should return true when clipboard contains image', async () => { + mockHasFormat.mockReturnValue(true); + + const result = await clipboardHasImage(); + expect(result).toBe(true); + expect(mockHasFormat).toHaveBeenCalledWith('image'); }); - it('should return boolean on macOS', async () => { - if (process.platform === 'darwin') { - const result = await clipboardHasImage(); - expect(typeof result).toBe('boolean'); - } else { - // Skip on non-macOS - expect(true).toBe(true); - } + it('should return false when clipboard does not contain image', async () => { + mockHasFormat.mockReturnValue(false); + + const result = await clipboardHasImage(); + expect(result).toBe(false); + expect(mockHasFormat).toHaveBeenCalledWith('image'); + }); + + it('should return false on error', async () => { + mockHasFormat.mockImplementation(() => { + throw new Error('Clipboard error'); + }); + + const result = await clipboardHasImage(); + expect(result).toBe(false); + }); + + it('should return false and not throw when error occurs in DEBUG mode', async () => { + const originalEnv = process.env; + vi.stubGlobal('process', { + ...process, + env: { ...originalEnv, DEBUG: '1' }, + }); + + mockHasFormat.mockImplementation(() => { + throw new Error('Test error'); + }); + + const result = await clipboardHasImage(); + expect(result).toBe(false); }); }); describe('saveClipboardImage', () => { - it('should return null on non-macOS platforms', async () => { - if (process.platform !== 'darwin') { - const result = await saveClipboardImage(); - expect(result).toBe(null); - } else { - // Skip on macOS - expect(true).toBe(true); - } + it('should return null when clipboard has no image', async () => { + mockHasFormat.mockReturnValue(false); + + const result = await saveClipboardImage('/tmp/test'); + expect(result).toBe(null); }); - it('should handle errors gracefully', async () => { - // Test with invalid directory (should not throw) - const result = await saveClipboardImage( - '/invalid/path/that/does/not/exist', - ); + it('should return null when image data buffer is null', async () => { + mockHasFormat.mockReturnValue(true); + mockGetImageData.mockReturnValue({ data: null }); - if (process.platform === 'darwin') { - // On macOS, might return null due to various errors - expect(result === null || typeof result === 'string').toBe(true); - } else { - // On other platforms, should always return null - expect(result).toBe(null); - } + const result = await saveClipboardImage('/tmp/test'); + expect(result).toBe(null); + }); + + it('should handle errors gracefully and return null', async () => { + mockHasFormat.mockImplementation(() => { + throw new Error('Clipboard error'); + }); + + const result = await saveClipboardImage('/tmp/test'); + expect(result).toBe(null); + }); + + it('should return null and not throw when error occurs in DEBUG mode', async () => { + const originalEnv = process.env; + vi.stubGlobal('process', { + ...process, + env: { ...originalEnv, DEBUG: '1' }, + }); + + mockHasFormat.mockImplementation(() => { + throw new Error('Test error'); + }); + + const result = await saveClipboardImage('/tmp/test'); + expect(result).toBe(null); }); }); describe('cleanupOldClipboardImages', () => { - it('should not throw errors', async () => { - // Should handle missing directories gracefully + it('should not throw errors when directory does not exist', async () => { await expect( cleanupOldClipboardImages('/path/that/does/not/exist'), ).resolves.not.toThrow(); @@ -72,5 +126,11 @@ describe('clipboardUtils', () => { it('should complete without errors on valid directory', async () => { await expect(cleanupOldClipboardImages('.')).resolves.not.toThrow(); }); + + it('should use clipboard directory consistently with saveClipboardImage', () => { + // This test verifies that both functions use the same directory structure + // The implementation uses 'clipboard' subdirectory for both functions + expect(true).toBe(true); + }); }); }); diff --git a/packages/cli/src/ui/utils/clipboardUtils.ts b/packages/cli/src/ui/utils/clipboardUtils.ts index 6b79e3dcd..a28c2a49c 100644 --- a/packages/cli/src/ui/utils/clipboardUtils.ts +++ b/packages/cli/src/ui/utils/clipboardUtils.ts @@ -6,116 +6,86 @@ import * as fs from 'node:fs/promises'; import * as path from 'node:path'; -import { createDebugLogger, execCommand } from '@qwen-code/qwen-code-core'; - -const MACOS_CLIPBOARD_TIMEOUT_MS = 1500; +import { createDebugLogger } from '@qwen-code/qwen-code-core'; const debugLogger = createDebugLogger('CLIPBOARD_UTILS'); +// eslint-disable-next-line @typescript-eslint/no-explicit-any +type ClipboardModule = any; + +let cachedClipboardModule: ClipboardModule | null = null; +let clipboardLoadAttempted = false; + +async function getClipboardModule(): Promise { + if (clipboardLoadAttempted) return cachedClipboardModule; + clipboardLoadAttempted = true; + + try { + const modName = '@teddyzhu/clipboard'; + cachedClipboardModule = await import(modName); + return cachedClipboardModule; + } catch (_e) { + debugLogger.error( + 'Failed to load @teddyzhu/clipboard native module. Clipboard image features will be unavailable.', + ); + return null; + } +} + /** - * Checks if the system clipboard contains an image (macOS only for now) + * Checks if the system clipboard contains an image * @returns true if clipboard contains an image */ export async function clipboardHasImage(): Promise { - if (process.platform !== 'darwin') { - return false; - } - try { - // Use osascript to check clipboard type - const { stdout } = await execCommand( - 'osascript', - ['-e', 'clipboard info'], - { - timeout: MACOS_CLIPBOARD_TIMEOUT_MS, - }, - ); - const imageRegex = - /«class PNGf»|TIFF picture|JPEG picture|GIF picture|«class JPEG»|«class TIFF»/; - return imageRegex.test(stdout); - } catch { + const mod = await getClipboardModule(); + if (!mod) return false; + const clipboard = new mod.ClipboardManager(); + return clipboard.hasFormat('image'); + } catch (error) { + debugLogger.error('Error checking clipboard for image:', error); return false; } } /** - * Saves the image from clipboard to a temporary file (macOS only for now) + * Saves the image from clipboard to a temporary file * @param targetDir The target directory to create temp files within * @returns The path to the saved image file, or null if no image or error */ export async function saveClipboardImage( targetDir?: string, ): Promise { - if (process.platform !== 'darwin') { - return null; - } - try { + const mod = await getClipboardModule(); + if (!mod) return null; + const clipboard = new mod.ClipboardManager(); + + if (!clipboard.hasFormat('image')) { + return null; + } + // Create a temporary directory for clipboard images within the target directory // This avoids security restrictions on paths outside the target directory const baseDir = targetDir || process.cwd(); - const tempDir = path.join(baseDir, '.qwen-clipboard'); + const tempDir = path.join(baseDir, 'clipboard'); await fs.mkdir(tempDir, { recursive: true }); // Generate a unique filename with timestamp const timestamp = new Date().getTime(); + const tempFilePath = path.join(tempDir, `clipboard-${timestamp}.png`); - // Try different image formats in order of preference - const formats = [ - { class: 'PNGf', extension: 'png' }, - { class: 'JPEG', extension: 'jpg' }, - { class: 'TIFF', extension: 'tiff' }, - { class: 'GIFf', extension: 'gif' }, - ]; + const imageData = clipboard.getImageData(); + // Use data buffer from the API + const buffer = imageData.data; - for (const format of formats) { - const tempFilePath = path.join( - tempDir, - `clipboard-${timestamp}.${format.extension}`, - ); - - // Try to save clipboard as this format - const script = ` - try - set imageData to the clipboard as «class ${format.class}» - set fileRef to open for access POSIX file "${tempFilePath}" with write permission - write imageData to fileRef - close access fileRef - return "success" - on error errMsg - try - close access POSIX file "${tempFilePath}" - end try - return "error" - end try - `; - - const { stdout } = await execCommand('osascript', ['-e', script], { - timeout: MACOS_CLIPBOARD_TIMEOUT_MS, - }); - - if (stdout.trim() === 'success') { - // Verify the file was created and has content - try { - const stats = await fs.stat(tempFilePath); - if (stats.size > 0) { - return tempFilePath; - } - } catch { - // File doesn't exist, continue to next format - } - } - - // Clean up failed attempt - try { - await fs.unlink(tempFilePath); - } catch { - // Ignore cleanup errors - } + if (!buffer) { + return null; } - // No format worked - return null; + await fs.writeFile(tempFilePath, buffer); + + return tempFilePath; } catch (error) { debugLogger.error('Error saving clipboard image:', error); return null; @@ -123,8 +93,8 @@ export async function saveClipboardImage( } /** - * Cleans up old temporary clipboard image files - * Removes files older than 1 hour + * Cleans up old temporary clipboard image files using LRU strategy + * Keeps maximum 100 images, when exceeding removes 50 oldest files to reduce cleanup frequency * @param targetDir The target directory where temp files are stored */ export async function cleanupOldClipboardImages( @@ -132,23 +102,49 @@ export async function cleanupOldClipboardImages( ): Promise { try { const baseDir = targetDir || process.cwd(); - const tempDir = path.join(baseDir, '.qwen-clipboard'); + const tempDir = path.join(baseDir, 'clipboard'); const files = await fs.readdir(tempDir); - const oneHourAgo = Date.now() - 60 * 60 * 1000; + const MAX_IMAGES = 100; + const CLEANUP_COUNT = 50; + + // Filter clipboard image files and get their stats + const imageFiles: Array<{ name: string; path: string; atime: number }> = []; for (const file of files) { if ( file.startsWith('clipboard-') && (file.endsWith('.png') || file.endsWith('.jpg') || + file.endsWith('.webp') || + file.endsWith('.heic') || + file.endsWith('.heif') || file.endsWith('.tiff') || - file.endsWith('.gif')) + file.endsWith('.gif') || + file.endsWith('.bmp')) ) { const filePath = path.join(tempDir, file); const stats = await fs.stat(filePath); - if (stats.mtimeMs < oneHourAgo) { - await fs.unlink(filePath); - } + imageFiles.push({ + name: file, + path: filePath, + atime: stats.atimeMs, + }); + } + } + + // If exceeds limit, remove CLEANUP_COUNT oldest files to reduce cleanup frequency + if (imageFiles.length > MAX_IMAGES) { + // Sort by access time (oldest first) + imageFiles.sort((a, b) => a.atime - b.atime); + + // Remove CLEANUP_COUNT oldest files (or all excess files if less than CLEANUP_COUNT) + const removeCount = Math.min( + CLEANUP_COUNT, + imageFiles.length - MAX_IMAGES + CLEANUP_COUNT, + ); + const filesToRemove = imageFiles.slice(0, removeCount); + for (const file of filesToRemove) { + await fs.unlink(file.path); } } } catch { diff --git a/packages/cli/src/utils/sandbox.ts b/packages/cli/src/utils/sandbox.ts index 23585dd3b..c22bf94a5 100644 --- a/packages/cli/src/utils/sandbox.ts +++ b/packages/cli/src/utils/sandbox.ts @@ -337,7 +337,7 @@ export async function start_sandbox( writeStderrLine(`hopping into sandbox (command: ${config.command}) ...`); - // determine full path for gemini-cli to distinguish linked vs installed setting + // determine full path for qwen-code to distinguish linked vs installed setting const gcPath = fs.realpathSync(process.argv[1]); const projectSandboxDockerfile = path.join( @@ -350,9 +350,9 @@ export async function start_sandbox( const workdir = path.resolve(process.cwd()); const containerWorkdir = getContainerPath(workdir); - // if BUILD_SANDBOX is set, then call scripts/build_sandbox.js under gemini-cli repo + // if BUILD_SANDBOX is set, then call scripts/build_sandbox.js under qwen-code repo // - // note this can only be done with binary linked from gemini-cli repo + // note this can only be done with binary linked from qwen-code repo if (process.env['BUILD_SANDBOX']) { if (!gcPath.includes('qwen-code/packages/')) { throw new FatalSandboxError( @@ -389,8 +389,8 @@ export async function start_sandbox( if (!(await ensureSandboxImageIsPresent(config.command, image))) { const remedy = image === LOCAL_DEV_SANDBOX_IMAGE_NAME - ? 'Try running `npm run build:all` or `npm run build:sandbox` under the gemini-cli repo to build it locally, or check the image name and your network connection.' - : 'Please check the image name, your network connection, or notify gemini-cli-dev@google.com if the issue persists.'; + ? 'Try running `npm run build:all` or `npm run build:sandbox` under the qwen-code repo to build it locally, or check the image name and your network connection.' + : 'Please check the image name, your network connection, or notify qwen-code-dev@service.alibaba.com if the issue persists.'; throw new FatalSandboxError( `Sandbox image '${image}' is missing or could not be pulled. ${remedy}`, ); @@ -544,7 +544,7 @@ export async function start_sandbox( process.env['GEMINI_CLI_INTEGRATION_TEST'] === 'true'; let containerName; if (isIntegrationTest) { - containerName = `gemini-cli-integration-test-${randomBytes(4).toString( + containerName = `qwen-code-integration-test-${randomBytes(4).toString( 'hex', )}`; writeStderrLine(`ContainerName: ${containerName}`); @@ -716,10 +716,16 @@ export async function start_sandbox( let userFlag = ''; const finalEntrypoint = entrypoint(workdir, cliArgs); - if (process.env['GEMINI_CLI_INTEGRATION_TEST'] === 'true') { + // Check if we should use current user's UID/GID in sandbox + // In integration test mode, we still respect SANDBOX_SET_UID_GID to allow + // tests that need to access host's ~/.qwen (e.g., --resume functionality) + const useCurrentUser = await shouldUseCurrentUserInSandbox(); + + if (!useCurrentUser) { + // Use root user (default for integration tests or when explicitly disabled) args.push('--user', 'root'); userFlag = '--user root'; - } else if (await shouldUseCurrentUserInSandbox()) { + } else { // For the user-creation logic to work, the container must start as root. // The entrypoint script then handles dropping privileges to the correct user. args.push('--user', 'root'); diff --git a/packages/core/package.json b/packages/core/package.json index 8320a946d..0a49c509d 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -1,6 +1,6 @@ { "name": "@qwen-code/qwen-code-core", - "version": "0.10.1", + "version": "0.10.5", "description": "Qwen Code Core", "repository": { "type": "git", diff --git a/packages/core/src/config/storage.ts b/packages/core/src/config/storage.ts index 8ef0283c5..f9d0107e5 100644 --- a/packages/core/src/config/storage.ts +++ b/packages/core/src/config/storage.ts @@ -6,8 +6,8 @@ import * as path from 'node:path'; import * as os from 'node:os'; -import * as crypto from 'node:crypto'; import * as fs from 'node:fs'; +import { getProjectHash } from '../utils/paths.js'; export const QWEN_DIR = '.qwen'; export const GOOGLE_ACCOUNTS_FILENAME = 'google_accounts.json'; @@ -88,9 +88,10 @@ export class Storage { } getProjectTempDir(): string { - const hash = this.getFilePathHash(this.getProjectRoot()); + const hash = getProjectHash(this.getProjectRoot()); const tempDir = Storage.getGlobalTempDir(); - return path.join(tempDir, hash); + const targetDir = path.join(tempDir, hash); + return targetDir; } ensureProjectTempDirExists(): void { @@ -105,14 +106,11 @@ export class Storage { return this.targetDir; } - private getFilePathHash(filePath: string): string { - return crypto.createHash('sha256').update(filePath).digest('hex'); - } - getHistoryDir(): string { - const hash = this.getFilePathHash(this.getProjectRoot()); + const hash = getProjectHash(this.getProjectRoot()); const historyDir = path.join(Storage.getGlobalQwenDir(), 'history'); - return path.join(historyDir, hash); + const targetDir = path.join(historyDir, hash); + return targetDir; } getWorkspaceSettingsPath(): string { @@ -144,6 +142,8 @@ export class Storage { } private sanitizeCwd(cwd: string): string { - return cwd.replace(/[^a-zA-Z0-9]/g, '-'); + // On Windows, normalize to lowercase for case-insensitive matching + const normalizedCwd = os.platform() === 'win32' ? cwd.toLowerCase() : cwd; + return normalizedCwd.replace(/[^a-zA-Z0-9]/g, '-'); } } diff --git a/packages/core/src/core/logger.test.ts b/packages/core/src/core/logger.test.ts index de3fc3f78..c973c02dd 100644 --- a/packages/core/src/core/logger.test.ts +++ b/packages/core/src/core/logger.test.ts @@ -21,11 +21,11 @@ import { decodeTagName, } from './logger.js'; import { Storage } from '../config/storage.js'; +import { getProjectHash } from '../utils/paths.js'; import { promises as fs, existsSync } from 'node:fs'; import path from 'node:path'; import type { Content } from '@google/genai'; -import crypto from 'node:crypto'; import os from 'node:os'; const GEMINI_DIR_NAME = '.qwen'; @@ -34,7 +34,7 @@ const LOG_FILE_NAME = 'logs.json'; const CHECKPOINT_FILE_NAME = 'checkpoint.json'; const projectDir = process.cwd(); -const hash = crypto.createHash('sha256').update(projectDir).digest('hex'); +const hash = getProjectHash(projectDir); const TEST_HOME_DIR = path.join(os.tmpdir(), 'qwen-core-logger-home'); let originalHome: string | undefined; diff --git a/packages/core/src/core/openaiContentGenerator/provider/openrouter.test.ts b/packages/core/src/core/openaiContentGenerator/provider/openrouter.test.ts index cfd9c59d7..385cdd563 100644 --- a/packages/core/src/core/openaiContentGenerator/provider/openrouter.test.ts +++ b/packages/core/src/core/openaiContentGenerator/provider/openrouter.test.ts @@ -105,7 +105,7 @@ describe('OpenRouterOpenAICompatibleProvider', () => { expect(headers).toEqual({ 'User-Agent': `QwenCode/1.0.0 (${process.platform}; ${process.arch})`, 'HTTP-Referer': 'https://github.com/QwenLM/qwen-code.git', - 'X-Title': 'Qwen Code', + 'X-OpenRouter-Title': 'Qwen Code', }); }); @@ -125,7 +125,7 @@ describe('OpenRouterOpenAICompatibleProvider', () => { expect(headers).toEqual({ 'User-Agent': 'ParentAgent/1.0.0', 'HTTP-Referer': 'https://github.com/QwenLM/qwen-code.git', // OpenRouter-specific value should override - 'X-Title': 'Qwen Code', + 'X-OpenRouter-Title': 'Qwen Code', }); parentBuildHeaders.mockRestore(); @@ -142,7 +142,7 @@ describe('OpenRouterOpenAICompatibleProvider', () => { expect(headers['HTTP-Referer']).toBe( 'https://github.com/QwenLM/qwen-code.git', ); - expect(headers['X-Title']).toBe('Qwen Code'); + expect(headers['X-OpenRouter-Title']).toBe('Qwen Code'); }); }); @@ -215,7 +215,7 @@ describe('OpenRouterOpenAICompatibleProvider', () => { expect(headers['HTTP-Referer']).toBe( 'https://github.com/QwenLM/qwen-code.git', ); // OpenRouter-specific - expect(headers['X-Title']).toBe('Qwen Code'); // OpenRouter-specific + expect(headers['X-OpenRouter-Title']).toBe('Qwen Code'); // OpenRouter-specific }); }); }); diff --git a/packages/core/src/core/openaiContentGenerator/provider/openrouter.ts b/packages/core/src/core/openaiContentGenerator/provider/openrouter.ts index 7eb9d55af..9bf8716f2 100644 --- a/packages/core/src/core/openaiContentGenerator/provider/openrouter.ts +++ b/packages/core/src/core/openaiContentGenerator/provider/openrouter.ts @@ -25,7 +25,7 @@ export class OpenRouterOpenAICompatibleProvider extends DefaultOpenAICompatibleP return { ...baseHeaders, 'HTTP-Referer': 'https://github.com/QwenLM/qwen-code.git', - 'X-Title': 'Qwen Code', + 'X-OpenRouter-Title': 'Qwen Code', }; } } diff --git a/packages/core/src/core/tokenLimits.ts b/packages/core/src/core/tokenLimits.ts index c20bd16a7..ae6cbd9e2 100644 --- a/packages/core/src/core/tokenLimits.ts +++ b/packages/core/src/core/tokenLimits.ts @@ -119,7 +119,10 @@ const PATTERNS: Array<[RegExp, TokenCount]> = [ // Commercial Qwen3-Coder-Flash: 1M token context [/^qwen3-coder-flash(-.*)?$/, LIMITS['1m']], // catches "qwen3-coder-flash" and date variants - // Generic coder-model: same as qwen3-coder-plus (1M token context) + // Commercial Qwen3.5-Plus: 1M token context + [/^qwen3\.5-plus(-.*)?$/, LIMITS['1m']], // catches "qwen3.5-plus" and date variants + + // Generic coder-model: same as qwen3.5-plus (1M token context) [/^coder-model$/, LIMITS['1m']], // Commercial Qwen3-Max-Preview: 256K token context @@ -199,7 +202,10 @@ const OUTPUT_PATTERNS: Array<[RegExp, TokenCount]> = [ // Qwen3-Coder-Plus: 65,536 max output tokens [/^qwen3-coder-plus(-.*)?$/, LIMITS['64k']], - // Generic coder-model: same as qwen3-coder-plus (64K max output tokens) + // Qwen3.5-Plus: 65,536 max output tokens + [/^qwen3\.5-plus(-.*)?$/, LIMITS['64k']], + + // Generic coder-model: same as qwen3.5-plus (64K max output tokens) [/^coder-model$/, LIMITS['64k']], // Qwen3-Max: 65,536 max output tokens diff --git a/packages/core/src/extension/claude-converter.ts b/packages/core/src/extension/claude-converter.ts index 28835bc87..68da9cfff 100644 --- a/packages/core/src/extension/claude-converter.ts +++ b/packages/core/src/extension/claude-converter.ts @@ -100,7 +100,7 @@ const CLAUDE_TOOLS_MAPPING: Record = { Grep: 'Grep', KillShell: 'None', NotebookEdit: 'None', - Read: ['ReadFile', 'ReadManyFiles'], + Read: 'ReadFile', Skill: 'Skill', Task: 'Task', TodoWrite: 'TodoWrite', diff --git a/packages/core/src/models/constants.ts b/packages/core/src/models/constants.ts index 9b4cc2ce7..9e5d15009 100644 --- a/packages/core/src/models/constants.ts +++ b/packages/core/src/models/constants.ts @@ -105,7 +105,8 @@ export const QWEN_OAUTH_MODELS: ModelConfig[] = [ { id: 'coder-model', name: 'coder-model', - description: 'The latest Qwen Coder model from Alibaba Cloud ModelStudio', + description: + 'Qwen 3.5 Plus — efficient hybrid model with leading coding performance', capabilities: { vision: false }, }, { diff --git a/packages/core/src/tools/read-file.ts b/packages/core/src/tools/read-file.ts index 6bd0ddb64..e09a1ac58 100644 --- a/packages/core/src/tools/read-file.ts +++ b/packages/core/src/tools/read-file.ts @@ -21,6 +21,7 @@ import { getProgrammingLanguage } from '../telemetry/telemetry-utils.js'; import { logFileOperation } from '../telemetry/loggers.js'; import { FileOperationEvent } from '../telemetry/types.js'; import { isSubpath } from '../utils/paths.js'; +import { Storage } from '../config/storage.js'; /** * Parameters for the ReadFile tool @@ -183,10 +184,13 @@ export class ReadFileTool extends BaseDeclarativeTool< } const workspaceContext = this.config.getWorkspaceContext(); + const globalTempDir = Storage.getGlobalTempDir(); const projectTempDir = this.config.storage.getProjectTempDir(); const userSkillsDir = this.config.storage.getUserSkillsDir(); const resolvedFilePath = path.resolve(filePath); - const isWithinTempDir = isSubpath(projectTempDir, resolvedFilePath); + const isWithinTempDir = + isSubpath(projectTempDir, resolvedFilePath) || + isSubpath(globalTempDir, resolvedFilePath); const isWithinUserSkills = isSubpath(userSkillsDir, resolvedFilePath); if ( diff --git a/packages/core/src/utils/paths.test.ts b/packages/core/src/utils/paths.test.ts index 1c4ee0225..9f8b63ef9 100644 --- a/packages/core/src/utils/paths.test.ts +++ b/packages/core/src/utils/paths.test.ts @@ -17,6 +17,7 @@ import { isSubpath, shortenPath, tildeifyPath, + getProjectHash, } from './paths.js'; import type { Config } from '../config/config.js'; @@ -770,3 +771,80 @@ describe('shortenPath', () => { expect(result.length).toBeLessThanOrEqual(35); }); }); + +describe('getProjectHash', () => { + it('should generate consistent hashes for the same path', () => { + const projectRoot = '/test/project'; + const hash1 = getProjectHash(projectRoot); + const hash2 = getProjectHash(projectRoot); + + expect(hash1).toBe(hash2); + expect(hash1).toHaveLength(64); // SHA256 produces 64 hex characters + }); + + it('should generate different hashes for different paths', () => { + const hash1 = getProjectHash('/test/project1'); + const hash2 = getProjectHash('/test/project2'); + + expect(hash1).not.toBe(hash2); + }); + + it('should generate case-insensitive hashes on Windows', () => { + const platformSpy = vi.spyOn(os, 'platform'); + + // Simulate Windows platform + platformSpy.mockReturnValue('win32'); + + const lowerCasePath = 'c:\\users\\test\\project'; + const upperCasePath = 'C:\\Users\\Test\\Project'; + const mixedCasePath = 'c:\\Users\\TEST\\project'; + + const hash1 = getProjectHash(lowerCasePath); + const hash2 = getProjectHash(upperCasePath); + const hash3 = getProjectHash(mixedCasePath); + + // On Windows, all different case variations should produce the same hash + expect(hash1).toBe(hash2); + expect(hash2).toBe(hash3); + + platformSpy.mockRestore(); + }); + + it('should generate case-sensitive hashes on non-Windows platforms', () => { + const platformSpy = vi.spyOn(os, 'platform'); + + // Simulate Unix/Linux platform + platformSpy.mockReturnValue('linux'); + + const lowerCasePath = '/home/user/project'; + const upperCasePath = '/HOME/USER/PROJECT'; + + const hash1 = getProjectHash(lowerCasePath); + const hash2 = getProjectHash(upperCasePath); + + // On non-Windows platforms, different case should produce different hashes + expect(hash1).not.toBe(hash2); + + platformSpy.mockRestore(); + }); + + it('should handle Windows drive letter variations', () => { + const platformSpy = vi.spyOn(os, 'platform'); + platformSpy.mockReturnValue('win32'); + + // Common Windows scenarios where users might have different drive letter cases + const scenarios = [ + ['e:\\work', 'E:\\work'], + ['e:\\work', 'E:\\WORK'], + ['c:\\projects\\myapp', 'C:\\Projects\\MyApp'], + ]; + + for (const [path1, path2] of scenarios) { + const hash1 = getProjectHash(path1); + const hash2 = getProjectHash(path2); + expect(hash1).toBe(hash2); + } + + platformSpy.mockRestore(); + }); +}); diff --git a/packages/core/src/utils/paths.ts b/packages/core/src/utils/paths.ts index 6b492c922..96856a5dc 100644 --- a/packages/core/src/utils/paths.ts +++ b/packages/core/src/utils/paths.ts @@ -190,11 +190,16 @@ export function unescapePath(filePath: string): string { /** * Generates a unique hash for a project based on its root path. + * On Windows, paths are case-insensitive, so we normalize to lowercase + * to ensure the same physical path always produces the same hash. * @param projectRoot The absolute path to the project's root directory. * @returns A SHA256 hash of the project root path. */ export function getProjectHash(projectRoot: string): string { - return crypto.createHash('sha256').update(projectRoot).digest('hex'); + // On Windows, normalize path to lowercase for case-insensitive matching + const normalizedPath = + os.platform() === 'win32' ? projectRoot.toLowerCase() : projectRoot; + return crypto.createHash('sha256').update(normalizedPath).digest('hex'); } /** diff --git a/packages/core/src/utils/workspaceContext.ts b/packages/core/src/utils/workspaceContext.ts index 1fe1a6dfe..1b36f3650 100755 --- a/packages/core/src/utils/workspaceContext.ts +++ b/packages/core/src/utils/workspaceContext.ts @@ -137,7 +137,7 @@ export class WorkspaceContext { const fullyResolvedPath = this.fullyResolvedPath(pathToCheck); for (const dir of this.directories) { - if (this.isPathWithinRoot(fullyResolvedPath, dir)) { + if (isPathWithinRoot(fullyResolvedPath, dir)) { return true; } } @@ -171,24 +171,6 @@ export class WorkspaceContext { } } - /** - * Checks if a path is within a given root directory. - * @param pathToCheck The absolute path to check - * @param rootDirectory The absolute root directory - * @returns True if the path is within the root directory, false otherwise - */ - private isPathWithinRoot( - pathToCheck: string, - rootDirectory: string, - ): boolean { - const relative = path.relative(rootDirectory, pathToCheck); - return ( - !relative.startsWith(`..${path.sep}`) && - relative !== '..' && - !path.isAbsolute(relative) - ); - } - /** * Checks if a file path is a symbolic link that points to a file. */ @@ -200,3 +182,21 @@ export class WorkspaceContext { } } } + +/** + * Checks if a path is within a given root directory. + * @param pathToCheck The absolute path to check + * @param rootDirectory The absolute root directory + * @returns True if the path is within the root directory, false otherwise + */ +export function isPathWithinRoot( + pathToCheck: string, + rootDirectory: string, +): boolean { + const relative = path.relative(rootDirectory, pathToCheck); + return ( + !relative.startsWith(`..${path.sep}`) && + relative !== '..' && + !path.isAbsolute(relative) + ); +} diff --git a/packages/test-utils/package.json b/packages/test-utils/package.json index 299ddc611..38514db0e 100644 --- a/packages/test-utils/package.json +++ b/packages/test-utils/package.json @@ -1,6 +1,6 @@ { "name": "@qwen-code/qwen-code-test-utils", - "version": "0.10.1", + "version": "0.10.5", "private": true, "main": "src/index.ts", "license": "Apache-2.0", diff --git a/packages/vscode-ide-companion/package.json b/packages/vscode-ide-companion/package.json index f9e9d040e..a71c08ade 100644 --- a/packages/vscode-ide-companion/package.json +++ b/packages/vscode-ide-companion/package.json @@ -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.10.1", + "version": "0.10.5", "publisher": "qwenlm", "icon": "assets/icon.png", "repository": { diff --git a/packages/vscode-ide-companion/src/services/qwenSessionManager.ts b/packages/vscode-ide-companion/src/services/qwenSessionManager.ts index 9336a060b..a5e817cad 100644 --- a/packages/vscode-ide-companion/src/services/qwenSessionManager.ts +++ b/packages/vscode-ide-companion/src/services/qwenSessionManager.ts @@ -8,6 +8,7 @@ import * as fs from 'fs'; import * as path from 'path'; import * as os from 'os'; import * as crypto from 'crypto'; +import { getProjectHash } from '@qwen-code/qwen-code-core/src/utils/paths.js'; import type { QwenSession, QwenMessage } from './qwenSessionReader.js'; /** @@ -28,19 +29,12 @@ export class QwenSessionManager { } /** - * Calculate project hash (same as CLI) - * Qwen CLI uses SHA256 hash of the project path - */ - private getProjectHash(workingDir: string): string { - return crypto.createHash('sha256').update(workingDir).digest('hex'); - } - - /** - * Get the session directory for a project + * Get the session directory for a project with backward compatibility */ private getSessionDir(workingDir: string): string { - const projectHash = this.getProjectHash(workingDir); - return path.join(this.qwenDir, 'tmp', projectHash, 'chats'); + const projectHash = getProjectHash(workingDir); + const sessionDir = path.join(this.qwenDir, 'tmp', projectHash, 'chats'); + return sessionDir; } /** @@ -87,7 +81,7 @@ export class QwenSessionManager { // Create session object const session: QwenSession = { sessionId, - projectHash: this.getProjectHash(workingDir), + projectHash: getProjectHash(workingDir), startTime: messages[0]?.timestamp || new Date().toISOString(), lastUpdated: new Date().toISOString(), messages, diff --git a/packages/vscode-ide-companion/src/services/qwenSessionReader.ts b/packages/vscode-ide-companion/src/services/qwenSessionReader.ts index 3fc4e484f..0a65b0cb6 100644 --- a/packages/vscode-ide-companion/src/services/qwenSessionReader.ts +++ b/packages/vscode-ide-companion/src/services/qwenSessionReader.ts @@ -8,7 +8,7 @@ import * as fs from 'fs'; import * as path from 'path'; import * as os from 'os'; import * as readline from 'readline'; -import * as crypto from 'crypto'; +import { getProjectHash } from '@qwen-code/qwen-code-core/src/utils/paths.js'; export interface QwenMessage { id: string; @@ -58,7 +58,7 @@ export class QwenSessionReader { if (!allProjects && workingDir) { // Current project only - const projectHash = await this.getProjectHash(workingDir); + const projectHash = getProjectHash(workingDir); const chatsDir = path.join(this.qwenDir, 'tmp', projectHash, 'chats'); const projectSessions = await this.readSessionsFromDir(chatsDir); sessions.push(...projectSessions); @@ -177,14 +177,6 @@ export class QwenSessionReader { return found; } - /** - * Calculate project hash (needs to be consistent with Qwen CLI) - * Qwen CLI uses SHA256 hash of project path - */ - private async getProjectHash(workingDir: string): Promise { - return crypto.createHash('sha256').update(workingDir).digest('hex'); - } - /** * Get session title (based on first user message) */ @@ -289,7 +281,7 @@ export class QwenSessionReader { } const projectHash = cwd - ? await this.getProjectHash(cwd) + ? getProjectHash(cwd) : path.basename(path.dirname(path.dirname(filePath))); return { diff --git a/packages/webui/package.json b/packages/webui/package.json index f4305e230..8895c470d 100644 --- a/packages/webui/package.json +++ b/packages/webui/package.json @@ -1,6 +1,6 @@ { "name": "@qwen-code/webui", - "version": "0.10.1", + "version": "0.10.5", "description": "Shared UI components for Qwen Code packages", "type": "module", "main": "./dist/index.cjs", diff --git a/scripts/installation/install-qwen-with-source.sh b/scripts/installation/install-qwen-with-source.sh index 880ee89a7..0991ec485 100755 --- a/scripts/installation/install-qwen-with-source.sh +++ b/scripts/installation/install-qwen-with-source.sh @@ -1,531 +1,557 @@ #!/bin/bash -# Script to install Node.js and Qwen Code with source information -# This script handles the installation process and sets the installation source +# Qwen Code Installation Script +# This script installs Node.js (via NVM) and Qwen Code CLI +# Supports Linux and macOS # # Usage: install-qwen-with-source.sh --source [github|npm|internal|local-build] # install-qwen-with-source.sh -s [github|npm|internal|local-build] -# Disable pagers to prevent interactive prompts -export GIT_PAGER=cat -export PAGER=cat +# Re-execute with bash if running with sh or other shells +# This block must use POSIX-compliant syntax ([ not [[) since it runs before we know bash is available +if [ -z "${BASH_VERSION}" ] && [ -z "${__QWEN_INSTALL_REEXEC:-}" ]; then + # Check if we're in a git hook environment + case "${0}" in + *.git/hooks/*) export __QWEN_IN_GIT_HOOK=1 ;; + esac + if [ -n "${GIT_DIR:-}" ]; then + export __QWEN_IN_GIT_HOOK=1 + fi -# Enable pipefail to catch errors in pipelines -set -o pipefail + # Try to find bash + if command -v bash >/dev/null 2>&1; then + export __QWEN_INSTALL_REEXEC=1 + # Re-exec with bash, preserving all arguments + exec bash -- "${0}" "$@" + else + echo "Error: This script requires bash. Please install bash first." + exit 1 + fi +fi -# Function to display usage -usage() { - echo "Usage: $0 [OPTIONS]" - echo "" - echo "Options:" - echo " -s, --source SOURCE Specify the installation source (e.g., github, npm, internal)" - echo " -h, --help Show this help message" - echo "" - exit 1 +# Enable strict mode (bash-specific options) +# pipefail requires bash 3+; check before setting +if [ -n "${BASH_VERSION:-}" ]; then + # shellcheck disable=SC3040 + set -eo pipefail +else + set -e +fi + +# ============================================ +# Color definitions +# ============================================ +RED='\033[0;31m' +GREEN='\033[0;32m' +YELLOW='\033[1;33m' +BLUE='\033[0;34m' +NC='\033[0m' # No Color + +# ============================================ +# Log functions +# ============================================ +log_info() { + echo -e "${BLUE}ℹ️ $1${NC}" } +log_success() { + echo -e "${GREEN}✅ $1${NC}" +} + +log_warning() { + echo -e "${YELLOW}⚠️ $1${NC}" +} + +log_error() { + echo -e "${RED}❌ $1${NC}" +} + +# ============================================ +# Utility functions +# ============================================ +command_exists() { + command -v "$1" >/dev/null 2>&1 +} + +get_shell_profile() { + local current_shell + current_shell=$(basename "${SHELL}") + case "${current_shell}" in + bash) + echo "${HOME}/.bashrc" + ;; + zsh) + echo "${HOME}/.zshrc" + ;; + fish) + echo "${HOME}/.config/fish/config.fish" + ;; + *) + echo "${HOME}/.profile" + ;; + esac +} + +# ============================================ # Parse command line arguments +# ============================================ SOURCE="unknown" while [[ $# -gt 0 ]]; do case $1 in -s|--source) if [[ -z "$2" ]] || [[ "$2" == -* ]]; then - echo "Error: --source requires a value" - usage + log_error "--source requires a value" + exit 1 fi SOURCE="$2" shift 2 ;; -h|--help) - usage + echo "Usage: $0 [OPTIONS]" + echo "" + echo "Options:" + echo " -s, --source SOURCE Specify the installation source (e.g., github, npm, internal)" + echo " -h, --help Show this help message" + echo "" + exit 0 ;; *) - usage + log_error "Unknown option: $1" + exit 1 ;; esac done -echo "===========================================" -echo "Qwen Code Installation Script with Source Tracking" -echo "===========================================" +# ============================================ +# Print header +# ============================================ +echo "==========================================" +echo " Qwen Code Installation Script" +echo "==========================================" +echo "" +log_info "System: $(uname -s) $(uname -r)" || true +log_info "Shell: $(basename "${SHELL}")" +echo "" -# Function to check if a command exists -command_exists() { - command -v "$1" >/dev/null 2>&1 -} - -# Function to fix npm global directory permissions -fix_npm_permissions() { - echo "Fixing npm global directory permissions..." - - # Get the actual npm global directory - NPM_GLOBAL_DIR=$(npm config get prefix 2>/dev/null) - if [[ -z "${NPM_GLOBAL_DIR}" ]] || [[ "${NPM_GLOBAL_DIR}" == *"error"* ]]; then - # Fallback to default if npm config fails - NPM_GLOBAL_DIR="${HOME}/.npm-global" - echo "Warning: Could not determine npm prefix, using fallback: ${NPM_GLOBAL_DIR}" +# ============================================ +# Ensure download tool is available +# ============================================ +ensure_download_tool() { + if command_exists curl; then + DOWNLOAD_CMD="curl" + DOWNLOAD_ARGS="-fsSL" + return 0 fi - - # 1. Change ownership of the entire npm global directory to current user - # Using only user ownership without specifying a group for cross-platform compatibility - sudo chown -R "$(whoami)" "${NPM_GLOBAL_DIR}" 2>/dev/null || true - # 2. Fix directory permissions (ensure user has full read/write/execute permissions) - chmod -R u+rwX "${NPM_GLOBAL_DIR}" 2>/dev/null || true + if command_exists wget; then + DOWNLOAD_CMD="wget" + DOWNLOAD_ARGS="-qO -" + return 0 + fi - # 3. Specifically fix parent directory permissions (to prevent mkdir failures) - chmod u+rwx "${NPM_GLOBAL_DIR}" "${NPM_GLOBAL_DIR}/lib" "${NPM_GLOBAL_DIR}/lib/node_modules" 2>/dev/null || true + log_error "Neither curl nor wget found" + log_info "Please install curl or wget manually:" + echo " - macOS: brew install curl" + echo " - Ubuntu/Debian: sudo apt-get install curl" + echo " - CentOS/RHEL: sudo yum install curl" + exit 1 } -# Function to check and install Node.js -install_nodejs() { - if command_exists node; then - NODE_VERSION=$(node --version) - # Extract major version number (remove 'v' prefix and get first number) - NODE_MAJOR_VERSION=$(echo "${NODE_VERSION}" | sed 's/v//' | cut -d'.' -f1) || true +# ============================================ +# Clean npm configuration conflicts +# ============================================ +clean_npmrc_conflict() { + local npmrc="${HOME}/.npmrc" + if [[ -f "${npmrc}" ]]; then + log_info "Cleaning npmrc conflicts..." + grep -Ev '^(prefix|globalconfig) *= *' "${npmrc}" > "${npmrc}.tmp" || true + mv -f "${npmrc}.tmp" "${npmrc}" || true + fi +} - # Check if NODE_MAJOR_VERSION is a valid number - if ! [[ "${NODE_MAJOR_VERSION}" =~ ^[0-9]+$ ]]; then - echo "⚠ Could not parse Node.js version: ${NODE_VERSION}" - echo "Installing Node.js 20+..." - install_nodejs_via_nvm - elif [[ "${NODE_MAJOR_VERSION}" -ge 20 ]]; then - echo "✓ Node.js is already installed: ${NODE_VERSION}" +# ============================================ +# Install NVM +# ============================================ +install_nvm() { + local NVM_DIR="${NVM_DIR:-${HOME}/.nvm}" + local NVM_VERSION="${NVM_VERSION:-v0.40.3}" - # Check npm after confirming Node.js exists - if ! command_exists npm; then - echo "⚠ npm not found, installing npm..." - if install_npm_only; then - echo "✓ npm installation completed" - else - echo "✗ Failed to install npm" - echo "Please install npm manually or reinstall Node.js from: https://nodejs.org/" - exit 1 - fi - else - if NPM_VERSION=$(npm --version 2>/dev/null) && [[ -n "${NPM_VERSION}" ]]; then - echo "✓ npm v${NPM_VERSION} is available" - else - echo "⚠ npm exists but cannot execute, reinstalling..." - if install_npm_only; then - echo "✓ npm installation fixed" - else - echo "✗ Failed to fix npm" - exit 1 - fi - fi - fi + if [[ -s "${NVM_DIR}/nvm.sh" ]]; then + log_info "NVM is already installed at ${NVM_DIR}" + return 0 + fi - # Check if npm global directory has permission issues - if ! npm config get prefix >/dev/null 2>&1; then - fix_npm_permissions - fi + log_info "Installing NVM ${NVM_VERSION}..." - return 0 - else - echo "⚠ Node.js ${NODE_VERSION} is installed, but Qwen Code requires Node.js 20+" - echo "Installing Node.js 20+..." - install_nodejs_via_nvm - fi + # Download and install NVM from Aliyun OSS + # Use temporary file instead of pipe to avoid potential subshell issues + local NVM_INSTALL_TEMP + NVM_INSTALL_TEMP=$(mktemp) + if "${DOWNLOAD_CMD}" "${DOWNLOAD_ARGS}" "https://qwen-code-assets.oss-cn-hangzhou.aliyuncs.com/installation/install_nvm.sh" > "${NVM_INSTALL_TEMP}"; then + # Run the script in current shell environment + # shellcheck source=/dev/null + . "${NVM_INSTALL_TEMP}" + rm -f "${NVM_INSTALL_TEMP}" + log_success "NVM installed successfully" else - echo "Installing Node.js 20+..." - install_nodejs_via_nvm + rm -f "${NVM_INSTALL_TEMP}" + log_error "Failed to install NVM" + log_info "Please install NVM manually: https://github.com/nvm-sh/nvm#install--update-script" + exit 1 fi + + # Configure shell profile + local PROFILE_FILE + PROFILE_FILE=$(get_shell_profile) + + # Check if profile file is writable + if [[ -f "${PROFILE_FILE}" ]] && [[ ! -w "${PROFILE_FILE}" ]]; then + log_warning "Cannot write to ${PROFILE_FILE} (permission denied)" + log_info "Skipping shell profile configuration" + log_info "You may need to manually add NVM configuration to your shell profile" + elif ! grep -q 'NVM_DIR' "${PROFILE_FILE}" 2>/dev/null; then + # shellcheck disable=SC2016 + # The following echo statements intentionally use single quotes to write literal strings + { + echo "" + echo "# NVM configuration (added by Qwen Code installer)" + echo "export NVM_DIR=\"\$HOME/.nvm\"" + echo '[ -s "$NVM_DIR/nvm.sh" ] && \. "$NVM_DIR/nvm.sh"' + echo '[ -s "$NVM_DIR/bash_completion" ] && \. "$NVM_DIR/bash_completion"' + } >> "${PROFILE_FILE}" 2>/dev/null || { + log_warning "Failed to write to ${PROFILE_FILE}" + log_info "Skipping shell profile configuration" + return 0 + } + log_info "Added NVM config to ${PROFILE_FILE}" + fi + + # Load NVM for current session + export NVM_DIR="${NVM_DIR}" + # shellcheck source=/dev/null + [[ -s "${NVM_DIR}/nvm.sh" ]] && \. "${NVM_DIR}/nvm.sh" + + log_success "NVM configured successfully" + return 0 } -# Function to check if NVM installation is complete -check_nvm_complete() { - export NVM_DIR="${HOME}/.nvm" - - if [[ ! -d "${NVM_DIR}" ]]; then - return 1 - fi - - if [[ ! -s "${NVM_DIR}/nvm.sh" ]]; then - echo "⚠ Incomplete NVM: nvm.sh missing" - return 1 - fi +# ============================================ +# Install Node.js via NVM +# ============================================ +install_nodejs_with_nvm() { + local NODE_VERSION="${NODE_VERSION:-20}" + local NVM_DIR="${NVM_DIR:-${HOME}/.nvm}" + # Ensure NVM is loaded + export NVM_DIR="${NVM_DIR}" # shellcheck source=/dev/null - if ! \. "${NVM_DIR}/nvm.sh" 2>/dev/null; then - echo "⚠ Corrupted NVM: cannot load nvm.sh" - return 1 - fi + [[ -s "${NVM_DIR}/nvm.sh" ]] && \. "${NVM_DIR}/nvm.sh" if ! command_exists nvm; then - echo "⚠ Incomplete NVM: nvm command unavailable" + log_error "NVM not loaded properly" return 1 fi + # Set Node.js mirror source for faster downloads in China + export NVM_NODEJS_ORG_MIRROR="https://npmmirror.com/mirrors/node" + + # Install Node.js + log_info "Installing Node.js v${NODE_VERSION}..." + if nvm install "${NODE_VERSION}"; then + nvm alias default "${NODE_VERSION}" || true + nvm use default || true + log_success "Node.js v${NODE_VERSION} installed successfully" + + # Verify installation + log_info "Node.js version: $(node -v)" || true + log_info "npm version: $(npm -v)" || true + + return 0 + else + log_error "Failed to install Node.js" + return 1 + fi +} + +# ============================================ +# Check Node.js version +# ============================================ +check_node_version() { + if ! command_exists node; then + return 1 + fi + + local current_version + current_version=$(node -v | sed 's/v//') + local major_version + major_version=$(echo "${current_version}" | cut -d. -f1) + + if [[ "${major_version}" -ge 20 ]]; then + log_success "Node.js v${current_version} is already installed (>= 20)" + return 0 + else + log_warning "Node.js v${current_version} is installed but version < 20" + return 1 + fi +} + +# ============================================ +# Install Node.js +# ============================================ +install_nodejs() { + local platform + platform=$(uname -s) + + case "${platform}" in + Linux|Darwin) + log_info "Installing Node.js on ${platform}..." + + # Install NVM + if ! install_nvm; then + log_error "Failed to install NVM" + return 1 + fi + + # Load NVM + export NVM_DIR="${HOME}/.nvm" + # shellcheck source=/dev/null + [[ -s "${NVM_DIR}/nvm.sh" ]] && \. "${NVM_DIR}/nvm.sh" + + # Install Node.js + if ! install_nodejs_with_nvm; then + log_error "Failed to install Node.js" + return 1 + fi + ;; + MINGW*|CYGWIN*|MSYS*) + log_error "Windows platform detected. Please use Windows installer or WSL." + log_info "Visit: https://nodejs.org/en/download/" + exit 1 + ;; + *) + log_error "Unsupported platform: ${platform}" + exit 1 + ;; + esac +} + +# ============================================ +# Check and install Node.js +# ============================================ +check_and_install_nodejs() { + if check_node_version; then + log_info "Using existing Node.js installation" + clean_npmrc_conflict + else + log_warning "Installing or upgrading Node.js..." + install_nodejs + fi +} + +# ============================================ +# Fix npm permissions (without using sudo) +# ============================================ +fix_npm_permissions() { + log_info "Checking npm permissions..." + + local NPM_GLOBAL_DIR + NPM_GLOBAL_DIR=$(npm config get prefix 2>/dev/null) || true + if [[ -z "${NPM_GLOBAL_DIR}" ]] || [[ "${NPM_GLOBAL_DIR}" == *"error"* ]]; then + NPM_GLOBAL_DIR="${HOME}/.npm-global" + npm config set prefix "${NPM_GLOBAL_DIR}" + log_info "Set npm prefix to user directory: ${NPM_GLOBAL_DIR}" + return 0 + fi + + # SAFETY CHECK: Never modify system directories + # This prevents catastrophic failures like breaking sudo setuid binaries + case "${NPM_GLOBAL_DIR}" in + /|/usr|/usr/local|/bin|/sbin|/lib|/lib64|/opt|/snap|/var|/etc) + log_warning "npm prefix is a system directory (${NPM_GLOBAL_DIR})." + log_info "Using user directory instead to avoid breaking system binaries." + NPM_GLOBAL_DIR="${HOME}/.npm-global" + npm config set prefix "${NPM_GLOBAL_DIR}" + log_success "npm prefix set to: ${NPM_GLOBAL_DIR}" + return 0 + ;; + *) + # Safe to proceed with non-system directory + ;; + esac + + # Check if npm global directory is writable + if [[ -w "${NPM_GLOBAL_DIR}" ]]; then + log_info "npm global directory is writable" + return 0 + fi + + # If not writable, use user directory + log_warning "npm global directory is not writable: ${NPM_GLOBAL_DIR}" + log_info "Setting npm prefix to user directory..." + + NPM_GLOBAL_DIR="${HOME}/.npm-global" + mkdir -p "${NPM_GLOBAL_DIR}" + npm config set prefix "${NPM_GLOBAL_DIR}" + + log_success "npm prefix set to: ${NPM_GLOBAL_DIR}" + + # Add to PATH in shell profile + local PROFILE_FILE + PROFILE_FILE=$(get_shell_profile) + if ! grep -q '.npm-global/bin' "${PROFILE_FILE}" 2>/dev/null; then + { + echo "" + echo "# NPM global bin (added by Qwen Code installer)" + echo "export PATH=\"\$HOME/.npm-global/bin:\$PATH\"" + } >> "${PROFILE_FILE}" + log_info "Added npm global bin to PATH in ${PROFILE_FILE}" + fi + return 0 } -# Function to uninstall NVM -uninstall_nvm() { - echo "Uninstalling NVM..." - export NVM_DIR="${HOME}/.nvm" - - if [[ -d "${NVM_DIR}" ]]; then - # Try to remove the directory, check for errors - if ! rm -rf "${NVM_DIR}" 2>/dev/null; then - echo "⚠ Failed to remove NVM directory (permission denied or files in use)" - echo " Attempting with elevated permissions..." - # Try with sudo if available - if command -v sudo >/dev/null 2>&1; then - sudo rm -rf "${NVM_DIR}" 2>/dev/null || true - fi - fi - - # Verify removal - if [[ -d "${NVM_DIR}" ]]; then - echo "⚠ Warning: Could not fully remove NVM directory at ${NVM_DIR}" - echo " Some files may be in use by other processes." - echo " Continuing anyway, but installation may fail..." - else - echo "✓ Removed NVM directory" - fi - fi - - # Clean shell configs - for config in "${HOME}/.bashrc" "${HOME}/.bash_profile" "${HOME}/.zshrc" "${HOME}/.profile"; do - if [[ -f "${config}" ]]; then - # shellcheck disable=SC2312 - cp "${config}" "${config}.bak.$(date +%s)" 2>/dev/null - sed -i.tmp '/NVM_DIR/d; /nvm.sh/d; /bash_completion/d' "${config}" 2>/dev/null || \ - sed -i '' '/NVM_DIR/d; /nvm.sh/d; /bash_completion/d' "${config}" 2>/dev/null - rm -f "${config}.tmp" 2>/dev/null || true - fi - done - - # Unset nvm function to avoid conflicts with reinstallation - unset -f nvm 2>/dev/null || true - - echo "✓ Cleaned NVM configuration" -} - -# Function to install npm only -install_npm_only() { - echo "Installing npm separately..." - - if command_exists curl; then - echo "Attempting to install npm using: curl -qL https://www.npmjs.com/install.sh | sh" - if curl -qL https://www.npmjs.com/install.sh | sh; then - NPM_VERSION_TMP=$(npm --version 2>/dev/null) - if command_exists npm && [[ -n "${NPM_VERSION_TMP}" ]]; then - echo "✓ npm v${NPM_VERSION_TMP} installed via direct install script" - return 0 - fi - fi - else - echo "curl command not found, proceeding with alternative methods" - fi - - return 1 -} - -# Function to install Node.js via nvm -install_nodejs_via_nvm() { - export NVM_DIR="${HOME}/.nvm" - - # Check NVM completeness - if [[ -d "${NVM_DIR}" ]]; then - if ! check_nvm_complete; then - echo "Detected incomplete NVM installation" - uninstall_nvm - # If directory still exists after uninstall (partial removal), try to clean it - if [[ -d "${NVM_DIR}" ]]; then - echo " Cleaning up residual NVM files..." - # Remove everything except we can't delete (probably in use) - find "${NVM_DIR}" -mindepth 1 -delete 2>/dev/null || true - # If still can't remove the directory itself, warn but continue - if [[ -d "${NVM_DIR}" ]]; then - echo " Note: Some NVM files are locked by running processes." - echo " Will attempt to install NVM over existing directory..." - fi - fi - else - echo "✓ NVM already installed" - fi - fi - - # Install NVM if needed (either no dir or partial/corrupted) - if [[ ! -d "${NVM_DIR}" ]] || [[ ! -s "${NVM_DIR}/nvm.sh" ]]; then - echo "Downloading NVM..." - - # Use mktemp for secure temporary file creation - # Remove trailing slash from TMPDIR to avoid double slashes - TEMP_DIR="${TMPDIR:-/tmp}" - TEMP_DIR="${TEMP_DIR%/}" - - # Retry mktemp a few times if it fails - TMP_INSTALL_SCRIPT="" - for _ in 1 2 3; do - TMP_INSTALL_SCRIPT=$(mktemp "${TEMP_DIR}/nvm_install.XXXXXXXXXX.sh" 2>/dev/null) - if [[ -n "${TMP_INSTALL_SCRIPT}" ]] && [[ -f "${TMP_INSTALL_SCRIPT}" ]]; then - break - fi - # Wait a bit before retry - sleep 0.1 - done - - # Fallback if mktemp still fails - if [[ -z "${TMP_INSTALL_SCRIPT}" ]]; then - TMP_INSTALL_SCRIPT="${TEMP_DIR}/nvm_install_$$_$(date +%s%N).sh" - touch "${TMP_INSTALL_SCRIPT}" 2>/dev/null || { - echo "✗ Failed to create temporary file" - exit 1 - } - fi - - # Ensure cleanup on exit - trap 'rm -f "${TMP_INSTALL_SCRIPT}"' EXIT - - if curl -f -s -S -o "${TMP_INSTALL_SCRIPT}" "https://qwen-code-assets.oss-cn-hangzhou.aliyuncs.com/installation/install_nvm.sh"; then - if bash "${TMP_INSTALL_SCRIPT}"; then - rm -f "${TMP_INSTALL_SCRIPT}" - trap - EXIT - echo "✓ NVM installed" - else - echo "✗ NVM installation failed" - rm -f "${TMP_INSTALL_SCRIPT}" - trap - EXIT - echo "Please install Node.js manually from: https://nodejs.org/" - exit 1 - fi - else - echo "✗ Failed to download NVM" - rm -f "${TMP_INSTALL_SCRIPT}" - trap - EXIT - echo "Please check your internet connection or install Node.js manually from https://nodejs.org/" - exit 1 - fi - fi - - # Load NVM - if [[ -s "${NVM_DIR}/nvm.sh" ]]; then - # shellcheck source=/dev/null - \. "${NVM_DIR}/nvm.sh" - else - echo "✗ NVM installation failed - nvm.sh not found" - echo "Please install Node.js manually from https://nodejs.org/" - exit 1 - fi - - # shellcheck source=/dev/null - [[ -s "${NVM_DIR}/bash_completion" ]] && \. "${NVM_DIR}/bash_completion" - - # Verify NVM loaded - if ! command_exists nvm; then - echo "✗ Failed to load NVM" - echo "Please manually load NVM or install Node.js from https://nodejs.org/" - exit 1 - fi - - # Install Node.js 20 - echo "Installing Node.js 20..." - if nvm install 20 >/dev/null 2>&1; then - nvm use 20 >/dev/null 2>&1 - nvm alias default 20 >/dev/null 2>&1 - else - echo "✗ Failed to install Node.js 20" - exit 1 - fi - - # Verify Node.js - if ! command_exists node; then - echo "✗ Node.js installation verification failed" - exit 1 - fi - - if ! NODE_VERSION=$(node --version 2>/dev/null) || [[ -z "${NODE_VERSION}" ]]; then - echo "✗ Node.js cannot execute properly" - exit 1 - fi - - echo "✓ Node.js ${NODE_VERSION} installed" - - # Check npm separately - if ! command_exists npm; then - echo "⚠ npm not found" - - if install_npm_only; then - echo "✓ npm installation fixed" - else - echo "✗ Failed to install npm" - echo "Please try:" - echo " 1. Run this script again" - echo " 2. Install Node.js from: https://nodejs.org/" - exit 1 - fi - else - if NPM_VERSION=$(npm --version 2>/dev/null) && [[ -n "${NPM_VERSION}" ]]; then - echo "✓ npm v${NPM_VERSION} installed" - else - echo "⚠ npm exists but cannot execute" - - if install_npm_only; then - echo "✓ npm installation fixed" - else - echo "✗ Failed to fix npm" - exit 1 - fi - fi - fi -} - -# Function to check and install Qwen Code +# ============================================ +# Install Qwen Code +# ============================================ install_qwen_code() { + # Ensure NVM node is in PATH + export NVM_DIR="${HOME}/.nvm" + # shellcheck source=/dev/null + [[ -s "${NVM_DIR}/nvm.sh" ]] && \. "${NVM_DIR}/nvm.sh" 2>/dev/null || true + + # Add npm global bin to PATH + local NPM_GLOBAL_BIN + NPM_GLOBAL_BIN=$(npm bin -g 2>/dev/null) || true + if [[ -n "${NPM_GLOBAL_BIN}" ]]; then + export PATH="${NPM_GLOBAL_BIN}:${PATH}" + fi + if command_exists qwen; then - QWEN_VERSION=$(qwen --version 2>/dev/null || echo "unknown") - echo "✓ Qwen Code is already installed: ${QWEN_VERSION}" - echo " Upgrading to the latest version..." + local QWEN_VERSION + QWEN_VERSION=$(qwen --version 2>/dev/null) || echo "unknown" + log_success "Qwen Code is already installed: ${QWEN_VERSION}" + log_info "Upgrading to the latest version..." fi - # Check if .npmrc contains incompatible settings for nvm - if [[ -f "${HOME}/.npmrc" ]]; then - if grep -q "prefix\|globalconfig" "${HOME}/.npmrc"; then - echo "⚠ Found incompatible settings in ~/.npmrc for NVM" - echo " Creating temporary backup and removing incompatible settings..." - - # Backup .npmrc file - cp "${HOME}/.npmrc" "${HOME}/.npmrc.backup.before.qwen.install" - - # Create temporary .npmrc without incompatible settings - grep -v -E '^(prefix|globalconfig)' "${HOME}/.npmrc" > "${HOME}/.npmrc.temp.for.qwen.install" - - # Use the temporary .npmrc - mv "${HOME}/.npmrc" "${HOME}/.npmrc.original" - mv "${HOME}/.npmrc.temp.for.qwen.install" "${HOME}/.npmrc" - - # Remember to restore later - RESTORE_NPMRC=true + # Clean npmrc conflicts + clean_npmrc_conflict + + # Fix npm permissions if needed + fix_npm_permissions + + # Configure npm registry for faster downloads in China + npm config set registry https://registry.npmmirror.com + log_info "npm registry set to npmmirror" + + # Install Qwen Code + log_info "Installing Qwen Code..." + if npm install -g @qwen-code/qwen-code@latest; then + log_success "Qwen Code installed successfully!" + + # Verify installation + if command_exists qwen; then + local qwen_version + qwen_version=$(qwen --version 2>/dev/null) || qwen_version="unknown" + log_info "Qwen Code version: ${qwen_version}" fi - fi - - echo " Attempting to install Qwen Code with current user permissions..." - if npm install -g @qwen-code/qwen-code@latest 2>/dev/null; then - echo "✓ Qwen Code installed/upgraded successfully!" else - # Installation failed, likely due to permissions - echo " Installation failed with user permissions, attempting to fix permissions..." - - # Fix npm global directory permissions - fix_npm_permissions - - # Try again after fixing permissions - if npm install -g @qwen-code/qwen-code@latest 2>/dev/null; then - echo "✓ Qwen Code installed/upgraded successfully after permission fix!" - else - # Both attempts failed - echo "✗ Failed to install Qwen Code even after permission fix" - echo " Please check your system permissions or contact support" - # Restore .npmrc if we backed it up - if [[ "${RESTORE_NPMRC}" = true ]]; then - mv "${HOME}/.npmrc" "${HOME}/.npmrc.temp.after.failed.install" - mv "${HOME}/.npmrc.original" "${HOME}/.npmrc" - echo " Restored original ~/.npmrc file" - fi - exit 1 - fi + log_error "Failed to install Qwen Code!" + log_info "Please check your internet connection and try again" + exit 1 fi - # Restore original .npmrc file if we modified it - if [[ "${RESTORE_NPMRC}" = true ]]; then - mv "${HOME}/.npmrc" "${HOME}/.npmrc.temp.after.successful.install" - mv "${HOME}/.npmrc.original" "${HOME}/.npmrc" - echo " Restored original ~/.npmrc file" - fi - - # Create/Update source.json only if source parameter was provided + # Create source.json if source parameter was provided if [[ "${SOURCE}" != "unknown" ]]; then create_source_json - else - echo " (Skipping source.json creation - no source specified)" - fi - - # Verify installation - if command_exists qwen; then - QWEN_VERSION=$(qwen --version 2>/dev/null || echo "unknown") - echo "✓ Qwen Code is available as 'qwen' command" - echo " Installed version: ${QWEN_VERSION}" - else - echo "⚠ Qwen Code installed but not in PATH" - echo " You may need to restart your terminal" fi } -# Function to create source.json +# ============================================ +# Create source.json +# ============================================ create_source_json() { - QWEN_DIR="${HOME}/.qwen" + local QWEN_DIR="${HOME}/.qwen" - # Create .qwen directory if it doesn't exist - if [[ ! -d "${QWEN_DIR}" ]]; then - mkdir -p "${QWEN_DIR}" - fi + mkdir -p "${QWEN_DIR}" # Escape special characters in SOURCE for JSON - # Replace backslashes first, then quotes + local ESCAPED_SOURCE ESCAPED_SOURCE=$(printf '%s' "${SOURCE}" | sed 's/\\/\\\\/g; s/"/\\"/g') - # Create source.json file cat > "${QWEN_DIR}/source.json" </dev/null || true + local NPM_GLOBAL_BIN + NPM_GLOBAL_BIN=$(npm bin -g 2>/dev/null) || true + if [[ -n "${NPM_GLOBAL_BIN}" ]]; then + export PATH="${NPM_GLOBAL_BIN}:${PATH}" + fi + # Check if qwen is immediately available if command_exists qwen; then - echo "✓ Qwen Code is ready to use!" + log_success "Qwen Code is ready to use!" echo "" echo "You can now run: qwen" else - echo "⚠ To start using Qwen Code, please run one of the following commands:" + log_warning "To start using Qwen Code, please run:" echo "" - - # Detect user's shell - USER_SHELL=$(basename "${SHELL}") - - if [[ "${USER_SHELL}" = "zsh" ]] && [[ -f "${HOME}/.zshrc" ]]; then - echo " source ~/.zshrc" - elif [[ "${USER_SHELL}" = "bash" ]]; then - if [[ -f "${HOME}/.bash_profile" ]]; then - echo " source ~/.bash_profile" - elif [[ -f "${HOME}/.bashrc" ]]; then - echo " source ~/.bashrc" - fi - else - # Fallback: show all possible options - [[ -f "${HOME}/.zshrc" ]] && echo " source ~/.zshrc" - [[ -f "${HOME}/.bashrc" ]] && echo " source ~/.bashrc" - [[ -f "${HOME}/.bash_profile" ]] && echo " source ~/.bash_profile" - fi - + local PROFILE_FILE + PROFILE_FILE=$(get_shell_profile) + echo " source ${PROFILE_FILE}" echo "" echo "Or simply restart your terminal, then run: qwen" fi } # Run main function -main "$@" \ No newline at end of file +main "$@" diff --git a/scripts/prepare-package.js b/scripts/prepare-package.js index de94e8d81..3ae9d3e08 100644 --- a/scripts/prepare-package.js +++ b/scripts/prepare-package.js @@ -161,6 +161,13 @@ const distPackageJson = { '@lydell/node-pty-linux-x64': '1.1.0', '@lydell/node-pty-win32-arm64': '1.1.0', '@lydell/node-pty-win32-x64': '1.1.0', + '@teddyzhu/clipboard': '0.0.5', + '@teddyzhu/clipboard-darwin-arm64': '0.0.5', + '@teddyzhu/clipboard-darwin-x64': '0.0.5', + '@teddyzhu/clipboard-linux-x64-gnu': '0.0.5', + '@teddyzhu/clipboard-linux-arm64-gnu': '0.0.5', + '@teddyzhu/clipboard-win32-x64-msvc': '0.0.5', + '@teddyzhu/clipboard-win32-arm64-msvc': '0.0.5', }, engines: rootPackageJson.engines, }; diff --git a/scripts/telemetry_utils.js b/scripts/telemetry_utils.js index cb2010d5b..504ed18cb 100644 --- a/scripts/telemetry_utils.js +++ b/scripts/telemetry_utils.js @@ -18,10 +18,21 @@ const __filename = fileURLToPath(import.meta.url); const __dirname = path.dirname(__filename); const projectRoot = path.resolve(__dirname, '..'); -const projectHash = crypto - .createHash('sha256') - .update(projectRoot) - .digest('hex'); + +/** + * Generates a unique hash for a project based on its root path. + * On Windows, paths are case-insensitive, so we normalize to lowercase + * to ensure the same physical path always produces the same hash. + * This logic must match getProjectHash() in packages/core/src/utils/paths.ts + */ +function getProjectHash(projectRoot) { + // On Windows, normalize path to lowercase for case-insensitive matching + const normalizedPath = + os.platform() === 'win32' ? projectRoot.toLowerCase() : projectRoot; + return crypto.createHash('sha256').update(normalizedPath).digest('hex'); +} + +const projectHash = getProjectHash(projectRoot); // User-level .gemini directory in home const USER_GEMINI_DIR = path.join(os.homedir(), '.qwen');