diff --git a/.github/workflows/e2e.yml b/.github/workflows/e2e.yml index fd02bb832..13b71ffa3 100644 --- a/.github/workflows/e2e.yml +++ b/.github/workflows/e2e.yml @@ -18,8 +18,6 @@ jobs: - 'sandbox:docker' node-version: - '20.x' - - '22.x' - - '24.x' steps: - name: 'Checkout' uses: 'actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8' # ratchet:actions/checkout@v5 @@ -67,10 +65,13 @@ jobs: OPENAI_BASE_URL: '${{ secrets.OPENAI_BASE_URL }}' OPENAI_MODEL: '${{ secrets.OPENAI_MODEL }}' KEEP_OUTPUT: 'true' - SANDBOX: '${{ matrix.sandbox }}' VERBOSE: 'true' run: |- - npm run "test:integration:${SANDBOX}" + if [[ "${{ matrix.sandbox }}" == "sandbox:docker" ]]; then + npm run test:integration:sandbox:docker + else + npm run test:integration:sandbox:none + fi e2e-test-macos: name: 'E2E Test - macOS' diff --git a/.github/workflows/release-sdk.yml b/.github/workflows/release-sdk.yml index 691925200..7ae0e2d03 100644 --- a/.github/workflows/release-sdk.yml +++ b/.github/workflows/release-sdk.yml @@ -33,6 +33,10 @@ on: type: 'boolean' default: false +concurrency: + group: '${{ github.workflow }}' + cancel-in-progress: false + jobs: release-sdk: runs-on: 'ubuntu-latest' @@ -46,6 +50,7 @@ jobs: packages: 'write' id-token: 'write' issues: 'write' + pull-requests: 'write' outputs: RELEASE_TAG: '${{ steps.version.outputs.RELEASE_TAG }}' @@ -121,6 +126,11 @@ jobs: IS_PREVIEW: '${{ steps.vars.outputs.is_preview }}' MANUAL_VERSION: '${{ inputs.version }}' + - name: 'Build CLI Bundle' + run: | + npm run build + npm run bundle + - name: 'Run Tests' if: |- ${{ github.event.inputs.force_skip_tests != 'true' }} @@ -132,13 +142,6 @@ jobs: OPENAI_BASE_URL: '${{ secrets.OPENAI_BASE_URL }}' OPENAI_MODEL: '${{ secrets.OPENAI_MODEL }}' - - name: 'Build CLI for Integration Tests' - if: |- - ${{ github.event.inputs.force_skip_tests != 'true' }} - run: | - npm run build - npm run bundle - - name: 'Run SDK Integration Tests' if: |- ${{ github.event.inputs.force_skip_tests != 'true' }} @@ -165,11 +168,11 @@ jobs: echo "BRANCH_NAME=${BRANCH_NAME}" >> "${GITHUB_OUTPUT}" - name: 'Update package version' - working-directory: 'packages/sdk-typescript' env: RELEASE_VERSION: '${{ steps.version.outputs.RELEASE_VERSION }}' run: |- - npm version "${RELEASE_VERSION}" --no-git-tag-version --allow-same-version + # Use npm workspaces so the root lockfile is updated consistently. + npm version -w @qwen-code/sdk "${RELEASE_VERSION}" --no-git-tag-version --allow-same-version - name: 'Commit and Conditionally Push package version' env: @@ -177,7 +180,7 @@ jobs: IS_DRY_RUN: '${{ steps.vars.outputs.is_dry_run }}' RELEASE_TAG: '${{ steps.version.outputs.RELEASE_TAG }}' run: |- - git add packages/sdk-typescript/package.json + git add packages/sdk-typescript/package.json package-lock.json if git diff --staged --quiet; then echo "No version changes to commit" else @@ -224,6 +227,49 @@ jobs: --notes-start-tag "sdk-typescript-${PREVIOUS_RELEASE_TAG}" \ --generate-notes + - name: 'Create PR to merge release branch into main' + if: |- + ${{ steps.vars.outputs.is_dry_run == 'false' }} + id: 'pr' + env: + GITHUB_TOKEN: '${{ secrets.GITHUB_TOKEN }}' + RELEASE_BRANCH: '${{ steps.release_branch.outputs.BRANCH_NAME }}' + RELEASE_TAG: '${{ steps.version.outputs.RELEASE_TAG }}' + run: |- + set -euo pipefail + + pr_url="$(gh pr list --head "${RELEASE_BRANCH}" --base main --json url --jq '.[0].url')" + if [[ -z "${pr_url}" ]]; then + pr_url="$(gh pr create \ + --base main \ + --head "${RELEASE_BRANCH}" \ + --title "chore(release): sdk-typescript ${RELEASE_TAG}" \ + --body "Automated release PR for sdk-typescript ${RELEASE_TAG}.")" + fi + + echo "PR_URL=${pr_url}" >> "${GITHUB_OUTPUT}" + + - name: 'Wait for CI checks to complete' + if: |- + ${{ steps.vars.outputs.is_dry_run == 'false' }} + env: + GITHUB_TOKEN: '${{ secrets.GITHUB_TOKEN }}' + PR_URL: '${{ steps.pr.outputs.PR_URL }}' + run: |- + set -euo pipefail + echo "Waiting for CI checks to complete..." + gh pr checks "${PR_URL}" --watch --interval 30 + + - name: 'Enable auto-merge for release PR' + if: |- + ${{ steps.vars.outputs.is_dry_run == 'false' }} + env: + GITHUB_TOKEN: '${{ secrets.GITHUB_TOKEN }}' + PR_URL: '${{ steps.pr.outputs.PR_URL }}' + run: |- + set -euo pipefail + gh pr merge "${PR_URL}" --merge --auto + - name: 'Create Issue on Failure' if: |- ${{ failure() }} diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 0c4ff85a0..ffcda3dc0 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -133,8 +133,8 @@ jobs: ${{ github.event.inputs.force_skip_tests != 'true' }} run: | npm run preflight - npm run test:integration:sandbox:none - npm run test:integration:sandbox:docker + npm run test:integration:cli:sandbox:none + npm run test:integration:cli:sandbox:docker env: OPENAI_API_KEY: '${{ secrets.OPENAI_API_KEY }}' OPENAI_BASE_URL: '${{ secrets.OPENAI_BASE_URL }}' diff --git a/.gitignore b/.gitignore index 2c3156b96..fac00d412 100644 --- a/.gitignore +++ b/.gitignore @@ -57,3 +57,8 @@ gha-creds-*.json # Log files patch_output.log + +# docs build +docs-site/.next +# content is a symlink to ../docs +docs-site/content diff --git a/.vscode/launch.json b/.vscode/launch.json index 0ae4f1b14..bab4f22e0 100644 --- a/.vscode/launch.json +++ b/.vscode/launch.json @@ -27,7 +27,7 @@ "outFiles": [ "${workspaceFolder}/packages/vscode-ide-companion/dist/**/*.js" ], - "preLaunchTask": "npm: build: vscode-ide-companion" + "preLaunchTask": "launch: vscode-ide-companion (copy+build)" }, { "name": "Attach", diff --git a/.vscode/tasks.json b/.vscode/tasks.json index 58709bc92..e0ee47302 100644 --- a/.vscode/tasks.json +++ b/.vscode/tasks.json @@ -20,6 +20,22 @@ "problemMatcher": [], "label": "npm: build: vscode-ide-companion", "detail": "npm run build -w packages/vscode-ide-companion" + }, + { + "label": "copy: bundled-cli (dev)", + "type": "shell", + "command": "node", + "args": ["packages/vscode-ide-companion/scripts/copy-bundled-cli.js"], + "problemMatcher": [] + }, + { + "label": "launch: vscode-ide-companion (copy+build)", + "dependsOrder": "sequence", + "dependsOn": [ + "copy: bundled-cli (dev)", + "npm: build: vscode-ide-companion" + ], + "problemMatcher": [] } ] } diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index a8f053ee7..84aa5520b 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -2,27 +2,6 @@ We would love to accept your patches and contributions to this project. -## Before you begin - -### Sign our Contributor License Agreement - -Contributions to this project must be accompanied by a -[Contributor License Agreement](https://cla.developers.google.com/about) (CLA). -You (or your employer) retain the copyright to your contribution; this simply -gives us permission to use and redistribute your contributions as part of the -project. - -If you or your current employer have already signed the Google CLA (even if it -was for a different project), you probably don't need to do it again. - -Visit to see your current agreements or to -sign a new one. - -### Review our Community Guidelines - -This project follows [Google's Open Source Community -Guidelines](https://opensource.google/conduct/). - ## Contribution Process ### Code Reviews @@ -74,12 +53,6 @@ Your PR should have a clear, descriptive title and a detailed description of the In the PR description, explain the "why" behind your changes and link to the relevant issue (e.g., `Fixes #123`). -## Forking - -If you are forking the repository you will be able to run the Build, Test and Integration test workflows. However in order to make the integration tests run you'll need to add a [GitHub Repository Secret](https://docs.github.com/en/actions/security-for-github-actions/security-guides/using-secrets-in-github-actions#creating-secrets-for-a-repository) with a value of `GEMINI_API_KEY` and set that to a valid API key that you have available. Your key and secret are private to your repo; no one without access can see your key and you cannot see any secrets related to this repo. - -Additionally you will need to click on the `Actions` tab and enable workflows for your repository, you'll find it's the large blue button in the center of the screen. - ## Development Setup and Workflow This section guides contributors on how to build, modify, and understand the development setup of this project. @@ -98,8 +71,8 @@ This section guides contributors on how to build, modify, and understand the dev To clone the repository: ```bash -git clone https://github.com/google-gemini/gemini-cli.git # Or your fork's URL -cd gemini-cli +git clone https://github.com/QwenLM/qwen-code.git # Or your fork's URL +cd qwen-code ``` To install dependencies defined in `package.json` as well as root dependencies: @@ -118,9 +91,9 @@ This command typically compiles TypeScript to JavaScript, bundles assets, and pr ### Enabling Sandboxing -[Sandboxing](#sandboxing) is highly recommended and requires, at a minimum, setting `GEMINI_SANDBOX=true` in your `~/.env` and ensuring a sandboxing provider (e.g. `macOS Seatbelt`, `docker`, or `podman`) is available. See [Sandboxing](#sandboxing) for details. +[Sandboxing](#sandboxing) is highly recommended and requires, at a minimum, setting `QWEN_SANDBOX=true` in your `~/.env` and ensuring a sandboxing provider (e.g. `macOS Seatbelt`, `docker`, or `podman`) is available. See [Sandboxing](#sandboxing) for details. -To build both the `gemini` CLI utility and the sandbox container, run `build:all` from the root directory: +To build both the `qwen-code` CLI utility and the sandbox container, run `build:all` from the root directory: ```bash npm run build:all @@ -130,13 +103,13 @@ To skip building the sandbox container, you can use `npm run build` instead. ### Running -To start the Gemini CLI from the source code (after building), run the following command from the root directory: +To start the Qwen Code application from the source code (after building), run the following command from the root directory: ```bash npm start ``` -If you'd like to run the source build outside of the gemini-cli folder, you can utilize `npm link path/to/gemini-cli/packages/cli` (see: [docs](https://docs.npmjs.com/cli/v9/commands/npm-link)) or `alias gemini="node path/to/gemini-cli/packages/cli"` to run with `gemini` +If you'd like to run the source build outside of the qwen-code folder, you can utilize `npm link path/to/qwen-code/packages/cli` (see: [docs](https://docs.npmjs.com/cli/v9/commands/npm-link)) to run with `qwen-code` ### Running Tests @@ -154,7 +127,7 @@ This will run tests located in the `packages/core` and `packages/cli` directorie #### Integration Tests -The integration tests are designed to validate the end-to-end functionality of the Gemini CLI. They are not run as part of the default `npm run test` command. +The integration tests are designed to validate the end-to-end functionality of Qwen Code. They are not run as part of the default `npm run test` command. To run the integration tests, use the following command: @@ -209,19 +182,61 @@ npm run lint ### Coding Conventions - Please adhere to the coding style, patterns, and conventions used throughout the existing codebase. -- Consult [QWEN.md](https://github.com/QwenLM/qwen-code/blob/main/QWEN.md) (typically found in the project root) for specific instructions related to AI-assisted development, including conventions for React, comments, and Git usage. - **Imports:** Pay special attention to import paths. The project uses ESLint to enforce restrictions on relative imports between packages. ### Project Structure - `packages/`: Contains the individual sub-packages of the project. - `cli/`: The command-line interface. - - `core/`: The core backend logic for the Gemini CLI. + - `core/`: The core backend logic for Qwen Code. - `docs/`: Contains all project documentation. - `scripts/`: Utility scripts for building, testing, and development tasks. For more detailed architecture, see `docs/architecture.md`. +## Documentation Development + +This section describes how to develop and preview the documentation locally. + +### Prerequisites + +1. Ensure you have Node.js (version 18+) installed +2. Have npm or yarn available + +### Setup Documentation Site Locally + +To work on the documentation and preview changes locally: + +1. Navigate to the `docs-site` directory: + + ```bash + cd docs-site + ``` + +2. Install dependencies: + + ```bash + npm install + ``` + +3. Link the documentation content from the main `docs` directory: + + ```bash + npm run link + ``` + + This creates a symbolic link from `../docs` to `content` in the docs-site project, allowing the documentation content to be served by the Next.js site. + +4. Start the development server: + + ```bash + npm run dev + ``` + +5. Open [http://localhost:3000](http://localhost:3000) in your browser to see the documentation site with live updates as you make changes. + +Any changes made to the documentation files in the main `docs` directory will be reflected immediately in the documentation site. + ## Debugging ### VS Code: @@ -231,7 +246,7 @@ For more detailed architecture, see `docs/architecture.md`. ```bash npm run debug ``` - This command runs `node --inspect-brk dist/gemini.js` within the `packages/cli` directory, pausing execution until a debugger attaches. You can then open `chrome://inspect` in your Chrome browser to connect to the debugger. + This command runs `node --inspect-brk dist/index.js` within the `packages/cli` directory, pausing execution until a debugger attaches. You can then open `chrome://inspect` in your Chrome browser to connect to the debugger. 2. In VS Code, use the "Attach" launch configuration (found in `.vscode/launch.json`). Alternatively, you can use the "Launch Program" configuration in VS Code if you prefer to launch the currently open file directly, but 'F5' is generally recommended. @@ -239,16 +254,16 @@ Alternatively, you can use the "Launch Program" configuration in VS Code if you To hit a breakpoint inside the sandbox container run: ```bash -DEBUG=1 gemini +DEBUG=1 qwen-code ``` -**Note:** If you have `DEBUG=true` in a project's `.env` file, it won't affect gemini-cli due to automatic exclusion. Use `.gemini/.env` files for gemini-cli specific debug settings. +**Note:** If you have `DEBUG=true` in a project's `.env` file, it won't affect qwen-code due to automatic exclusion. Use `.qwen-code/.env` files for qwen-code specific debug settings. ### React DevTools To debug the CLI's React-based UI, you can use React DevTools. Ink, the library used for the CLI's interface, is compatible with React DevTools version 4.x. -1. **Start the Gemini CLI in development mode:** +1. **Start the Qwen Code application in development mode:** ```bash DEV=true npm start @@ -270,23 +285,10 @@ To debug the CLI's React-based UI, you can use React DevTools. Ink, the library ``` Your running CLI application should then connect to React DevTools. - ![](/docs/assets/connected_devtools.png) ## Sandboxing -### macOS Seatbelt - -On macOS, `qwen` uses Seatbelt (`sandbox-exec`) under a `permissive-open` profile (see `packages/cli/src/utils/sandbox-macos-permissive-open.sb`) that restricts writes to the project folder but otherwise allows all other operations and outbound network traffic ("open") by default. You can switch to a `restrictive-closed` profile (see `packages/cli/src/utils/sandbox-macos-restrictive-closed.sb`) that declines all operations and outbound network traffic ("closed") by default by setting `SEATBELT_PROFILE=restrictive-closed` in your environment or `.env` file. Available built-in profiles are `{permissive,restrictive}-{open,closed,proxied}` (see below for proxied networking). You can also switch to a custom profile `SEATBELT_PROFILE=` if you also create a file `.qwen/sandbox-macos-.sb` under your project settings directory `.qwen`. - -### Container-based Sandboxing (All Platforms) - -For stronger container-based sandboxing on macOS or other platforms, you can set `GEMINI_SANDBOX=true|docker|podman|` in your environment or `.env` file. The specified command (or if `true` then either `docker` or `podman`) must be installed on the host machine. Once enabled, `npm run build:all` will build a minimal container ("sandbox") image and `npm start` will launch inside a fresh instance of that container. The first build can take 20-30s (mostly due to downloading of the base image) but after that both build and start overhead should be minimal. Default builds (`npm run build`) will not rebuild the sandbox. - -Container-based sandboxing mounts the project directory (and system temp directory) with read-write access and is started/stopped/removed automatically as you start/stop Gemini CLI. Files created within the sandbox should be automatically mapped to your user/group on host machine. You can easily specify additional mounts, ports, or environment variables by setting `SANDBOX_{MOUNTS,PORTS,ENV}` as needed. You can also fully customize the sandbox for your projects by creating the files `.qwen/sandbox.Dockerfile` and/or `.qwen/sandbox.bashrc` under your project settings directory (`.qwen`) and running `qwen` with `BUILD_SANDBOX=1` to trigger building of your custom sandbox. - -#### Proxied Networking - -All sandboxing methods, including macOS Seatbelt using `*-proxied` profiles, support restricting outbound network traffic through a custom proxy server that can be specified as `GEMINI_SANDBOX_PROXY_COMMAND=`, where `` must start a proxy server that listens on `:::8877` for relevant requests. See `docs/examples/proxy-script.md` for a minimal proxy that only allows `HTTPS` connections to `example.com:443` (e.g. `curl https://example.com`) and declines all other requests. The proxy is started and stopped automatically alongside the sandbox. +> TBD ## Manual Publish diff --git a/Makefile b/Makefile index fc540ccc7..db8cba23d 100644 --- a/Makefile +++ b/Makefile @@ -1,9 +1,9 @@ -# Makefile for gemini-cli +# Makefile for qwen-code .PHONY: help install build build-sandbox build-all test lint format preflight clean start debug release run-npx create-alias help: - @echo "Makefile for gemini-cli" + @echo "Makefile for qwen-code" @echo "" @echo "Usage:" @echo " make install - Install npm dependencies" @@ -14,11 +14,11 @@ help: @echo " make format - Format the code" @echo " make preflight - Run formatting, linting, and tests" @echo " make clean - Remove generated files" - @echo " make start - Start the Gemini CLI" - @echo " make debug - Start the Gemini CLI in debug mode" + @echo " make start - Start the Qwen Code CLI" + @echo " make debug - Start the Qwen Code CLI in debug mode" @echo "" @echo " make run-npx - Run the CLI using npx (for testing the published package)" - @echo " make create-alias - Create a 'gemini' alias for your shell" + @echo " make create-alias - Create a 'qwen' alias for your shell" install: npm install diff --git a/README.md b/README.md index c6230b961..022ea7279 100644 --- a/README.md +++ b/README.md @@ -1,88 +1,48 @@ -# Qwen Code -
-![Qwen Code Screenshot](./docs/assets/qwen-screenshot.png) - [![npm version](https://img.shields.io/npm/v/@qwen-code/qwen-code.svg)](https://www.npmjs.com/package/@qwen-code/qwen-code) [![License](https://img.shields.io/github/license/QwenLM/qwen-code.svg)](./LICENSE) [![Node.js Version](https://img.shields.io/badge/node-%3E%3D20.0.0-brightgreen.svg)](https://nodejs.org/) [![Downloads](https://img.shields.io/npm/dm/@qwen-code/qwen-code.svg)](https://www.npmjs.com/package/@qwen-code/qwen-code) -**AI-powered command-line workflow tool for developers** +**An open-source AI agent that lives in your terminal.** -[Installation](#installation) • [Quick Start](#quick-start) • [Features](#key-features) • [Documentation](./docs/) • [Contributing](./CONTRIBUTING.md) +中文 | +Deutsch | +français | +日本語 | +Русский | +Português (Brasil)
- +Qwen Code is an open-source AI agent for the terminal, optimized for [Qwen3-Coder](https://github.com/QwenLM/Qwen3-Coder). It helps you understand large codebases, automate tedious work, and ship faster. -Qwen Code is a powerful command-line AI workflow tool adapted from [**Gemini CLI**](https://github.com/google-gemini/gemini-cli), specifically optimized for [Qwen3-Coder](https://github.com/QwenLM/Qwen3-Coder) models. It enhances your development workflow with advanced code understanding, automated tasks, and intelligent assistance. +![](https://gw.alicdn.com/imgextra/i1/O1CN01D2DviS1wwtEtMwIzJ_!!6000000006373-2-tps-1600-900.png) -## 💡 Free Options Available +## Why Qwen Code? -Get started with Qwen Code at no cost using any of these free options: - -### 🔥 Qwen OAuth (Recommended) - -- **2,000 requests per day** with no token limits -- **60 requests per minute** rate limit -- Simply run `qwen` and authenticate with your qwen.ai account -- Automatic credential management and refresh -- Use `/auth` command to switch to Qwen OAuth if you have initialized with OpenAI compatible mode - -### 🌏 Regional Free Tiers - -- **Mainland China**: ModelScope offers **2,000 free API calls per day** -- **International**: OpenRouter provides **up to 1,000 free API calls per day** worldwide - -For detailed setup instructions, see [Authorization](#authorization). - -> [!WARNING] -> **Token Usage Notice**: Qwen Code may issue multiple API calls per cycle, resulting in higher token usage (similar to Claude Code). We're actively optimizing API efficiency. - -## Key Features - -- **Code Understanding & Editing** - Query and edit large codebases beyond traditional context window limits -- **Workflow Automation** - Automate operational tasks like handling pull requests and complex rebases -- **Enhanced Parser** - Adapted parser specifically optimized for Qwen-Coder models -- **Vision Model Support** - Automatically detect images in your input and seamlessly switch to vision-capable models for multimodal analysis +- **OpenAI-compatible, OAuth free tier**: use an OpenAI-compatible API, or sign in with Qwen OAuth to get 2,000 free requests/day. +- **Open-source, co-evolving**: both the framework and the Qwen3-Coder model are open-source—and they ship and evolve together. +- **Agentic workflow, feature-rich**: rich built-in tools (Skills, SubAgents, Plan Mode) for a full agentic workflow and a Claude Code-like experience. +- **Terminal-first, IDE-friendly**: built for developers who live in the command line, with optional integration for VS Code and Zed. ## Installation -### Prerequisites - -Ensure you have [Node.js version 20](https://nodejs.org/en/download) or higher installed. +#### Prerequisites ```bash +# Node.js 20+ curl -qL https://www.npmjs.com/install.sh | sh ``` -### Install from npm +#### NPM (recommended) ```bash npm install -g @qwen-code/qwen-code@latest -qwen --version ``` -### Install from source - -```bash -git clone https://github.com/QwenLM/qwen-code.git -cd qwen-code -npm install -npm install -g . -``` - -### Install globally with Homebrew (macOS/Linux) +#### Homebrew (macOS, Linux) ```bash brew install qwen-code @@ -91,286 +51,102 @@ brew install qwen-code ## Quick Start ```bash -# Start Qwen Code +# Start Qwen Code (interactive) qwen -# Example commands -> Explain this codebase structure -> Help me refactor this function -> Generate unit tests for this module +# Then, in the session: +/help +/auth ``` -### Session Management +On first use, you'll be prompted to sign in. You can run `/auth` anytime to switch authentication methods. -Control your token usage with configurable session limits to optimize costs and performance. +Example prompts: -#### Configure Session Token Limit - -Create or edit `.qwen/settings.json` in your home directory: - -```json -{ - "sessionTokenLimit": 32000 -} +```text +What does this project do? +Explain the codebase structure. +Help me refactor this function. +Generate unit tests for this module. ``` -#### Session Commands - -- **`/compress`** - Compress conversation history to continue within token limits -- **`/clear`** - Clear all conversation history and start fresh -- **`/stats`** - Check current token usage and limits - -> 📝 **Note**: Session token limit applies to a single conversation, not cumulative API calls. - -### Vision Model Configuration - -Qwen Code includes intelligent vision model auto-switching that detects images in your input and can automatically switch to vision-capable models for multimodal analysis. **This feature is enabled by default** - when you include images in your queries, you'll see a dialog asking how you'd like to handle the vision model switch. - -#### Skip the Switch Dialog (Optional) - -If you don't want to see the interactive dialog each time, configure the default behavior in your `.qwen/settings.json`: - -```json -{ - "experimental": { - "vlmSwitchMode": "once" - } -} -``` - -**Available modes:** - -- **`"once"`** - Switch to vision model for this query only, then revert -- **`"session"`** - Switch to vision model for the entire session -- **`"persist"`** - Continue with current model (no switching) -- **Not set** - Show interactive dialog each time (default) - -#### Command Line Override - -You can also set the behavior via command line: - -```bash -# Switch once per query -qwen --vlm-switch-mode once - -# Switch for entire session -qwen --vlm-switch-mode session - -# Never switch automatically -qwen --vlm-switch-mode persist -``` - -#### Disable Vision Models (Optional) - -To completely disable vision model support, add to your `.qwen/settings.json`: - -```json -{ - "experimental": { - "visionModelPreview": false - } -} -``` - -> 💡 **Tip**: In YOLO mode (`--yolo`), vision switching happens automatically without prompts when images are detected. - -### Authorization - -Choose your preferred authentication method based on your needs: - -#### 1. Qwen OAuth (🚀 Recommended - Start in 30 seconds) - -The easiest way to get started - completely free with generous quotas: - -```bash -# Just run this command and follow the browser authentication -qwen -``` - -**What happens:** - -1. **Instant Setup**: CLI opens your browser automatically -2. **One-Click Login**: Authenticate with your qwen.ai account -3. **Automatic Management**: Credentials cached locally for future use -4. **No Configuration**: Zero setup required - just start coding! - -**Free Tier Benefits:** - -- ✅ **2,000 requests/day** (no token counting needed) -- ✅ **60 requests/minute** rate limit -- ✅ **Automatic credential refresh** -- ✅ **Zero cost** for individual users -- ℹ️ **Note**: Model fallback may occur to maintain service quality - -#### 2. OpenAI-Compatible API - -Use API keys for OpenAI or other compatible providers: - -**Configuration Methods:** - -1. **Environment Variables** - - ```bash - export OPENAI_API_KEY="your_api_key_here" - export OPENAI_BASE_URL="your_api_endpoint" - export OPENAI_MODEL="your_model_choice" - ``` - -2. **Project `.env` File** - Create a `.env` file in your project root: - ```env - OPENAI_API_KEY=your_api_key_here - OPENAI_BASE_URL=your_api_endpoint - OPENAI_MODEL=your_model_choice - ``` - -**API Provider Options** - -> ⚠️ **Regional Notice:** -> -> - **Mainland China**: Use Alibaba Cloud Bailian or ModelScope -> - **International**: Use Alibaba Cloud ModelStudio or OpenRouter -
-🇨🇳 For Users in Mainland China +Click to watch a demo video -**Option 1: Alibaba Cloud Bailian** ([Apply for API Key](https://bailian.console.aliyun.com/)) - -```bash -export OPENAI_API_KEY="your_api_key_here" -export OPENAI_BASE_URL="https://dashscope.aliyuncs.com/compatible-mode/v1" -export OPENAI_MODEL="qwen3-coder-plus" -``` - -**Option 2: ModelScope (Free Tier)** ([Apply for API Key](https://modelscope.cn/docs/model-service/API-Inference/intro)) - -- ✅ **2,000 free API calls per day** -- ⚠️ Connect your Aliyun account to avoid authentication errors - -```bash -export OPENAI_API_KEY="your_api_key_here" -export OPENAI_BASE_URL="https://api-inference.modelscope.cn/v1" -export OPENAI_MODEL="Qwen/Qwen3-Coder-480B-A35B-Instruct" -``` +
-
-🌍 For International Users +## Authentication -**Option 1: Alibaba Cloud ModelStudio** ([Apply for API Key](https://modelstudio.console.alibabacloud.com/)) +Qwen Code supports two authentication methods: + +- **Qwen OAuth (recommended & free)**: sign in with your `qwen.ai` account in a browser. +- **OpenAI-compatible API**: use `OPENAI_API_KEY` (and optionally a custom base URL / model). + +#### Qwen OAuth (recommended) + +Start `qwen`, then run: ```bash -export OPENAI_API_KEY="your_api_key_here" -export OPENAI_BASE_URL="https://dashscope-intl.aliyuncs.com/compatible-mode/v1" -export OPENAI_MODEL="qwen3-coder-plus" +/auth ``` -**Option 2: OpenRouter (Free Tier Available)** ([Apply for API Key](https://openrouter.ai/)) +Choose **Qwen OAuth** and complete the browser flow. Your credentials are cached locally so you usually won't need to log in again. + +#### OpenAI-compatible API (API key) + +Environment variables (recommended for CI / headless environments): ```bash -export OPENAI_API_KEY="your_api_key_here" -export OPENAI_BASE_URL="https://openrouter.ai/api/v1" -export OPENAI_MODEL="qwen/qwen3-coder:free" +export OPENAI_API_KEY="your-api-key-here" +export OPENAI_BASE_URL="https://api.openai.com/v1" # optional +export OPENAI_MODEL="gpt-4o" # optional ``` -
+For details (including `.qwen/.env` loading and security notes), see the [authentication guide](https://qwenlm.github.io/qwen-code-docs/en/users/configuration/auth/). -## Usage Examples +## Usage -### 🔍 Explore Codebases +As an open-source terminal agent, you can use Qwen Code in four primary ways: + +1. Interactive mode (terminal UI) +2. Headless mode (scripts, CI) +3. IDE integration (VS Code, Zed) +4. TypeScript SDK + +#### Interactive mode ```bash cd your-project/ qwen - -# Architecture analysis -> Describe the main pieces of this system's architecture -> What are the key dependencies and how do they interact? -> Find all API endpoints and their authentication methods ``` -### 💻 Code Development +Run `qwen` in your project folder to launch the interactive terminal UI. Use `@` to reference local files (for example `@src/main.ts`). + +#### Headless mode ```bash -# Refactoring -> Refactor this function to improve readability and performance -> Convert this class to use dependency injection -> Split this large module into smaller, focused components - -# Code generation -> Create a REST API endpoint for user management -> Generate unit tests for the authentication module -> Add error handling to all database operations +cd your-project/ +qwen -p "your question" ``` -### 🔄 Automate Workflows +Use `-p` to run Qwen Code without the interactive UI—ideal for scripts, automation, and CI/CD. Learn more: [Headless mode](https://qwenlm.github.io/qwen-code-docs/en/users/features/headless). -```bash -# Git automation -> Analyze git commits from the last 7 days, grouped by feature -> Create a changelog from recent commits -> Find all TODO comments and create GitHub issues +#### IDE integration -# File operations -> Convert all images in this directory to PNG format -> Rename all test files to follow the *.test.ts pattern -> Find and remove all console.log statements -``` +Use Qwen Code inside your editor (VS Code and Zed): -### 🐛 Debugging & Analysis +- [Use in VS Code](https://qwenlm.github.io/qwen-code-docs/en/users/integration-vscode/) +- [Use in Zed](https://qwenlm.github.io/qwen-code-docs/en/users/integration-zed/) -```bash -# Performance analysis -> Identify performance bottlenecks in this React component -> Find all N+1 query problems in the codebase +#### TypeScript SDK -# Security audit -> Check for potential SQL injection vulnerabilities -> Find all hardcoded credentials or API keys -``` +Build on top of Qwen Code with the TypeScript SDK: -## Popular Tasks - -### 📚 Understand New Codebases - -```text -> What are the core business logic components? -> What security mechanisms are in place? -> How does the data flow through the system? -> What are the main design patterns used? -> Generate a dependency graph for this module -``` - -### 🔨 Code Refactoring & Optimization - -```text -> What parts of this module can be optimized? -> Help me refactor this class to follow SOLID principles -> Add proper error handling and logging -> Convert callbacks to async/await pattern -> Implement caching for expensive operations -``` - -### 📝 Documentation & Testing - -```text -> Generate comprehensive JSDoc comments for all public APIs -> Write unit tests with edge cases for this component -> Create API documentation in OpenAPI format -> Add inline comments explaining complex algorithms -> Generate a README for this module -``` - -### 🚀 Development Acceleration - -```text -> Set up a new Express server with authentication -> Create a React component with TypeScript and tests -> Implement a rate limiter middleware -> Add database migrations for new schema -> Configure CI/CD pipeline for this project -``` +- [Use the Qwen Code SDK](./packages/sdk-typescript/README.md) ## Commands & Shortcuts @@ -380,6 +156,7 @@ qwen - `/clear` - Clear conversation history - `/compress` - Compress history to save tokens - `/stats` - Show current session information +- `/bug` - Submit a bug report - `/exit` or `/quit` - Exit Qwen Code ### Keyboard Shortcuts @@ -388,6 +165,19 @@ qwen - `Ctrl+D` - Exit (on empty line) - `Up/Down` - Navigate command history +> Learn more about [Commands](https://qwenlm.github.io/qwen-code-docs/en/users/features/commands/) +> +> **Tip**: In YOLO mode (`--yolo`), vision switching happens automatically without prompts when images are detected. Learn more about [Approval Mode](https://qwenlm.github.io/qwen-code-docs/en/users/features/approval-mode/) + +## Configuration + +Qwen Code can be configured via `settings.json`, environment variables, and CLI flags. + +- **User settings**: `~/.qwen/settings.json` +- **Project settings**: `.qwen/settings.json` + +See [settings](https://qwenlm.github.io/qwen-code-docs/en/users/configuration/settings/) for available options and precedence. + ## Benchmark Results ### Terminal-Bench Performance @@ -397,24 +187,18 @@ qwen | Qwen Code | Qwen3-Coder-480A35 | 37.5% | | Qwen Code | Qwen3-Coder-30BA3B | 31.3% | -## Development & Contributing +## Ecosystem -See [CONTRIBUTING.md](./CONTRIBUTING.md) to learn how to contribute to the project. +Looking for a graphical interface? -For detailed authentication setup, see the [authentication guide](./docs/cli/authentication.md). +- [**Gemini CLI Desktop**](https://github.com/Piebald-AI/gemini-cli-desktop) A cross-platform desktop/web/mobile UI for Qwen Code ## Troubleshooting -If you encounter issues, check the [troubleshooting guide](docs/troubleshooting.md). +If you encounter issues, check the [troubleshooting guide](https://qwenlm.github.io/qwen-code-docs/en/users/support/troubleshooting/). + +To report a bug from within the CLI, run `/bug` and include a short title and repro steps. ## Acknowledgments This project is based on [Google Gemini CLI](https://github.com/google-gemini/gemini-cli). We acknowledge and appreciate the excellent work of the Gemini CLI team. Our main contribution focuses on parser-level adaptations to better support Qwen-Coder models. - -## License - -[LICENSE](./LICENSE) - -## Star History - -[![Star History Chart](https://api.star-history.com/svg?repos=QwenLM/qwen-code&type=Date)](https://www.star-history.com/#QwenLM/qwen-code&Date) diff --git a/docs-site/README.md b/docs-site/README.md new file mode 100644 index 000000000..ad6272c33 --- /dev/null +++ b/docs-site/README.md @@ -0,0 +1,54 @@ +# Qwen Code Docs Site + +A documentation website for Qwen Code built with [Next.js](https://nextjs.org/) and [Nextra](https://nextra.site/). + +## Getting Started + +### Prerequisites + +- Node.js 18+ +- npm or yarn + +### Installation + +```bash +npm install +``` + +### Setup Content + +Link the documentation content from the parent `docs` directory: + +```bash +npm run link +``` + +This creates a symbolic link from `../docs` to `content` in the project. + +### Development + +Start the development server: + +```bash +npm run dev +``` + +Open [http://localhost:3000](http://localhost:3000) in your browser to see the documentation site. + +## Project Structure + +``` +docs-site/ +├── src/ +│ └── app/ +│ ├── [[...mdxPath]]/ # Dynamic routing for MDX pages +│ │ └── page.jsx +│ └── layout.jsx # Root layout with navbar and footer +├── mdx-components.js # MDX component configuration +├── next.config.mjs # Next.js configuration +└── package.json +``` + +## License + +MIT © Qwen Team diff --git a/docs-site/mdx-components.js b/docs-site/mdx-components.js new file mode 100644 index 000000000..ad856fe44 --- /dev/null +++ b/docs-site/mdx-components.js @@ -0,0 +1,12 @@ +import { useMDXComponents as getThemeComponents } from 'nextra-theme-docs'; // nextra-theme-blog or your custom theme + +// Get the default MDX components +const themeComponents = getThemeComponents(); + +// Merge components +export function useMDXComponents(components) { + return { + ...themeComponents, + ...components, + }; +} diff --git a/docs-site/next-env.d.ts b/docs-site/next-env.d.ts new file mode 100644 index 000000000..20e7bcfb0 --- /dev/null +++ b/docs-site/next-env.d.ts @@ -0,0 +1,6 @@ +/// +/// +import './.next/dev/types/routes.d.ts'; + +// NOTE: This file should not be edited +// see https://nextjs.org/docs/app/api-reference/config/typescript for more information. diff --git a/docs-site/next.config.mjs b/docs-site/next.config.mjs new file mode 100644 index 000000000..88adb8688 --- /dev/null +++ b/docs-site/next.config.mjs @@ -0,0 +1,5 @@ +import nextra from 'nextra'; + +const withNextra = nextra({}); + +export default withNextra({}); diff --git a/docs-site/package.json b/docs-site/package.json new file mode 100644 index 000000000..1b5af5ae5 --- /dev/null +++ b/docs-site/package.json @@ -0,0 +1,22 @@ +{ + "name": "docs-site", + "version": "1.0.0", + "description": "", + "license": "ISC", + "author": "", + "type": "module", + "main": "index.js", + "scripts": { + "link": "ln -s ../docs content", + "clean": "rm -rf .next", + "dev": "npm run clean && next --turbopack", + "test": "echo \"Error: no test specified\" && exit 1" + }, + "dependencies": { + "next": "^16.0.8", + "nextra": "^4.6.1", + "nextra-theme-docs": "^4.6.1", + "react": "^19.2.1", + "react-dom": "^19.2.1" + } +} diff --git a/docs-site/src/app/[[...mdxPath]]/page.jsx b/docs-site/src/app/[[...mdxPath]]/page.jsx new file mode 100644 index 000000000..c980e9f60 --- /dev/null +++ b/docs-site/src/app/[[...mdxPath]]/page.jsx @@ -0,0 +1,27 @@ +import { generateStaticParamsFor, importPage } from 'nextra/pages'; +import { useMDXComponents as getMDXComponents } from '../../../mdx-components'; + +export const generateStaticParams = generateStaticParamsFor('mdxPath'); + +export async function generateMetadata(props) { + const params = await props.params; + const { metadata } = await importPage(params.mdxPath); + return metadata; +} + +const Wrapper = getMDXComponents().wrapper; + +export default async function Page(props) { + const params = await props.params; + const { + default: MDXContent, + toc, + metadata, + sourceCode, + } = await importPage(params.mdxPath); + return ( + + + + ); +} diff --git a/docs-site/src/app/layout.jsx b/docs-site/src/app/layout.jsx new file mode 100644 index 000000000..87dd6c0c3 --- /dev/null +++ b/docs-site/src/app/layout.jsx @@ -0,0 +1,55 @@ +import { Footer, Layout, Navbar } from 'nextra-theme-docs'; +import { Banner, Head } from 'nextra/components'; +import { getPageMap } from 'nextra/page-map'; +import 'nextra-theme-docs/style.css'; + +export const metadata = { + // Define your metadata here + // For more information on metadata API, see: https://nextjs.org/docs/app/building-your-application/optimizing/metadata +}; + +const banner = ( + Qwen Code 0.5.0 is released 🎉 +); +const navbar = ( + Qwen Code} + // ... Your additional navbar options + /> +); +const footer =
MIT {new Date().getFullYear()} © Qwen Team.
; + +export default async function RootLayout({ children }) { + return ( + + + {/* Your additional tags should be passed as `children` of `` element */} + + + + {children} + + + + ); +} diff --git a/docs/_meta.ts b/docs/_meta.ts index 4939cb311..10f50a104 100644 --- a/docs/_meta.ts +++ b/docs/_meta.ts @@ -1,10 +1,14 @@ export default { - index: 'Welcome to Qwen Code', - cli: 'CLI', - core: 'Core', - tools: 'Tools', - features: 'Features', - 'ide-integration': 'IDE Integration', - development: 'Development', - support: 'Support', + index: { + type: 'page', + display: 'hidden', + }, + users: { + type: 'page', + title: 'User Guide', + }, + developers: { + type: 'page', + title: 'Developer Guide', + }, }; diff --git a/docs/assets/connected_devtools.png b/docs/assets/connected_devtools.png deleted file mode 100644 index 34a3c568a..000000000 Binary files a/docs/assets/connected_devtools.png and /dev/null differ diff --git a/docs/assets/gemini-screenshot.png b/docs/assets/gemini-screenshot.png deleted file mode 100644 index 1cc163d8e..000000000 Binary files a/docs/assets/gemini-screenshot.png and /dev/null differ diff --git a/docs/assets/qwen-screenshot.png b/docs/assets/qwen-screenshot.png deleted file mode 100644 index 15f66cce0..000000000 Binary files a/docs/assets/qwen-screenshot.png and /dev/null differ diff --git a/docs/assets/release_patch.png b/docs/assets/release_patch.png deleted file mode 100644 index 952dc6abf..000000000 Binary files a/docs/assets/release_patch.png and /dev/null differ diff --git a/docs/assets/theme-ansi-light.png b/docs/assets/theme-ansi-light.png deleted file mode 100644 index 9766ae782..000000000 Binary files a/docs/assets/theme-ansi-light.png and /dev/null differ diff --git a/docs/assets/theme-ansi.png b/docs/assets/theme-ansi.png deleted file mode 100644 index 5d46dacab..000000000 Binary files a/docs/assets/theme-ansi.png and /dev/null differ diff --git a/docs/assets/theme-atom-one.png b/docs/assets/theme-atom-one.png deleted file mode 100644 index c2787d6b6..000000000 Binary files a/docs/assets/theme-atom-one.png and /dev/null differ diff --git a/docs/assets/theme-ayu-light.png b/docs/assets/theme-ayu-light.png deleted file mode 100644 index f17746567..000000000 Binary files a/docs/assets/theme-ayu-light.png and /dev/null differ diff --git a/docs/assets/theme-ayu.png b/docs/assets/theme-ayu.png deleted file mode 100644 index 99391f827..000000000 Binary files a/docs/assets/theme-ayu.png and /dev/null differ diff --git a/docs/assets/theme-custom.png b/docs/assets/theme-custom.png deleted file mode 100644 index 0eb80f960..000000000 Binary files a/docs/assets/theme-custom.png and /dev/null differ diff --git a/docs/assets/theme-default-light.png b/docs/assets/theme-default-light.png deleted file mode 100644 index 829d4ed5c..000000000 Binary files a/docs/assets/theme-default-light.png and /dev/null differ diff --git a/docs/assets/theme-default.png b/docs/assets/theme-default.png deleted file mode 100644 index 0b93a3343..000000000 Binary files a/docs/assets/theme-default.png and /dev/null differ diff --git a/docs/assets/theme-dracula.png b/docs/assets/theme-dracula.png deleted file mode 100644 index 27213fbc5..000000000 Binary files a/docs/assets/theme-dracula.png and /dev/null differ diff --git a/docs/assets/theme-github-light.png b/docs/assets/theme-github-light.png deleted file mode 100644 index 3cdc94aa4..000000000 Binary files a/docs/assets/theme-github-light.png and /dev/null differ diff --git a/docs/assets/theme-github.png b/docs/assets/theme-github.png deleted file mode 100644 index a62961b65..000000000 Binary files a/docs/assets/theme-github.png and /dev/null differ diff --git a/docs/assets/theme-google-light.png b/docs/assets/theme-google-light.png deleted file mode 100644 index 835ebc4be..000000000 Binary files a/docs/assets/theme-google-light.png and /dev/null differ diff --git a/docs/assets/theme-xcode-light.png b/docs/assets/theme-xcode-light.png deleted file mode 100644 index eb056a558..000000000 Binary files a/docs/assets/theme-xcode-light.png and /dev/null differ diff --git a/docs/cli/_meta.ts b/docs/cli/_meta.ts deleted file mode 100644 index 1557b5955..000000000 --- a/docs/cli/_meta.ts +++ /dev/null @@ -1,35 +0,0 @@ -export default { - index: 'Introduction', - authentication: 'Authentication', - 'openai-auth': 'OpenAI Authentication', - commands: 'Commands', - configuration: 'Configuration', - 'configuration-v1': 'Configuration (v1)', - themes: 'Themes', - tutorials: 'Tutorials', - 'keyboard-shortcuts': 'Keyboard Shortcuts', - 'trusted-folders': 'Trusted Folders', - 'qwen-ignore': 'Ignoring Files', - Uninstall: 'Uninstall', -}; - -/** - * - * { "label": "Introduction", "slug": "docs/cli" }, - { "label": "Authentication", "slug": "docs/cli/authentication" }, - { "label": "Commands", "slug": "docs/cli/commands" }, - { "label": "Configuration", "slug": "docs/cli/configuration" }, - { "label": "Checkpointing", "slug": "docs/checkpointing" }, - { "label": "Extensions", "slug": "docs/extension" }, - { "label": "Headless Mode", "slug": "docs/headless" }, - { "label": "IDE Integration", "slug": "docs/ide-integration" }, - { - "label": "IDE Companion Spec", - "slug": "docs/ide-companion-spec" - }, - { "label": "Telemetry", "slug": "docs/telemetry" }, - { "label": "Themes", "slug": "docs/cli/themes" }, - { "label": "Token Caching", "slug": "docs/cli/token-caching" }, - { "label": "Trusted Folders", "slug": "docs/trusted-folders" }, - { "label": "Tutorials", "slug": "docs/cli/tutorials" } - */ diff --git a/docs/cli/authentication.md b/docs/cli/authentication.md deleted file mode 100644 index 43f55d53f..000000000 --- a/docs/cli/authentication.md +++ /dev/null @@ -1,137 +0,0 @@ -# Authentication Setup - -Qwen Code supports two main authentication methods to access AI models. Choose the method that best fits your use case: - -1. **Qwen OAuth (Recommended):** - - Use this option to log in with your qwen.ai account. - - During initial startup, Qwen Code will direct you to the qwen.ai authentication page. Once authenticated, your credentials will be cached locally so the web login can be skipped on subsequent runs. - - **Requirements:** - - Valid qwen.ai account - - Internet connection for initial authentication - - **Benefits:** - - Seamless access to Qwen models - - Automatic credential refresh - - No manual API key management required - - **Getting Started:** - - ```bash - # Start Qwen Code and follow the OAuth flow - qwen - ``` - - The CLI will automatically open your browser and guide you through the authentication process. - - **For users who authenticate using their qwen.ai account:** - - **Quota:** - - 60 requests per minute - - 2,000 requests per day - - Token usage is not applicable - - **Cost:** Free - - **Notes:** A specific quota for different models is not specified; model fallback may occur to preserve shared experience quality. - -2. **OpenAI-Compatible API:** - - Use API keys for OpenAI or other compatible providers. - - This method allows you to use various AI models through API keys. - - **Configuration Methods:** - - a) **Environment Variables:** - - ```bash - export OPENAI_API_KEY="your_api_key_here" - export OPENAI_BASE_URL="your_api_endpoint" # Optional - export OPENAI_MODEL="your_model_choice" # Optional - ``` - - b) **Project `.env` File:** - Create a `.env` file in your project root: - - ```env - OPENAI_API_KEY=your_api_key_here - OPENAI_BASE_URL=your_api_endpoint - OPENAI_MODEL=your_model_choice - ``` - - **Supported Providers:** - - OpenAI (https://platform.openai.com/api-keys) - - Alibaba Cloud Bailian - - ModelScope - - OpenRouter - - Azure OpenAI - - Any OpenAI-compatible API - -## Switching Authentication Methods - -To switch between authentication methods during a session, use the `/auth` command in the CLI interface: - -```bash -# Within the CLI, type: -/auth -``` - -This will allow you to reconfigure your authentication method without restarting the application. - -### Persisting Environment Variables with `.env` Files - -You can create a **`.qwen/.env`** file in your project directory or in your home directory. Creating a plain **`.env`** file also works, but `.qwen/.env` is recommended to keep Qwen Code variables isolated from other tools. - -**Important:** Some environment variables (like `DEBUG` and `DEBUG_MODE`) are automatically excluded from project `.env` files to prevent interference with qwen-code behavior. Use `.qwen/.env` files for qwen-code specific variables. - -Qwen Code automatically loads environment variables from the **first** `.env` file it finds, using the following search order: - -1. Starting in the **current directory** and moving upward toward `/`, for each directory it checks: - 1. `.qwen/.env` - 2. `.env` -2. If no file is found, it falls back to your **home directory**: - - `~/.qwen/.env` - - `~/.env` - -> **Important:** The search stops at the **first** file encountered—variables are **not merged** across multiple files. - -#### Examples - -**Project-specific overrides** (take precedence when you are inside the project): - -```bash -mkdir -p .qwen -cat >> .qwen/.env <<'EOF' -OPENAI_API_KEY="your-api-key" -OPENAI_BASE_URL="https://api-inference.modelscope.cn/v1" -OPENAI_MODEL="Qwen/Qwen3-Coder-480B-A35B-Instruct" -EOF -``` - -**User-wide settings** (available in every directory): - -```bash -mkdir -p ~/.qwen -cat >> ~/.qwen/.env <<'EOF' -OPENAI_API_KEY="your-api-key" -OPENAI_BASE_URL="https://dashscope.aliyuncs.com/compatible-mode/v1" -OPENAI_MODEL="qwen3-coder-plus" -EOF -``` - -## Non-Interactive Mode / Headless Environments - -When running Qwen Code in a non-interactive environment, you cannot use the OAuth login flow. -Instead, you must configure authentication using environment variables. - -The CLI will automatically detect if it is running in a non-interactive terminal and will use the -OpenAI-compatible API method if configured: - -1. **OpenAI-Compatible API:** - - Set the `OPENAI_API_KEY` environment variable. - - Optionally set `OPENAI_BASE_URL` and `OPENAI_MODEL` for custom endpoints. - - The CLI will use these credentials to authenticate with the API provider. - -**Example for headless environments:** - -If none of these environment variables are set in a non-interactive session, the CLI will exit with an error. - -For comprehensive guidance on using Qwen COde programmatically and in -automation workflows, see the [Headless Mode Guide](../headless.md). diff --git a/docs/cli/commands.md b/docs/cli/commands.md deleted file mode 100644 index aa056a433..000000000 --- a/docs/cli/commands.md +++ /dev/null @@ -1,475 +0,0 @@ -# CLI Commands - -Qwen Code supports several built-in commands to help you manage your session, customize the interface, and control its behavior. These commands are prefixed with a forward slash (`/`), an at symbol (`@`), or an exclamation mark (`!`). - -## Slash commands (`/`) - -Slash commands provide meta-level control over the CLI itself. - -### Built-in Commands - -- **`/bug`** - - **Description:** File an issue about Qwen Code. By default, the issue is filed within the GitHub repository for Qwen Code. The string you enter after `/bug` will become the headline for the bug being filed. The default `/bug` behavior can be modified using the `advanced.bugCommand` setting in your `.qwen/settings.json` files. - -- **`/clear`** (aliases: `reset`, `new`) - - **Description:** Clear conversation history and free up context by starting a fresh session. Also clears the terminal output and scrollback within the CLI. - - **Keyboard shortcut:** Press **Ctrl+L** at any time to perform a clear action. - -- **`/summary`** - - **Description:** Generate a comprehensive project summary from the current conversation history and save it to `.qwen/PROJECT_SUMMARY.md`. This summary includes the overall goal, key knowledge, recent actions, and current plan, making it perfect for resuming work in future sessions. - - **Usage:** `/summary` - - **Features:** - - Analyzes the entire conversation history to extract important context - - Creates a structured markdown summary with sections for goals, knowledge, actions, and plans - - Automatically saves to `.qwen/PROJECT_SUMMARY.md` in your project root - - Shows progress indicators during generation and saving - - Integrates with the Welcome Back feature for seamless session resumption - - **Note:** This command requires an active conversation with at least 2 messages to generate a meaningful summary. - -- **`/compress`** - - **Description:** Replace the entire chat context with a summary. This saves on tokens used for future tasks while retaining a high level summary of what has happened. - -- **`/copy`** - - **Description:** Copies the last output produced by Qwen Code to your clipboard, for easy sharing or reuse. - -- **`/directory`** (or **`/dir`**) - - **Description:** Manage workspace directories for multi-directory support. - - **Sub-commands:** - - **`add`**: - - **Description:** Add a directory to the workspace. The path can be absolute or relative to the current working directory. Moreover, the reference from home directory is supported as well. - - **Usage:** `/directory add ,` - - **Note:** Disabled in restrictive sandbox profiles. If you're using that, use `--include-directories` when starting the session instead. - - **`show`**: - - **Description:** Display all directories added by `/directory add` and `--include-directories`. - - **Usage:** `/directory show` - -- **`/editor`** - - **Description:** Open a dialog for selecting supported editors. - -- **`/extensions`** - - **Description:** Lists all active extensions in the current Qwen Code session. See [Qwen Code Extensions](../extension.md). - -- **`/help`** (or **`/?`**) - - **Description:** Display help information about the Qwen Code, including available commands and their usage. - -- **`/mcp`** - - **Description:** List configured Model Context Protocol (MCP) servers, their connection status, server details, and available tools. - - **Sub-commands:** - - **`desc`** or **`descriptions`**: - - **Description:** Show detailed descriptions for MCP servers and tools. - - **`nodesc`** or **`nodescriptions`**: - - **Description:** Hide tool descriptions, showing only the tool names. - - **`schema`**: - - **Description:** Show the full JSON schema for the tool's configured parameters. - - **Keyboard Shortcut:** Press **Ctrl+T** at any time to toggle between showing and hiding tool descriptions. - -- **`/memory`** - - **Description:** Manage the AI's instructional context (hierarchical memory loaded from `QWEN.md` files by default; configurable via `contextFileName`). - - **Sub-commands:** - - **`add`**: - - **Description:** Adds the following text to the AI's memory. Usage: `/memory add ` - - **`show`**: - - **Description:** Display the full, concatenated content of the current hierarchical memory that has been loaded from all context files (e.g., `QWEN.md`). This lets you inspect the instructional context being provided to the model. - - **`refresh`**: - - **Description:** Reload the hierarchical instructional memory from all context files (default: `QWEN.md`) found in the configured locations (global, project/ancestors, and sub-directories). This updates the model with the latest context content. - - **Note:** For more details on how context files contribute to hierarchical memory, see the [CLI Configuration documentation](./configuration.md#context-files-hierarchical-instructional-context). - -- **`/model`** - - **Description:** Switch the model for the current session. Opens a dialog to select from available models based on your authentication type. - - **Usage:** `/model` - - **Features:** - - Shows a dialog with all available models for your current authentication type - - Displays model descriptions and capabilities (e.g., vision support) - - Changes the model for the current session only - - Supports both Qwen models (via OAuth) and OpenAI models (via API key) - - **Available Models:** - - **Qwen Coder:** The latest Qwen Coder model from Alibaba Cloud ModelStudio (version: qwen3-coder-plus-2025-09-23) - - **Qwen Vision:** The latest Qwen Vision model from Alibaba Cloud ModelStudio (version: qwen3-vl-plus-2025-09-23) - supports image analysis - - **OpenAI Models:** Available when using OpenAI authentication (configured via `OPENAI_MODEL` environment variable) - - **Note:** Model selection is session-specific and does not persist across different Qwen Code sessions. To set a default model, use the `model.name` setting in your configuration. - -- **`/restore`** - - **Description:** Restores the project files to the state they were in just before a tool was executed. This is particularly useful for undoing file edits made by a tool. If run without a tool call ID, it will list available checkpoints to restore from. - - **Usage:** `/restore [tool_call_id]` - - **Note:** Only available if the CLI is invoked with the `--checkpointing` option or configured via [settings](./configuration.md). See [Checkpointing documentation](../checkpointing.md) for more details. - -- **`/settings`** - - **Description:** Open the settings editor to view and modify Qwen Code settings. - - **Details:** This command provides a user-friendly interface for changing settings that control the behavior and appearance of Qwen Code. It is equivalent to manually editing the `.qwen/settings.json` file, but with validation and guidance to prevent errors. - - **Usage:** Simply run `/settings` and the editor will open. You can then browse or search for specific settings, view their current values, and modify them as desired. Changes to some settings are applied immediately, while others require a restart. - -- **`/stats`** - - **Description:** Display detailed statistics for the current Qwen Code session, including token usage, cached token savings (when available), and session duration. Note: Cached token information is only displayed when cached tokens are being used, which occurs with API key authentication but not with OAuth authentication at this time. - -- [**`/theme`**](./themes.md) - - **Description:** Open a dialog that lets you change the visual theme of Qwen Code. - -- **`/auth`** - - **Description:** Open a dialog that lets you change the authentication method. - -- **`/approval-mode`** - - **Description:** Change the approval mode for tool usage. - - **Usage:** `/approval-mode [mode] [--session|--project|--user]` - - **Available Modes:** - - **`plan`**: Analyze only; do not modify files or execute commands - - **`default`**: Require approval for file edits or shell commands - - **`auto-edit`**: Automatically approve file edits - - **`yolo`**: Automatically approve all tools - - **Examples:** - - `/approval-mode plan --project` (persist plan mode for this project) - - `/approval-mode yolo --user` (persist YOLO mode for this user across projects) - -- **`/about`** - - **Description:** Show version info. Please share this information when filing issues. - -- **`/agents`** - - **Description:** Manage specialized AI subagents for focused tasks. Subagents are independent AI assistants configured with specific expertise and tool access. - - **Sub-commands:** - - **`create`**: - - **Description:** Launch an interactive wizard to create a new subagent. The wizard guides you through location selection, AI-powered prompt generation, tool selection, and visual customization. - - **Usage:** `/agents create` - - **`manage`**: - - **Description:** Open an interactive management dialog to view, edit, and delete existing subagents. Shows both project-level and user-level agents. - - **Usage:** `/agents manage` - - **Storage Locations:** - - **Project-level:** `.qwen/agents/` (shared with team, takes precedence) - - **User-level:** `~/.qwen/agents/` (personal agents, available across projects) - - **Note:** For detailed information on creating and managing subagents, see the [Subagents documentation](../subagents.md). - -- [**`/tools`**](../tools/index.md) - - **Description:** Display a list of tools that are currently available within Qwen Code. - - **Usage:** `/tools [desc]` - - **Sub-commands:** - - **`desc`** or **`descriptions`**: - - **Description:** Show detailed descriptions of each tool, including each tool's name with its full description as provided to the model. - - **`nodesc`** or **`nodescriptions`**: - - **Description:** Hide tool descriptions, showing only the tool names. - -- **`/quit`** (or **`/exit`**) - - **Description:** Exit Qwen Code immediately without any confirmation dialog. - -- **`/vim`** - - **Description:** Toggle vim mode on or off. When vim mode is enabled, the input area supports vim-style navigation and editing commands in both NORMAL and INSERT modes. - - **Features:** - - **NORMAL mode:** Navigate with `h`, `j`, `k`, `l`; jump by words with `w`, `b`, `e`; go to line start/end with `0`, `$`, `^`; go to specific lines with `G` (or `gg` for first line) - - **INSERT mode:** Standard text input with escape to return to NORMAL mode - - **Editing commands:** Delete with `x`, change with `c`, insert with `i`, `a`, `o`, `O`; complex operations like `dd`, `cc`, `dw`, `cw` - - **Count support:** Prefix commands with numbers (e.g., `3h`, `5w`, `10G`) - - **Repeat last command:** Use `.` to repeat the last editing operation - - **Persistent setting:** Vim mode preference is saved to `~/.qwen/settings.json` and restored between sessions - - **Status indicator:** When enabled, shows `[NORMAL]` or `[INSERT]` in the footer - -- **`/init`** - - **Description:** Analyzes the current directory and creates a `QWEN.md` context file by default (or the filename specified by `contextFileName`). If a non-empty file already exists, no changes are made. The command seeds an empty file and prompts the model to populate it with project-specific instructions. - -- [**`/language`**](./language.md) - - **Description:** View or change the language setting for both UI and LLM output. - - **Sub-commands:** - - **`ui`**: Set the UI language (zh-CN or en-US) - - **`output`**: Set the LLM output language - - **Usage:** `/language [ui|output] [language]` - - **Examples:** - - `/language ui zh-CN` (set UI language to Simplified Chinese) - - `/language output English` (set LLM output language to English) - -### Custom Commands - -For a quick start, see the [example](#example-a-pure-function-refactoring-command) below. - -Custom commands allow you to save and reuse your favorite or most frequently used prompts as personal shortcuts within Qwen Code. You can create commands that are specific to a single project or commands that are available globally across all your projects, streamlining your workflow and ensuring consistency. - -#### File Locations & Precedence - -Qwen Code discovers commands from two locations, loaded in a specific order: - -1. **User Commands (Global):** Located in `~/.qwen/commands/`. These commands are available in any project you are working on. -2. **Project Commands (Local):** Located in `/.qwen/commands/`. These commands are specific to the current project and can be checked into version control to be shared with your team. - -If a command in the project directory has the same name as a command in the user directory, the **project command will always be used.** This allows projects to override global commands with project-specific versions. - -#### Naming and Namespacing - -The name of a command is determined by its file path relative to its `commands` directory. Subdirectories are used to create namespaced commands, with the path separator (`/` or `\`) being converted to a colon (`:`). - -- A file at `~/.qwen/commands/test.toml` becomes the command `/test`. -- A file at `/.qwen/commands/git/commit.toml` becomes the namespaced command `/git:commit`. - -#### TOML File Format (v1) - -Your command definition files must be written in the TOML format and use the `.toml` file extension. - -##### Required Fields - -- `prompt` (String): The prompt that will be sent to the model when the command is executed. This can be a single-line or multi-line string. - -##### Optional Fields - -- `description` (String): A brief, one-line description of what the command does. This text will be displayed next to your command in the `/help` menu. **If you omit this field, a generic description will be generated from the filename.** - -#### Handling Arguments - -Custom commands support two powerful methods for handling arguments. The CLI automatically chooses the correct method based on the content of your command's `prompt`. - -##### 1. Context-Aware Injection with `{{args}}` - -If your `prompt` contains the special placeholder `{{args}}`, the CLI will replace that placeholder with the text the user typed after the command name. - -The behavior of this injection depends on where it is used: - -**A. Raw Injection (Outside Shell Commands)** - -When used in the main body of the prompt, the arguments are injected exactly as the user typed them. - -**Example (`git/fix.toml`):** - -```toml -# Invoked via: /git:fix "Button is misaligned" - -description = "Generates a fix for a given issue." -prompt = "Please provide a code fix for the issue described here: {{args}}." -``` - -The model receives: `Please provide a code fix for the issue described here: "Button is misaligned".` - -**B. Using Arguments in Shell Commands (Inside `!{...}` Blocks)** - -When you use `{{args}}` inside a shell injection block (`!{...}`), the arguments are automatically **shell-escaped** before replacement. This allows you to safely pass arguments to shell commands, ensuring the resulting command is syntactically correct and secure while preventing command injection vulnerabilities. - -**Example (`/grep-code.toml`):** - -```toml -prompt = """ -Please summarize the findings for the pattern `{{args}}`. - -Search Results: -!{grep -r {{args}} .} -""" -``` - -When you run `/grep-code It's complicated`: - -1. The CLI sees `{{args}}` used both outside and inside `!{...}`. -2. Outside: The first `{{args}}` is replaced raw with `It's complicated`. -3. Inside: The second `{{args}}` is replaced with the escaped version (e.g., on Linux: `"It's complicated"`). -4. The command executed is `grep -r "It's complicated" .`. -5. The CLI prompts you to confirm this exact, secure command before execution. -6. The final prompt is sent. - -##### 2. Default Argument Handling - -If your `prompt` does **not** contain the special placeholder `{{args}}`, the CLI uses a default behavior for handling arguments. - -If you provide arguments to the command (e.g., `/mycommand arg1`), the CLI will append the full command you typed to the end of the prompt, separated by two newlines. This allows the model to see both the original instructions and the specific arguments you just provided. - -If you do **not** provide any arguments (e.g., `/mycommand`), the prompt is sent to the model exactly as it is, with nothing appended. - -**Example (`changelog.toml`):** - -This example shows how to create a robust command by defining a role for the model, explaining where to find the user's input, and specifying the expected format and behavior. - -```toml -# In: /.qwen/commands/changelog.toml -# Invoked via: /changelog 1.2.0 added "Support for default argument parsing." - -description = "Adds a new entry to the project's CHANGELOG.md file." -prompt = """ -# Task: Update Changelog - -You are an expert maintainer of this software project. A user has invoked a command to add a new entry to the changelog. - -**The user's raw command is appended below your instructions.** - -Your task is to parse the ``, ``, and `` from their input and use the `write_file` tool to correctly update the `CHANGELOG.md` file. - -## Expected Format -The command follows this format: `/changelog ` -- `` must be one of: "added", "changed", "fixed", "removed". - -## Behavior -1. Read the `CHANGELOG.md` file. -2. Find the section for the specified ``. -3. Add the `` under the correct `` heading. -4. If the version or type section doesn't exist, create it. -5. Adhere strictly to the "Keep a Changelog" format. -""" -``` - -When you run `/changelog 1.2.0 added "New feature"`, the final text sent to the model will be the original prompt followed by two newlines and the command you typed. - -##### 3. Executing Shell Commands with `!{...}` - -You can make your commands dynamic by executing shell commands directly within your `prompt` and injecting their output. This is ideal for gathering context from your local environment, like reading file content or checking the status of Git. - -When a custom command attempts to execute a shell command, Qwen Code will now prompt you for confirmation before proceeding. This is a security measure to ensure that only intended commands can be run. - -**How It Works:** - -1. **Inject Commands:** Use the `!{...}` syntax. -2. **Argument Substitution:** If `{{args}}` is present inside the block, it is automatically shell-escaped (see [Context-Aware Injection](#1-context-aware-injection-with-args) above). -3. **Robust Parsing:** The parser correctly handles complex shell commands that include nested braces, such as JSON payloads. **Note:** The content inside `!{...}` must have balanced braces (`{` and `}`). If you need to execute a command containing unbalanced braces, consider wrapping it in an external script file and calling the script within the `!{...}` block. -4. **Security Check and Confirmation:** The CLI performs a security check on the final, resolved command (after arguments are escaped and substituted). A dialog will appear showing the exact command(s) to be executed. -5. **Execution and Error Reporting:** The command is executed. If the command fails, the output injected into the prompt will include the error messages (stderr) followed by a status line, e.g., `[Shell command exited with code 1]`. This helps the model understand the context of the failure. - -**Example (`git/commit.toml`):** - -This command gets the staged git diff and uses it to ask the model to write a commit message. - -````toml -# In: /.qwen/commands/git/commit.toml -# Invoked via: /git:commit - -description = "Generates a Git commit message based on staged changes." - -# The prompt uses !{...} to execute the command and inject its output. -prompt = """ -Please generate a Conventional Commit message based on the following git diff: - -```diff -!{git diff --staged} -``` - -""" - -```` - -When you run `/git:commit`, the CLI first executes `git diff --staged`, then replaces `!{git diff --staged}` with the output of that command before sending the final, complete prompt to the model. - -##### 4. Injecting File Content with `@{...}` - -You can directly embed the content of a file or a directory listing into your prompt using the `@{...}` syntax. This is useful for creating commands that operate on specific files. - -**How It Works:** - -- **File Injection**: `@{path/to/file.txt}` is replaced by the content of `file.txt`. -- **Multimodal Support**: If the path points to a supported image (e.g., PNG, JPEG), PDF, audio, or video file, it will be correctly encoded and injected as multimodal input. Other binary files are handled gracefully and skipped. -- **Directory Listing**: `@{path/to/dir}` is traversed and each file present within the directory and all subdirectories are inserted into the prompt. This respects `.gitignore` and `.qwenignore` if enabled. -- **Workspace-Aware**: The command searches for the path in the current directory and any other workspace directories. Absolute paths are allowed if they are within the workspace. -- **Processing Order**: File content injection with `@{...}` is processed _before_ shell commands (`!{...}`) and argument substitution (`{{args}}`). -- **Parsing**: The parser requires the content inside `@{...}` (the path) to have balanced braces (`{` and `}`). - -**Example (`review.toml`):** - -This command injects the content of a _fixed_ best practices file (`docs/best-practices.md`) and uses the user's arguments to provide context for the review. - -```toml -# In: /.qwen/commands/review.toml -# Invoked via: /review FileCommandLoader.ts - -description = "Reviews the provided context using a best practice guide." -prompt = """ -You are an expert code reviewer. - -Your task is to review {{args}}. - -Use the following best practices when providing your review: - -@{docs/best-practices.md} -""" -``` - -When you run `/review FileCommandLoader.ts`, the `@{docs/best-practices.md}` placeholder is replaced by the content of that file, and `{{args}}` is replaced by the text you provided, before the final prompt is sent to the model. - ---- - -#### Example: A "Pure Function" Refactoring Command - -Let's create a global command that asks the model to refactor a piece of code. - -**1. Create the file and directories:** - -First, ensure the user commands directory exists, then create a `refactor` subdirectory for organization and the final TOML file. - -```bash -mkdir -p ~/.qwen/commands/refactor -touch ~/.qwen/commands/refactor/pure.toml -``` - -**2. Add the content to the file:** - -Open `~/.qwen/commands/refactor/pure.toml` in your editor and add the following content. We are including the optional `description` for best practice. - -```toml -# In: ~/.qwen/commands/refactor/pure.toml -# This command will be invoked via: /refactor:pure - -description = "Asks the model to refactor the current context into a pure function." - -prompt = """ -Please analyze the code I've provided in the current context. -Refactor it into a pure function. - -Your response should include: -1. The refactored, pure function code block. -2. A brief explanation of the key changes you made and why they contribute to purity. -""" -``` - -**3. Run the Command:** - -That's it! You can now run your command in the CLI. First, you might add a file to the context, and then invoke your command: - -``` -> @my-messy-function.js -> /refactor:pure -``` - -Qwen Code will then execute the multi-line prompt defined in your TOML file. - -## Input Prompt Shortcuts - -These shortcuts apply directly to the input prompt for text manipulation. - -- **Undo:** - - **Keyboard shortcut:** Press **Ctrl+z** to undo the last action in the input prompt. - -- **Redo:** - - **Keyboard shortcut:** Press **Ctrl+Shift+Z** to redo the last undone action in the input prompt. - -## At commands (`@`) - -At commands are used to include the content of files or directories as part of your prompt to the model. These commands include git-aware filtering. - -- **`@`** - - **Description:** Inject the content of the specified file or files into your current prompt. This is useful for asking questions about specific code, text, or collections of files. - - **Examples:** - - `@path/to/your/file.txt Explain this text.` - - `@src/my_project/ Summarize the code in this directory.` - - `What is this file about? @README.md` - - **Details:** - - If a path to a single file is provided, the content of that file is read. - - If a path to a directory is provided, the command attempts to read the content of files within that directory and any subdirectories. - - Spaces in paths should be escaped with a backslash (e.g., `@My\ Documents/file.txt`). - - The command uses the `read_many_files` tool internally. The content is fetched and then inserted into your query before being sent to the model. - - **Git-aware filtering:** By default, git-ignored files (like `node_modules/`, `dist/`, `.env`, `.git/`) are excluded. This behavior can be changed via the `context.fileFiltering` settings. - - **File types:** The command is intended for text-based files. While it might attempt to read any file, binary files or very large files might be skipped or truncated by the underlying `read_many_files` tool to ensure performance and relevance. The tool indicates if files were skipped. - - **Output:** The CLI will show a tool call message indicating that `read_many_files` was used, along with a message detailing the status and the path(s) that were processed. - -- **`@` (Lone at symbol)** - - **Description:** If you type a lone `@` symbol without a path, the query is passed as-is to the model. This might be useful if you are specifically talking _about_ the `@` symbol in your prompt. - -### Error handling for `@` commands - -- If the path specified after `@` is not found or is invalid, an error message will be displayed, and the query might not be sent to the model, or it will be sent without the file content. -- If the `read_many_files` tool encounters an error (e.g., permission issues), this will also be reported. - -## Shell mode & passthrough commands (`!`) - -The `!` prefix lets you interact with your system's shell directly from within Qwen Code. - -- **`!`** - - **Description:** Execute the given `` using `bash` on Linux/macOS or `cmd.exe` on Windows. Any output or errors from the command are displayed in the terminal. - - **Examples:** - - `!ls -la` (executes `ls -la` and returns to Qwen Code) - - `!git status` (executes `git status` and returns to Qwen Code) - -- **`!` (Toggle shell mode)** - - **Description:** Typing `!` on its own toggles shell mode. - - **Entering shell mode:** - - When active, shell mode uses a different coloring and a "Shell Mode Indicator". - - While in shell mode, text you type is interpreted directly as a shell command. - - **Exiting shell mode:** - - When exited, the UI reverts to its standard appearance and normal Qwen Code behavior resumes. - -- **Caution for all `!` usage:** Commands you execute in shell mode have the same permissions and impact as if you ran them directly in your terminal. - -- **Environment Variable:** When a command is executed via `!` or in shell mode, the `QWEN_CODE=1` environment variable is set in the subprocess's environment. This allows scripts or tools to detect if they are being run from within the CLI. diff --git a/docs/cli/configuration-v1.md b/docs/cli/configuration-v1.md deleted file mode 100644 index 2037db8d0..000000000 --- a/docs/cli/configuration-v1.md +++ /dev/null @@ -1,674 +0,0 @@ -# Qwen Code Configuration - -Qwen Code offers several ways to configure its behavior, including environment variables, command-line arguments, and settings files. This document outlines the different configuration methods and available settings. - -## Configuration layers - -Configuration is applied in the following order of precedence (lower numbers are overridden by higher numbers): - -1. **Default values:** Hardcoded defaults within the application. -2. **System defaults file:** System-wide default settings that can be overridden by other settings files. -3. **User settings file:** Global settings for the current user. -4. **Project settings file:** Project-specific settings. -5. **System settings file:** System-wide settings that override all other settings files. -6. **Environment variables:** System-wide or session-specific variables, potentially loaded from `.env` files. -7. **Command-line arguments:** Values passed when launching the CLI. - -## Settings files - -Qwen Code uses JSON settings files for persistent configuration. There are four locations for these files: - -- **System defaults file:** - - **Location:** `/etc/qwen-code/system-defaults.json` (Linux), `C:\ProgramData\qwen-code\system-defaults.json` (Windows) or `/Library/Application Support/QwenCode/system-defaults.json` (macOS). The path can be overridden using the `QWEN_CODE_SYSTEM_DEFAULTS_PATH` environment variable. - - **Scope:** Provides a base layer of system-wide default settings. These settings have the lowest precedence and are intended to be overridden by user, project, or system override settings. -- **User settings file:** - - **Location:** `~/.qwen/settings.json` (where `~` is your home directory). - - **Scope:** Applies to all Qwen Code sessions for the current user. -- **Project settings file:** - - **Location:** `.qwen/settings.json` within your project's root directory. - - **Scope:** Applies only when running Qwen Code from that specific project. Project settings override user settings. - -- **System settings file:** - - **Location:** `/etc/qwen-code/settings.json` (Linux), `C:\ProgramData\qwen-code\settings.json` (Windows) or `/Library/Application Support/QwenCode/settings.json` (macOS). The path can be overridden using the `QWEN_CODE_SYSTEM_SETTINGS_PATH` environment variable. - - **Scope:** Applies to all Qwen Code sessions on the system, for all users. System settings override user and project settings. May be useful for system administrators at enterprises to have controls over users' Qwen Code setups. - -**Note on environment variables in settings:** String values within your `settings.json` files can reference environment variables using either `$VAR_NAME` or `${VAR_NAME}` syntax. These variables will be automatically resolved when the settings are loaded. For example, if you have an environment variable `MY_API_TOKEN`, you could use it in `settings.json` like this: `"apiKey": "$MY_API_TOKEN"`. - -### The `.qwen` directory in your project - -In addition to a project settings file, a project's `.qwen` directory can contain other project-specific files related to Qwen Code's operation, such as: - -- [Custom sandbox profiles](#sandboxing) (e.g., `.qwen/sandbox-macos-custom.sb`, `.qwen/sandbox.Dockerfile`). - -### Available settings in `settings.json`: - -- **`contextFileName`** (string or array of strings): - - **Description:** Specifies the filename for context files (e.g., `QWEN.md`, `AGENTS.md`). Can be a single filename or a list of accepted filenames. - - **Default:** `QWEN.md` - - **Example:** `"contextFileName": "AGENTS.md"` - -- **`bugCommand`** (object): - - **Description:** Overrides the default URL for the `/bug` command. - - **Default:** `"urlTemplate": "https://github.com/QwenLM/qwen-code/issues/new?template=bug_report.yml&title={title}&info={info}"` - - **Properties:** - - **`urlTemplate`** (string): A URL that can contain `{title}` and `{info}` placeholders. - - **Example:** - ```json - "bugCommand": { - "urlTemplate": "https://bug.example.com/new?title={title}&info={info}" - } - ``` - -- **`fileFiltering`** (object): - - **Description:** Controls git-aware file filtering behavior for @ commands and file discovery tools. - - **Default:** `"respectGitIgnore": true, "enableRecursiveFileSearch": true` - - **Properties:** - - **`respectGitIgnore`** (boolean): Whether to respect .gitignore patterns when discovering files. When set to `true`, git-ignored files (like `node_modules/`, `dist/`, `.env`) are automatically excluded from @ commands and file listing operations. - - **`enableRecursiveFileSearch`** (boolean): Whether to enable searching recursively for filenames under the current tree when completing @ prefixes in the prompt. - - **`disableFuzzySearch`** (boolean): When `true`, disables the fuzzy search capabilities when searching for files, which can improve performance on projects with a large number of files. - - **Example:** - ```json - "fileFiltering": { - "respectGitIgnore": true, - "enableRecursiveFileSearch": false, - "disableFuzzySearch": true - } - ``` - -### Troubleshooting File Search Performance - -If you are experiencing performance issues with file searching (e.g., with `@` completions), especially in projects with a very large number of files, here are a few things you can try in order of recommendation: - -1. **Use `.qwenignore`:** Create a `.qwenignore` file in your project root to exclude directories that contain a large number of files that you don't need to reference (e.g., build artifacts, logs, `node_modules`). Reducing the total number of files crawled is the most effective way to improve performance. - -2. **Disable Fuzzy Search:** If ignoring files is not enough, you can disable fuzzy search by setting `disableFuzzySearch` to `true` in your `settings.json` file. This will use a simpler, non-fuzzy matching algorithm, which can be faster. - -3. **Disable Recursive File Search:** As a last resort, you can disable recursive file search entirely by setting `enableRecursiveFileSearch` to `false`. This will be the fastest option as it avoids a recursive crawl of your project. However, it means you will need to type the full path to files when using `@` completions. - -- **`coreTools`** (array of strings): - - **Description:** Allows you to specify a list of core tool names that should be made available to the model. This can be used to restrict the set of built-in tools. See [Built-in Tools](../core/tools-api.md#built-in-tools) for a list of core tools. You can also specify command-specific restrictions for tools that support it, like the `ShellTool`. For example, `"coreTools": ["ShellTool(ls -l)"]` will only allow the `ls -l` command to be executed. - - **Default:** All tools available for use by the model. - - **Example:** `"coreTools": ["ReadFileTool", "GlobTool", "ShellTool(ls)"]`. - -- **`allowedTools`** (array of strings): - - **Default:** `undefined` - - **Description:** A list of tool names that will bypass the confirmation dialog. This is useful for tools that you trust and use frequently. The match semantics are the same as `coreTools`. - - **Example:** `"allowedTools": ["ShellTool(git status)"]`. - -- **`excludeTools`** (array of strings): - - **Description:** Allows you to specify a list of core tool names that should be excluded from the model. A tool listed in both `excludeTools` and `coreTools` is excluded. You can also specify command-specific restrictions for tools that support it, like the `ShellTool`. For example, `"excludeTools": ["ShellTool(rm -rf)"]` will block the `rm -rf` command. - - **Default**: No tools excluded. - - **Example:** `"excludeTools": ["run_shell_command", "findFiles"]`. - - **Security Note:** Command-specific restrictions in - `excludeTools` for `run_shell_command` are based on simple string matching and can be easily bypassed. This feature is **not a security mechanism** and should not be relied upon to safely execute untrusted code. It is recommended to use `coreTools` to explicitly select commands - that can be executed. - -- **`allowMCPServers`** (array of strings): - - **Description:** Allows you to specify a list of MCP server names that should be made available to the model. This can be used to restrict the set of MCP servers to connect to. Note that this will be ignored if `--allowed-mcp-server-names` is set. - - **Default:** All MCP servers are available for use by the model. - - **Example:** `"allowMCPServers": ["myPythonServer"]`. - - **Security Note:** This uses simple string matching on MCP server names, which can be modified. If you're a system administrator looking to prevent users from bypassing this, consider configuring the `mcpServers` at the system settings level such that the user will not be able to configure any MCP servers of their own. This should not be used as an airtight security mechanism. - -- **`excludeMCPServers`** (array of strings): - - **Description:** Allows you to specify a list of MCP server names that should be excluded from the model. A server listed in both `excludeMCPServers` and `allowMCPServers` is excluded. Note that this will be ignored if `--allowed-mcp-server-names` is set. - - **Default**: No MCP servers excluded. - - **Example:** `"excludeMCPServers": ["myNodeServer"]`. - - **Security Note:** This uses simple string matching on MCP server names, which can be modified. If you're a system administrator looking to prevent users from bypassing this, consider configuring the `mcpServers` at the system settings level such that the user will not be able to configure any MCP servers of their own. This should not be used as an airtight security mechanism. - -- **`autoAccept`** (boolean): - - **Description:** Controls whether the CLI automatically accepts and executes tool calls that are considered safe (e.g., read-only operations) without explicit user confirmation. If set to `true`, the CLI will bypass the confirmation prompt for tools deemed safe. - - **Default:** `false` - - **Example:** `"autoAccept": true` - -- **`theme`** (string): - - **Description:** Sets the visual [theme](./themes.md) for Qwen Code. - - **Default:** `"Default"` - - **Example:** `"theme": "GitHub"` - -- **`vimMode`** (boolean): - - **Description:** Enables or disables vim mode for input editing. When enabled, the input area supports vim-style navigation and editing commands with NORMAL and INSERT modes. The vim mode status is displayed in the footer and persists between sessions. - - **Default:** `false` - - **Example:** `"vimMode": true` - -- **`sandbox`** (boolean or string): - - **Description:** Controls whether and how to use sandboxing for tool execution. If set to `true`, Qwen Code uses a pre-built `qwen-code-sandbox` Docker image. For more information, see [Sandboxing](#sandboxing). - - **Default:** `false` - - **Example:** `"sandbox": "docker"` - -- **`toolDiscoveryCommand`** (string): - - **Description:** **Align with Gemini CLI.** Defines a custom shell command for discovering tools from your project. The shell command must return on `stdout` a JSON array of [function declarations](https://ai.google.dev/gemini-api/docs/function-calling#function-declarations). Tool wrappers are optional. - - **Default:** Empty - - **Example:** `"toolDiscoveryCommand": "bin/get_tools"` - -- **`toolCallCommand`** (string): - - **Description:** **Align with Gemini CLI.** Defines a custom shell command for calling a specific tool that was discovered using `toolDiscoveryCommand`. The shell command must meet the following criteria: - - It must take function `name` (exactly as in [function declaration](https://ai.google.dev/gemini-api/docs/function-calling#function-declarations)) as first command line argument. - - It must read function arguments as JSON on `stdin`, analogous to [`functionCall.args`](https://cloud.google.com/vertex-ai/generative-ai/docs/model-reference/inference#functioncall). - - It must return function output as JSON on `stdout`, analogous to [`functionResponse.response.content`](https://cloud.google.com/vertex-ai/generative-ai/docs/model-reference/inference#functionresponse). - - **Default:** Empty - - **Example:** `"toolCallCommand": "bin/call_tool"` - -- **`mcpServers`** (object): - - **Description:** Configures connections to one or more Model-Context Protocol (MCP) servers for discovering and using custom tools. Qwen Code attempts to connect to each configured MCP server to discover available tools. If multiple MCP servers expose a tool with the same name, the tool names will be prefixed with the server alias you defined in the configuration (e.g., `serverAlias__actualToolName`) to avoid conflicts. Note that the system might strip certain schema properties from MCP tool definitions for compatibility. At least one of `command`, `url`, or `httpUrl` must be provided. If multiple are specified, the order of precedence is `httpUrl`, then `url`, then `command`. - - **Default:** Empty - - **Properties:** - - **``** (object): The server parameters for the named server. - - `command` (string, optional): The command to execute to start the MCP server via standard I/O. - - `args` (array of strings, optional): Arguments to pass to the command. - - `env` (object, optional): Environment variables to set for the server process. - - `cwd` (string, optional): The working directory in which to start the server. - - `url` (string, optional): The URL of an MCP server that uses Server-Sent Events (SSE) for communication. - - `httpUrl` (string, optional): The URL of an MCP server that uses streamable HTTP for communication. - - `headers` (object, optional): A map of HTTP headers to send with requests to `url` or `httpUrl`. - - `timeout` (number, optional): Timeout in milliseconds for requests to this MCP server. - - `trust` (boolean, optional): Trust this server and bypass all tool call confirmations. - - `description` (string, optional): A brief description of the server, which may be used for display purposes. - - `includeTools` (array of strings, optional): List of tool names to include from this MCP server. When specified, only the tools listed here will be available from this server (whitelist behavior). If not specified, all tools from the server are enabled by default. - - `excludeTools` (array of strings, optional): List of tool names to exclude from this MCP server. Tools listed here will not be available to the model, even if they are exposed by the server. **Note:** `excludeTools` takes precedence over `includeTools` - if a tool is in both lists, it will be excluded. - - **Example:** - ```json - "mcpServers": { - "myPythonServer": { - "command": "python", - "args": ["mcp_server.py", "--port", "8080"], - "cwd": "./mcp_tools/python", - "timeout": 5000, - "includeTools": ["safe_tool", "file_reader"], - }, - "myNodeServer": { - "command": "node", - "args": ["mcp_server.js"], - "cwd": "./mcp_tools/node", - "excludeTools": ["dangerous_tool", "file_deleter"] - }, - "myDockerServer": { - "command": "docker", - "args": ["run", "-i", "--rm", "-e", "API_KEY", "ghcr.io/foo/bar"], - "env": { - "API_KEY": "$MY_API_TOKEN" - } - }, - "mySseServer": { - "url": "http://localhost:8081/events", - "headers": { - "Authorization": "Bearer $MY_SSE_TOKEN" - }, - "description": "An example SSE-based MCP server." - }, - "myStreamableHttpServer": { - "httpUrl": "http://localhost:8082/stream", - "headers": { - "X-API-Key": "$MY_HTTP_API_KEY" - }, - "description": "An example Streamable HTTP-based MCP server." - } - } - ``` - -- **`checkpointing`** (object): - - **Description:** Configures the checkpointing feature, which allows you to save and restore conversation and file states. See the [Checkpointing documentation](../checkpointing.md) for more details. - - **Default:** `{"enabled": false}` - - **Properties:** - - **`enabled`** (boolean): When `true`, the `/restore` command is available. - -- **`preferredEditor`** (string): - - **Description:** Specifies the preferred editor to use for viewing diffs. - - **Default:** `vscode` - - **Example:** `"preferredEditor": "vscode"` - -- **`telemetry`** (object) - - **Description:** Configures logging and metrics collection for Qwen Code. For more information, see [Telemetry](../telemetry.md). - - **Default:** `{"enabled": false, "target": "local", "otlpEndpoint": "http://localhost:4317", "logPrompts": true}` - - **Properties:** - - **`enabled`** (boolean): Whether or not telemetry is enabled. - - **`target`** (string): The destination for collected telemetry. Supported values are `local` and `gcp`. - - **`otlpEndpoint`** (string): The endpoint for the OTLP Exporter. - - **`logPrompts`** (boolean): Whether or not to include the content of user prompts in the logs. - - **Example:** - ```json - "telemetry": { - "enabled": true, - "target": "local", - "otlpEndpoint": "http://localhost:16686", - "logPrompts": false - } - ``` -- **`usageStatisticsEnabled`** (boolean): - - **Description:** Enables or disables the collection of usage statistics. See [Usage Statistics](#usage-statistics) for more information. - - **Default:** `true` - - **Example:** - ```json - "usageStatisticsEnabled": false - ``` - -- **`hideTips`** (boolean): - - **Description:** Enables or disables helpful tips in the CLI interface. - - **Default:** `false` - - **Example:** - - ```json - "hideTips": true - ``` - -- **`hideBanner`** (boolean): - - **Description:** Enables or disables the startup banner (ASCII art logo) in the CLI interface. - - **Default:** `false` - - **Example:** - - ```json - "hideBanner": true - ``` - -- **`maxSessionTurns`** (number): - - **Description:** Sets the maximum number of turns for a session. If the session exceeds this limit, the CLI will stop processing and start a new chat. - - **Default:** `-1` (unlimited) - - **Example:** - ```json - "maxSessionTurns": 10 - ``` - -- **`summarizeToolOutput`** (object): - - **Description:** Enables or disables the summarization of tool output. You can specify the token budget for the summarization using the `tokenBudget` setting. - - Note: Currently only the `run_shell_command` tool is supported. - - **Default:** `{}` (Disabled by default) - - **Example:** - ```json - "summarizeToolOutput": { - "run_shell_command": { - "tokenBudget": 2000 - } - } - ``` - -- **`excludedProjectEnvVars`** (array of strings): - - **Description:** Specifies environment variables that should be excluded from being loaded from project `.env` files. This prevents project-specific environment variables (like `DEBUG=true`) from interfering with the CLI behavior. Variables from `.qwen/.env` files are never excluded. - - **Default:** `["DEBUG", "DEBUG_MODE"]` - - **Example:** - ```json - "excludedProjectEnvVars": ["DEBUG", "DEBUG_MODE", "NODE_ENV"] - ``` - -- **`includeDirectories`** (array of strings): - - **Description:** Specifies an array of additional absolute or relative paths to include in the workspace context. Missing directories will be skipped with a warning by default. Paths can use `~` to refer to the user's home directory. This setting can be combined with the `--include-directories` command-line flag. - - **Default:** `[]` - - **Example:** - ```json - "includeDirectories": [ - "/path/to/another/project", - "../shared-library", - "~/common-utils" - ] - ``` - -- **`loadMemoryFromIncludeDirectories`** (boolean): - - **Description:** Controls the behavior of the `/memory refresh` command. If set to `true`, `QWEN.md` files should be loaded from all directories that are added. If set to `false`, `QWEN.md` should only be loaded from the current directory. - - **Default:** `false` - - **Example:** - ```json - "loadMemoryFromIncludeDirectories": true - ``` - -- **`tavilyApiKey`** (string): - - **Description:** API key for Tavily web search service. Used to enable the `web_search` tool functionality. - - **Note:** This is a legacy configuration format. For Qwen OAuth users, DashScope provider is automatically available without any configuration. For other authentication types, configure Tavily or Google providers using the new `webSearch` configuration format. - - **Default:** `undefined` (web search disabled) - - **Example:** `"tavilyApiKey": "tvly-your-api-key-here"` -- **`chatCompression`** (object): - - **Description:** Controls the settings for chat history compression, both automatic and - when manually invoked through the /compress command. - - **Properties:** - - **`contextPercentageThreshold`** (number): A value between 0 and 1 that specifies the token threshold for compression as a percentage of the model's total token limit. For example, a value of `0.6` will trigger compression when the chat history exceeds 60% of the token limit. - - **Example:** - ```json - "chatCompression": { - "contextPercentageThreshold": 0.6 - } - ``` - -- **`showLineNumbers`** (boolean): - - **Description:** Controls whether line numbers are displayed in code blocks in the CLI output. - - **Default:** `true` - - **Example:** - ```json - "showLineNumbers": false - ``` - -- **`accessibility`** (object): - - **Description:** Configures accessibility features for the CLI. - - **Properties:** - - **`screenReader`** (boolean): Enables screen reader mode, which adjusts the TUI for better compatibility with screen readers. This can also be enabled with the `--screen-reader` command-line flag, which will take precedence over the setting. - - **`disableLoadingPhrases`** (boolean): Disables the display of loading phrases during operations. - - **Default:** `{"screenReader": false, "disableLoadingPhrases": false}` - - **Example:** - ```json - "accessibility": { - "screenReader": true, - "disableLoadingPhrases": true - } - ``` - -- **`skipNextSpeakerCheck`** (boolean): - - **Description:** Skips the next speaker check after text responses. When enabled, the system bypasses analyzing whether the AI should continue speaking. - - **Default:** `false` - - **Example:** - ```json - "skipNextSpeakerCheck": true - ``` - -- **`skipLoopDetection`** (boolean): - - **Description:** Disables all loop detection checks (streaming and LLM-based). Loop detection prevents infinite loops in AI responses but can generate false positives that interrupt legitimate workflows. Enable this option if you experience frequent false positive loop detection interruptions. - - **Default:** `false` - - **Example:** - ```json - "skipLoopDetection": true - ``` - -- **`approvalMode`** (string): - - **Description:** Sets the default approval mode for tool usage. Accepted values are: - - `plan`: Analyze only, do not modify files or execute commands. - - `default`: Require approval before file edits or shell commands run. - - `auto-edit`: Automatically approve file edits. - - `yolo`: Automatically approve all tool calls. - - **Default:** `"default"` - - **Example:** - ```json - "approvalMode": "plan" - ``` - -### Example `settings.json`: - -```json -{ - "theme": "GitHub", - "sandbox": "docker", - "toolDiscoveryCommand": "bin/get_tools", - "toolCallCommand": "bin/call_tool", - "tavilyApiKey": "$TAVILY_API_KEY", - "mcpServers": { - "mainServer": { - "command": "bin/mcp_server.py" - }, - "anotherServer": { - "command": "node", - "args": ["mcp_server.js", "--verbose"] - } - }, - "telemetry": { - "enabled": true, - "target": "local", - "otlpEndpoint": "http://localhost:4317", - "logPrompts": true - }, - "usageStatisticsEnabled": true, - "hideTips": false, - "hideBanner": false, - "skipNextSpeakerCheck": false, - "skipLoopDetection": false, - "maxSessionTurns": 10, - "summarizeToolOutput": { - "run_shell_command": { - "tokenBudget": 100 - } - }, - "excludedProjectEnvVars": ["DEBUG", "DEBUG_MODE", "NODE_ENV"], - "includeDirectories": ["path/to/dir1", "~/path/to/dir2", "../path/to/dir3"], - "loadMemoryFromIncludeDirectories": true -} -``` - -## Shell History - -The CLI keeps a history of shell commands you run. To avoid conflicts between different projects, this history is stored in a project-specific directory within your user's home folder. - -- **Location:** `~/.qwen/tmp//shell_history` - - `` is a unique identifier generated from your project's root path. - - The history is stored in a file named `shell_history`. - -## Environment Variables & `.env` Files - -Environment variables are a common way to configure applications, especially for sensitive information like API keys or for settings that might change between environments. For authentication setup, see the [Authentication documentation](./authentication.md) which covers all available authentication methods. - -The CLI automatically loads environment variables from an `.env` file. The loading order is: - -1. `.env` file in the current working directory. -2. If not found, it searches upwards in parent directories until it finds an `.env` file or reaches the project root (identified by a `.git` folder) or the home directory. -3. If still not found, it looks for `~/.env` (in the user's home directory). - -**Environment Variable Exclusion:** Some environment variables (like `DEBUG` and `DEBUG_MODE`) are automatically excluded from project `.env` files by default to prevent interference with the CLI behavior. Variables from `.qwen/.env` files are never excluded. You can customize this behavior using the `excludedProjectEnvVars` setting in your `settings.json` file. - -- **`OPENAI_API_KEY`**: - - One of several available [authentication methods](./authentication.md). - - Set this in your shell profile (e.g., `~/.bashrc`, `~/.zshrc`) or an `.env` file. -- **`OPENAI_BASE_URL`**: - - One of several available [authentication methods](./authentication.md). - - Set this in your shell profile (e.g., `~/.bashrc`, `~/.zshrc`) or an `.env` file. -- **`OPENAI_MODEL`**: - - Specifies the default OPENAI model to use. - - Overrides the hardcoded default - - Example: `export OPENAI_MODEL="qwen3-coder-plus"` -- **`GEMINI_SANDBOX`**: - - Alternative to the `sandbox` setting in `settings.json`. - - Accepts `true`, `false`, `docker`, `podman`, or a custom command string. -- **`SEATBELT_PROFILE`** (macOS specific): - - Switches the Seatbelt (`sandbox-exec`) profile on macOS. - - `permissive-open`: (Default) Restricts writes to the project folder (and a few other folders, see `packages/cli/src/utils/sandbox-macos-permissive-open.sb`) but allows other operations. - - `strict`: Uses a strict profile that declines operations by default. - - ``: Uses a custom profile. To define a custom profile, create a file named `sandbox-macos-.sb` in your project's `.qwen/` directory (e.g., `my-project/.qwen/sandbox-macos-custom.sb`). -- **`DEBUG` or `DEBUG_MODE`** (often used by underlying libraries or the CLI itself): - - Set to `true` or `1` to enable verbose debug logging, which can be helpful for troubleshooting. - - **Note:** These variables are automatically excluded from project `.env` files by default to prevent interference with the CLI behavior. Use `.qwen/.env` files if you need to set these for Qwen Code specifically. -- **`NO_COLOR`**: - - Set to any value to disable all color output in the CLI. -- **`CLI_TITLE`**: - - Set to a string to customize the title of the CLI. -- **`CODE_ASSIST_ENDPOINT`**: - - Specifies the endpoint for the code assist server. - - This is useful for development and testing. -- **`TAVILY_API_KEY`**: - - Your API key for the Tavily web search service. - - Used to enable the `web_search` tool functionality. - - **Note:** For Qwen OAuth users, DashScope provider is automatically available without any configuration. For other authentication types, configure Tavily or Google providers to enable web search. - - Example: `export TAVILY_API_KEY="tvly-your-api-key-here"` - -## Command-Line Arguments - -Arguments passed directly when running the CLI can override other configurations for that specific session. - -- **`--model `** (**`-m `**): - - Specifies the Qwen model to use for this session. - - Example: `npm start -- --model qwen3-coder-plus` -- **`--prompt `** (**`-p `**): - - Used to pass a prompt directly to the command. This invokes Qwen Code in a non-interactive mode. -- **`--prompt-interactive `** (**`-i `**): - - Starts an interactive session with the provided prompt as the initial input. - - The prompt is processed within the interactive session, not before it. - - Cannot be used when piping input from stdin. - - Example: `qwen -i "explain this code"` -- **`--sandbox`** (**`-s`**): - - Enables sandbox mode for this session. -- **`--sandbox-image`**: - - Sets the sandbox image URI. -- **`--debug`** (**`-d`**): - - Enables debug mode for this session, providing more verbose output. -- **`--all-files`** (**`-a`**): - - If set, recursively includes all files within the current directory as context for the prompt. -- **`--help`** (or **`-h`**): - - Displays help information about command-line arguments. -- **`--show-memory-usage`**: - - Displays the current memory usage. -- **`--yolo`**: - - Enables YOLO mode, which automatically approves all tool calls. -- **`--approval-mode `**: - - Sets the approval mode for tool calls. Supported modes: - - `plan`: Analyze only—do not modify files or execute commands. - - `default`: Require approval for file edits or shell commands (default behavior). - - `auto-edit`: Automatically approve edit tools (edit, write_file) while prompting for others. - - `yolo`: Automatically approve all tool calls (equivalent to `--yolo`). - - Cannot be used together with `--yolo`. Use `--approval-mode=yolo` instead of `--yolo` for the new unified approach. - - Example: `qwen --approval-mode auto-edit` -- **`--allowed-tools `**: - - A comma-separated list of tool names that will bypass the confirmation dialog. - - Example: `qwen --allowed-tools "ShellTool(git status)"` -- **`--telemetry`**: - - Enables [telemetry](../telemetry.md). -- **`--telemetry-target`**: - - Sets the telemetry target. See [telemetry](../telemetry.md) for more information. -- **`--telemetry-otlp-endpoint`**: - - Sets the OTLP endpoint for telemetry. See [telemetry](../telemetry.md) for more information. -- **`--telemetry-otlp-protocol`**: - - Sets the OTLP protocol for telemetry (`grpc` or `http`). Defaults to `grpc`. See [telemetry](../telemetry.md) for more information. -- **`--telemetry-log-prompts`**: - - Enables logging of prompts for telemetry. See [telemetry](../telemetry.md) for more information. -- **`--checkpointing`**: - - Enables [checkpointing](../checkpointing.md). -- **`--extensions `** (**`-e `**): - - Specifies a list of extensions to use for the session. If not provided, all available extensions are used. - - Use the special term `qwen -e none` to disable all extensions. - - Example: `qwen -e my-extension -e my-other-extension` -- **`--list-extensions`** (**`-l`**): - - Lists all available extensions and exits. -- **`--proxy`**: - - Sets the proxy for the CLI. - - Example: `--proxy http://localhost:7890`. -- **`--include-directories `**: - - Includes additional directories in the workspace for multi-directory support. - - Can be specified multiple times or as comma-separated values. - - 5 directories can be added at maximum. - - Example: `--include-directories /path/to/project1,/path/to/project2` or `--include-directories /path/to/project1 --include-directories /path/to/project2` -- **`--screen-reader`**: - - Enables screen reader mode for accessibility. -- **`--version`**: - - Displays the version of the CLI. -- **`--openai-logging`**: - - Enables logging of OpenAI API calls for debugging and analysis. This flag overrides the `enableOpenAILogging` setting in `settings.json`. -- **`--openai-logging-dir `**: - - Sets a custom directory path for OpenAI API logs. This flag overrides the `openAILoggingDir` setting in `settings.json`. Supports absolute paths, relative paths, and `~` expansion. - - **Example:** `qwen --openai-logging-dir "~/qwen-logs" --openai-logging` -- **`--tavily-api-key `**: - - Sets the Tavily API key for web search functionality for this session. - - Example: `qwen --tavily-api-key tvly-your-api-key-here` - -## Context Files (Hierarchical Instructional Context) - -While not strictly configuration for the CLI's _behavior_, context files (defaulting to `QWEN.md` but configurable via the `contextFileName` setting) are crucial for configuring the _instructional context_ (also referred to as "memory"). This powerful feature allows you to give project-specific instructions, coding style guides, or any relevant background information to the AI, making its responses more tailored and accurate to your needs. The CLI includes UI elements, such as an indicator in the footer showing the number of loaded context files, to keep you informed about the active context. - -- **Purpose:** These Markdown files contain instructions, guidelines, or context that you want the Qwen model to be aware of during your interactions. The system is designed to manage this instructional context hierarchically. - -### Example Context File Content (e.g., `QWEN.md`) - -Here's a conceptual example of what a context file at the root of a TypeScript project might contain: - -```markdown -# Project: My Awesome TypeScript Library - -## General Instructions: - -- When generating new TypeScript code, please follow the existing coding style. -- Ensure all new functions and classes have JSDoc comments. -- Prefer functional programming paradigms where appropriate. -- All code should be compatible with TypeScript 5.0 and Node.js 20+. - -## Coding Style: - -- Use 2 spaces for indentation. -- Interface names should be prefixed with `I` (e.g., `IUserService`). -- Private class members should be prefixed with an underscore (`_`). -- Always use strict equality (`===` and `!==`). - -## Specific Component: `src/api/client.ts` - -- This file handles all outbound API requests. -- When adding new API call functions, ensure they include robust error handling and logging. -- Use the existing `fetchWithRetry` utility for all GET requests. - -## Regarding Dependencies: - -- Avoid introducing new external dependencies unless absolutely necessary. -- If a new dependency is required, please state the reason. -``` - -This example demonstrates how you can provide general project context, specific coding conventions, and even notes about particular files or components. The more relevant and precise your context files are, the better the AI can assist you. Project-specific context files are highly encouraged to establish conventions and context. - -- **Hierarchical Loading and Precedence:** The CLI implements a sophisticated hierarchical memory system by loading context files (e.g., `QWEN.md`) from several locations. Content from files lower in this list (more specific) typically overrides or supplements content from files higher up (more general). The exact concatenation order and final context can be inspected using the `/memory show` command. The typical loading order is: - 1. **Global Context File:** - - Location: `~/.qwen/` (e.g., `~/.qwen/QWEN.md` in your user home directory). - - Scope: Provides default instructions for all your projects. - 2. **Project Root & Ancestors Context Files:** - - Location: The CLI searches for the configured context file in the current working directory and then in each parent directory up to either the project root (identified by a `.git` folder) or your home directory. - - Scope: Provides context relevant to the entire project or a significant portion of it. - 3. **Sub-directory Context Files (Contextual/Local):** - - Location: The CLI also scans for the configured context file in subdirectories _below_ the current working directory (respecting common ignore patterns like `node_modules`, `.git`, etc.). The breadth of this search is limited to 200 directories by default, but can be configured with a `memoryDiscoveryMaxDirs` field in your `settings.json` file. - - Scope: Allows for highly specific instructions relevant to a particular component, module, or subsection of your project. -- **Concatenation & UI Indication:** The contents of all found context files are concatenated (with separators indicating their origin and path) and provided as part of the system prompt. The CLI footer displays the count of loaded context files, giving you a quick visual cue about the active instructional context. -- **Importing Content:** You can modularize your context files by importing other Markdown files using the `@path/to/file.md` syntax. For more details, see the [Memory Import Processor documentation](../core/memport.md). -- **Commands for Memory Management:** - - Use `/memory refresh` to force a re-scan and reload of all context files from all configured locations. This updates the AI's instructional context. - - Use `/memory show` to display the combined instructional context currently loaded, allowing you to verify the hierarchy and content being used by the AI. - - See the [Commands documentation](./commands.md#memory) for full details on the `/memory` command and its sub-commands (`show` and `refresh`). - -By understanding and utilizing these configuration layers and the hierarchical nature of context files, you can effectively manage the AI's memory and tailor Qwen Code's responses to your specific needs and projects. - -## Sandboxing - -Qwen Code can execute potentially unsafe operations (like shell commands and file modifications) within a sandboxed environment to protect your system. - -Sandboxing is disabled by default, but you can enable it in a few ways: - -- Using `--sandbox` or `-s` flag. -- Setting `GEMINI_SANDBOX` environment variable. -- Sandbox is enabled when using `--yolo` or `--approval-mode=yolo` by default. - -By default, it uses a pre-built `qwen-code-sandbox` Docker image. - -For project-specific sandboxing needs, you can create a custom Dockerfile at `.qwen/sandbox.Dockerfile` in your project's root directory. This Dockerfile can be based on the base sandbox image: - -```dockerfile -FROM qwen-code-sandbox - -# Add your custom dependencies or configurations here -# For example: -# RUN apt-get update && apt-get install -y some-package -# COPY ./my-config /app/my-config -``` - -When `.qwen/sandbox.Dockerfile` exists, you can use `BUILD_SANDBOX` environment variable when running Qwen Code to automatically build the custom sandbox image: - -```bash -BUILD_SANDBOX=1 qwen -s -``` - -## Usage Statistics - -To help us improve Qwen Code, we collect anonymized usage statistics. This data helps us understand how the CLI is used, identify common issues, and prioritize new features. - -**What we collect:** - -- **Tool Calls:** We log the names of the tools that are called, whether they succeed or fail, and how long they take to execute. We do not collect the arguments passed to the tools or any data returned by them. -- **API Requests:** We log the model used for each request, the duration of the request, and whether it was successful. We do not collect the content of the prompts or responses. -- **Session Information:** We collect information about the configuration of the CLI, such as the enabled tools and the approval mode. - -**What we DON'T collect:** - -- **Personally Identifiable Information (PII):** We do not collect any personal information, such as your name, email address, or API keys. -- **Prompt and Response Content:** We do not log the content of your prompts or the responses from the model. -- **File Content:** We do not log the content of any files that are read or written by the CLI. - -**How to opt out:** - -You can opt out of usage statistics collection at any time by setting the `usageStatisticsEnabled` property to `false` in your `settings.json` file: - -```json -{ - "usageStatisticsEnabled": false -} -``` - -Note: When usage statistics are enabled, events are sent to an Alibaba Cloud RUM collection endpoint. - -- **`enableWelcomeBack`** (boolean): - - **Description:** Show welcome back dialog when returning to a project with conversation history. - - **Default:** `true` - - **Category:** UI - - **Requires Restart:** No - - **Example:** `"enableWelcomeBack": false` - - **Details:** When enabled, Qwen Code will automatically detect if you're returning to a project with a previously generated project summary (`.qwen/PROJECT_SUMMARY.md`) and show a dialog allowing you to continue your previous conversation or start fresh. This feature integrates with the `/summary` command. See the [Welcome Back documentation](./welcome-back.md) for more details. diff --git a/docs/cli/configuration.md b/docs/cli/configuration.md deleted file mode 100644 index aef6bc4fc..000000000 --- a/docs/cli/configuration.md +++ /dev/null @@ -1,757 +0,0 @@ -# Qwen Code Configuration - -**Note on New Configuration Format** - -The format of the `settings.json` file has been updated to a new, more organized structure. The old format will be migrated automatically. - -For details on the previous format, please see the [v1 Configuration documentation](./configuration-v1.md). - -Qwen Code offers several ways to configure its behavior, including environment variables, command-line arguments, and settings files. This document outlines the different configuration methods and available settings. - -## Configuration layers - -Configuration is applied in the following order of precedence (lower numbers are overridden by higher numbers): - -1. **Default values:** Hardcoded defaults within the application. -2. **System defaults file:** System-wide default settings that can be overridden by other settings files. -3. **User settings file:** Global settings for the current user. -4. **Project settings file:** Project-specific settings. -5. **System settings file:** System-wide settings that override all other settings files. -6. **Environment variables:** System-wide or session-specific variables, potentially loaded from `.env` files. -7. **Command-line arguments:** Values passed when launching the CLI. - -## Settings files - -Qwen Code uses JSON settings files for persistent configuration. There are four locations for these files: - -- **System defaults file:** - - **Location:** `/etc/qwen-code/system-defaults.json` (Linux), `C:\ProgramData\qwen-code\system-defaults.json` (Windows) or `/Library/Application Support/QwenCode/system-defaults.json` (macOS). The path can be overridden using the `QWEN_CODE_SYSTEM_DEFAULTS_PATH` environment variable. - - **Scope:** Provides a base layer of system-wide default settings. These settings have the lowest precedence and are intended to be overridden by user, project, or system override settings. -- **User settings file:** - - **Location:** `~/.qwen/settings.json` (where `~` is your home directory). - - **Scope:** Applies to all Qwen Code sessions for the current user. -- **Project settings file:** - - **Location:** `.qwen/settings.json` within your project's root directory. - - **Scope:** Applies only when running Qwen Code from that specific project. Project settings override user settings. - -- **System settings file:** - - **Location:** `/etc/qwen-code/settings.json` (Linux), `C:\ProgramData\qwen-code\settings.json` (Windows) or `/Library/Application Support/QwenCode/settings.json` (macOS). The path can be overridden using the `QWEN_CODE_SYSTEM_SETTINGS_PATH` environment variable. - - **Scope:** Applies to all Qwen Code sessions on the system, for all users. System settings override user and project settings. May be useful for system administrators at enterprises to have controls over users' Qwen Code setups. - -**Note on environment variables in settings:** String values within your `settings.json` files can reference environment variables using either `$VAR_NAME` or `${VAR_NAME}` syntax. These variables will be automatically resolved when the settings are loaded. For example, if you have an environment variable `MY_API_TOKEN`, you could use it in `settings.json` like this: `"apiKey": "$MY_API_TOKEN"`. - -### The `.qwen` directory in your project - -In addition to a project settings file, a project's `.qwen` directory can contain other project-specific files related to Qwen Code's operation, such as: - -- [Custom sandbox profiles](#sandboxing) (e.g., `.qwen/sandbox-macos-custom.sb`, `.qwen/sandbox.Dockerfile`). - -### Available settings in `settings.json` - -Settings are organized into categories. All settings should be placed within their corresponding top-level category object in your `settings.json` file. - -#### `general` - -- **`general.preferredEditor`** (string): - - **Description:** The preferred editor to open files in. - - **Default:** `undefined` - -- **`general.vimMode`** (boolean): - - **Description:** Enable Vim keybindings. - - **Default:** `false` - -- **`general.disableAutoUpdate`** (boolean): - - **Description:** Disable automatic updates. - - **Default:** `false` - -- **`general.disableUpdateNag`** (boolean): - - **Description:** Disable update notification prompts. - - **Default:** `false` - -- **`general.checkpointing.enabled`** (boolean): - - **Description:** Enable session checkpointing for recovery. - - **Default:** `false` - -#### `output` - -- **`output.format`** (string): - - **Description:** The format of the CLI output. - - **Default:** `"text"` - - **Values:** `"text"`, `"json"` - -#### `ui` - -- **`ui.theme`** (string): - - **Description:** The color theme for the UI. See [Themes](./themes.md) for available options. - - **Default:** `undefined` - -- **`ui.customThemes`** (object): - - **Description:** Custom theme definitions. - - **Default:** `{}` - -- **`ui.hideWindowTitle`** (boolean): - - **Description:** Hide the window title bar. - - **Default:** `false` - -- **`ui.hideTips`** (boolean): - - **Description:** Hide helpful tips in the UI. - - **Default:** `false` - -- **`ui.hideBanner`** (boolean): - - **Description:** Hide the application banner. - - **Default:** `false` - -- **`ui.hideFooter`** (boolean): - - **Description:** Hide the footer from the UI. - - **Default:** `false` - -- **`ui.showMemoryUsage`** (boolean): - - **Description:** Display memory usage information in the UI. - - **Default:** `false` - -- **`ui.showLineNumbers`** (boolean): - - **Description:** Show line numbers in the chat. - - **Default:** `false` - -- **`ui.showCitations`** (boolean): - - **Description:** Show citations for generated text in the chat. - - **Default:** `true` - -- **`enableWelcomeBack`** (boolean): - - **Description:** Show welcome back dialog when returning to a project with conversation history. - - **Default:** `true` - -- **`ui.accessibility.disableLoadingPhrases`** (boolean): - - **Description:** Disable loading phrases for accessibility. - - **Default:** `false` - -- **`ui.customWittyPhrases`** (array of strings): - - **Description:** A list of custom phrases to display during loading states. When provided, the CLI will cycle through these phrases instead of the default ones. - - **Default:** `[]` - -#### `ide` - -- **`ide.enabled`** (boolean): - - **Description:** Enable IDE integration mode. - - **Default:** `false` - -- **`ide.hasSeenNudge`** (boolean): - - **Description:** Whether the user has seen the IDE integration nudge. - - **Default:** `false` - -#### `privacy` - -- **`privacy.usageStatisticsEnabled`** (boolean): - - **Description:** Enable collection of usage statistics. - - **Default:** `true` - -#### `model` - -- **`model.name`** (string): - - **Description:** The Qwen model to use for conversations. - - **Default:** `undefined` - -- **`model.maxSessionTurns`** (number): - - **Description:** Maximum number of user/model/tool turns to keep in a session. -1 means unlimited. - - **Default:** `-1` - -- **`model.summarizeToolOutput`** (object): - - **Description:** Enables or disables the summarization of tool output. You can specify the token budget for the summarization using the `tokenBudget` setting. Note: Currently only the `run_shell_command` tool is supported. For example `{"run_shell_command": {"tokenBudget": 2000}}` - - **Default:** `undefined` - -- **`model.chatCompression.contextPercentageThreshold`** (number): - - **Description:** Sets the threshold for chat history compression as a percentage of the model's total token limit. This is a value between 0 and 1 that applies to both automatic compression and the manual `/compress` command. For example, a value of `0.6` will trigger compression when the chat history exceeds 60% of the token limit. Use `0` to disable compression entirely. - - **Default:** `0.7` - -- **`model.generationConfig`** (object): - - **Description:** Advanced overrides passed to the underlying content generator. Supports request controls such as `timeout`, `maxRetries`, and `disableCacheControl`, along with fine-tuning knobs under `samplingParams` (for example `temperature`, `top_p`, `max_tokens`). Leave unset to rely on provider defaults. - - **Default:** `undefined` - - **Example:** - - ```json - { - "model": { - "generationConfig": { - "timeout": 60000, - "disableCacheControl": false, - "samplingParams": { - "temperature": 0.2, - "top_p": 0.8, - "max_tokens": 1024 - } - } - } - } - ``` - -- **`model.skipNextSpeakerCheck`** (boolean): - - **Description:** Skip the next speaker check. - - **Default:** `false` - -- **`model.skipLoopDetection`**(boolean): - - **Description:** Disables loop detection checks. Loop detection prevents infinite loops in AI responses but can generate false positives that interrupt legitimate workflows. Enable this option if you experience frequent false positive loop detection interruptions. - - **Default:** `false` - -- **`model.skipStartupContext`** (boolean): - - **Description:** Skips sending the startup workspace context (environment summary and acknowledgement) at the beginning of each session. Enable this if you prefer to provide context manually or want to save tokens on startup. - - **Default:** `false` - -- **`model.enableOpenAILogging`** (boolean): - - **Description:** Enables logging of OpenAI API calls for debugging and analysis. When enabled, API requests and responses are logged to JSON files. - - **Default:** `false` - -- **`model.openAILoggingDir`** (string): - - **Description:** Custom directory path for OpenAI API logs. If not specified, defaults to `logs/openai` in the current working directory. Supports absolute paths, relative paths (resolved from current working directory), and `~` expansion (home directory). - - **Default:** `undefined` - - **Examples:** - - `"~/qwen-logs"` - Logs to `~/qwen-logs` directory - - `"./custom-logs"` - Logs to `./custom-logs` relative to current directory - - `"/tmp/openai-logs"` - Logs to absolute path `/tmp/openai-logs` - -#### `context` - -- **`context.fileName`** (string or array of strings): - - **Description:** The name of the context file(s). - - **Default:** `undefined` - -- **`context.importFormat`** (string): - - **Description:** The format to use when importing memory. - - **Default:** `undefined` - -- **`context.discoveryMaxDirs`** (number): - - **Description:** Maximum number of directories to search for memory. - - **Default:** `200` - -- **`context.includeDirectories`** (array): - - **Description:** Additional directories to include in the workspace context. Missing directories will be skipped with a warning. - - **Default:** `[]` - -- **`context.loadFromIncludeDirectories`** (boolean): - - **Description:** Controls the behavior of the `/memory refresh` command. If set to `true`, `QWEN.md` files should be loaded from all directories that are added. If set to `false`, `QWEN.md` should only be loaded from the current directory. - - **Default:** `false` - -- **`context.fileFiltering.respectGitIgnore`** (boolean): - - **Description:** Respect .gitignore files when searching. - - **Default:** `true` - -- **`context.fileFiltering.respectQwenIgnore`** (boolean): - - **Description:** Respect .qwenignore files when searching. - - **Default:** `true` - -- **`context.fileFiltering.enableRecursiveFileSearch`** (boolean): - - **Description:** Whether to enable searching recursively for filenames under the current tree when completing `@` prefixes in the prompt. - - **Default:** `true` - -#### `tools` - -- **`tools.sandbox`** (boolean or string): - - **Description:** Sandbox execution environment (can be a boolean or a path string). - - **Default:** `undefined` - -- **`tools.shell.enableInteractiveShell`** (boolean): - - Use `node-pty` for an interactive shell experience. Fallback to `child_process` still applies. Defaults to `false`. - -- **`tools.core`** (array of strings): - - **Description:** This can be used to restrict the set of built-in tools [with an allowlist](./enterprise.md#restricting-tool-access). See [Built-in Tools](../core/tools-api.md#built-in-tools) for a list of core tools. The match semantics are the same as `tools.allowed`. - - **Default:** `undefined` - -- **`tools.exclude`** (array of strings): - - **Description:** Tool names to exclude from discovery. - - **Default:** `undefined` - -- **`tools.allowed`** (array of strings): - - **Description:** A list of tool names that will bypass the confirmation dialog. This is useful for tools that you trust and use frequently. For example, `["run_shell_command(git)", "run_shell_command(npm test)"]` will skip the confirmation dialog to run any `git` and `npm test` commands. See [Shell Tool command restrictions](../tools/shell.md#command-restrictions) for details on prefix matching, command chaining, etc. - - **Default:** `undefined` - -- **`tools.approvalMode`** (string): - - **Description:** Sets the default approval mode for tool usage. Accepted values are: - - `plan`: Analyze only, do not modify files or execute commands. - - `default`: Require approval before file edits or shell commands run. - - `auto-edit`: Automatically approve file edits. - - `yolo`: Automatically approve all tool calls. - - **Default:** `default` - -- **`tools.discoveryCommand`** (string): - - **Description:** Command to run for tool discovery. - - **Default:** `undefined` - -- **`tools.callCommand`** (string): - - **Description:** Defines a custom shell command for calling a specific tool that was discovered using `tools.discoveryCommand`. The shell command must meet the following criteria: - - It must take function `name` (exactly as in [function declaration](https://ai.google.dev/gemini-api/docs/function-calling#function-declarations)) as first command line argument. - - It must read function arguments as JSON on `stdin`, analogous to [`functionCall.args`](https://cloud.google.com/vertex-ai/generative-ai/docs/model-reference/inference#functioncall). - - It must return function output as JSON on `stdout`, analogous to [`functionResponse.response.content`](https://cloud.google.com/vertex-ai/generative-ai/docs/model-reference/inference#functionresponse). - - **Default:** `undefined` - -- **`tools.useRipgrep`** (boolean): - - **Description:** Use ripgrep for file content search instead of the fallback implementation. Provides faster search performance. - - **Default:** `true` - -- **`tools.useBuiltinRipgrep`** (boolean): - - **Description:** Use the bundled ripgrep binary. When set to `false`, the system-level `rg` command will be used instead. This setting is only effective when `tools.useRipgrep` is `true`. - - **Default:** `true` - -- **`tools.enableToolOutputTruncation`** (boolean): - - **Description:** Enable truncation of large tool outputs. - - **Default:** `true` - - **Requires restart:** Yes - -- **`tools.truncateToolOutputThreshold`** (number): - - **Description:** Truncate tool output if it is larger than this many characters. Applies to Shell, Grep, Glob, ReadFile and ReadManyFiles tools. - - **Default:** `25000` - - **Requires restart:** Yes - -- **`tools.truncateToolOutputLines`** (number): - - **Description:** Maximum lines or entries kept when truncating tool output. Applies to Shell, Grep, Glob, ReadFile and ReadManyFiles tools. - - **Default:** `1000` - - **Requires restart:** Yes - -#### `mcp` - -- **`mcp.serverCommand`** (string): - - **Description:** Command to start an MCP server. - - **Default:** `undefined` - -- **`mcp.allowed`** (array of strings): - - **Description:** An allowlist of MCP servers to allow. - - **Default:** `undefined` - -- **`mcp.excluded`** (array of strings): - - **Description:** A denylist of MCP servers to exclude. - - **Default:** `undefined` - -#### `security` - -- **`security.folderTrust.enabled`** (boolean): - - **Description:** Setting to track whether Folder trust is enabled. - - **Default:** `false` - -- **`security.auth.selectedType`** (string): - - **Description:** The currently selected authentication type. - - **Default:** `undefined` - -- **`security.auth.enforcedType`** (string): - - **Description:** The required auth type (useful for enterprises). - - **Default:** `undefined` - -- **`security.auth.useExternal`** (boolean): - - **Description:** Whether to use an external authentication flow. - - **Default:** `undefined` - -#### `advanced` - -- **`advanced.autoConfigureMemory`** (boolean): - - **Description:** Automatically configure Node.js memory limits. - - **Default:** `false` - -- **`advanced.dnsResolutionOrder`** (string): - - **Description:** The DNS resolution order. - - **Default:** `undefined` - -- **`advanced.excludedEnvVars`** (array of strings): - - **Description:** Environment variables to exclude from project context. - - **Default:** `["DEBUG","DEBUG_MODE"]` - -- **`advanced.bugCommand`** (object): - - **Description:** Configuration for the bug report command. - - **Default:** `undefined` - -- **`advanced.tavilyApiKey`** (string): - - **Description:** API key for Tavily web search service. Used to enable the `web_search` tool functionality. - - **Note:** This is a legacy configuration format. For Qwen OAuth users, DashScope provider is automatically available without any configuration. For other authentication types, configure Tavily or Google providers using the new `webSearch` configuration format. - - **Default:** `undefined` - -#### `mcpServers` - -Configures connections to one or more Model-Context Protocol (MCP) servers for discovering and using custom tools. Qwen Code attempts to connect to each configured MCP server to discover available tools. If multiple MCP servers expose a tool with the same name, the tool names will be prefixed with the server alias you defined in the configuration (e.g., `serverAlias__actualToolName`) to avoid conflicts. Note that the system might strip certain schema properties from MCP tool definitions for compatibility. At least one of `command`, `url`, or `httpUrl` must be provided. If multiple are specified, the order of precedence is `httpUrl`, then `url`, then `command`. - -- **`mcpServers.`** (object): The server parameters for the named server. - - `command` (string, optional): The command to execute to start the MCP server via standard I/O. - - `args` (array of strings, optional): Arguments to pass to the command. - - `env` (object, optional): Environment variables to set for the server process. - - `cwd` (string, optional): The working directory in which to start the server. - - `url` (string, optional): The URL of an MCP server that uses Server-Sent Events (SSE) for communication. - - `httpUrl` (string, optional): The URL of an MCP server that uses streamable HTTP for communication. - - `headers` (object, optional): A map of HTTP headers to send with requests to `url` or `httpUrl`. - - `timeout` (number, optional): Timeout in milliseconds for requests to this MCP server. - - `trust` (boolean, optional): Trust this server and bypass all tool call confirmations. - - `description` (string, optional): A brief description of the server, which may be used for display purposes. - - `includeTools` (array of strings, optional): List of tool names to include from this MCP server. When specified, only the tools listed here will be available from this server (allowlist behavior). If not specified, all tools from the server are enabled by default. - - `excludeTools` (array of strings, optional): List of tool names to exclude from this MCP server. Tools listed here will not be available to the model, even if they are exposed by the server. **Note:** `excludeTools` takes precedence over `includeTools` - if a tool is in both lists, it will be excluded. - -#### `telemetry` - -Configures logging and metrics collection for Qwen Code. For more information, see [Telemetry](../telemetry.md). - -- **Properties:** - - **`enabled`** (boolean): Whether or not telemetry is enabled. - - **`target`** (string): The destination for collected telemetry. Supported values are `local` and `gcp`. - - **`otlpEndpoint`** (string): The endpoint for the OTLP Exporter. - - **`otlpProtocol`** (string): The protocol for the OTLP Exporter (`grpc` or `http`). - - **`logPrompts`** (boolean): Whether or not to include the content of user prompts in the logs. - - **`outfile`** (string): The file to write telemetry to when `target` is `local`. - - **`useCollector`** (boolean): Whether to use an external OTLP collector. - -### Example `settings.json` - -Here is an example of a `settings.json` file with the nested structure, new as of v0.3.0: - -```json -{ - "general": { - "vimMode": true, - "preferredEditor": "code" - }, - "ui": { - "theme": "GitHub", - "hideBanner": true, - "hideTips": false, - "customWittyPhrases": [ - "You forget a thousand things every day. Make sure this is one of ’em", - "Connecting to AGI" - ] - }, - "tools": { - "approvalMode": "yolo", - "sandbox": "docker", - "discoveryCommand": "bin/get_tools", - "callCommand": "bin/call_tool", - "exclude": ["write_file"] - }, - "mcpServers": { - "mainServer": { - "command": "bin/mcp_server.py" - }, - "anotherServer": { - "command": "node", - "args": ["mcp_server.js", "--verbose"] - } - }, - "telemetry": { - "enabled": true, - "target": "local", - "otlpEndpoint": "http://localhost:4317", - "logPrompts": true - }, - "privacy": { - "usageStatisticsEnabled": true - }, - "model": { - "name": "qwen3-coder-plus", - "maxSessionTurns": 10, - "enableOpenAILogging": false, - "openAILoggingDir": "~/qwen-logs", - "summarizeToolOutput": { - "run_shell_command": { - "tokenBudget": 100 - } - } - }, - "context": { - "fileName": ["CONTEXT.md", "QWEN.md"], - "includeDirectories": ["path/to/dir1", "~/path/to/dir2", "../path/to/dir3"], - "loadFromIncludeDirectories": true, - "fileFiltering": { - "respectGitIgnore": false - } - }, - "advanced": { - "excludedEnvVars": ["DEBUG", "DEBUG_MODE", "NODE_ENV"] - } -} -``` - -## Shell History - -The CLI keeps a history of shell commands you run. To avoid conflicts between different projects, this history is stored in a project-specific directory within your user's home folder. - -- **Location:** `~/.qwen/tmp//shell_history` - - `` is a unique identifier generated from your project's root path. - - The history is stored in a file named `shell_history`. - -## Environment Variables & `.env` Files - -Environment variables are a common way to configure applications, especially for sensitive information like API keys or for settings that might change between environments. For authentication setup, see the [Authentication documentation](./authentication.md) which covers all available authentication methods. - -The CLI automatically loads environment variables from an `.env` file. The loading order is: - -1. `.env` file in the current working directory. -2. If not found, it searches upwards in parent directories until it finds an `.env` file or reaches the project root (identified by a `.git` folder) or the home directory. -3. If still not found, it looks for `~/.env` (in the user's home directory). - -**Environment Variable Exclusion:** Some environment variables (like `DEBUG` and `DEBUG_MODE`) are automatically excluded from project `.env` files by default to prevent interference with the CLI behavior. Variables from `.qwen/.env` files are never excluded. You can customize this behavior using the `advanced.excludedEnvVars` setting in your `settings.json` file. - -- **`OPENAI_API_KEY`**: - - One of several available [authentication methods](./authentication.md). - - Set this in your shell profile (e.g., `~/.bashrc`, `~/.zshrc`) or an `.env` file. -- **`OPENAI_BASE_URL`**: - - One of several available [authentication methods](./authentication.md). - - Set this in your shell profile (e.g., `~/.bashrc`, `~/.zshrc`) or an `.env` file. -- **`OPENAI_MODEL`**: - - Specifies the default OPENAI model to use. - - Overrides the hardcoded default - - Example: `export OPENAI_MODEL="qwen3-coder-plus"` -- **`GEMINI_TELEMETRY_ENABLED`**: - - Set to `true` or `1` to enable telemetry. Any other value is treated as disabling it. - - Overrides the `telemetry.enabled` setting. -- **`GEMINI_TELEMETRY_TARGET`**: - - Sets the telemetry target (`local` or `gcp`). - - Overrides the `telemetry.target` setting. -- **`GEMINI_TELEMETRY_OTLP_ENDPOINT`**: - - Sets the OTLP endpoint for telemetry. - - Overrides the `telemetry.otlpEndpoint` setting. -- **`GEMINI_TELEMETRY_OTLP_PROTOCOL`**: - - Sets the OTLP protocol (`grpc` or `http`). - - Overrides the `telemetry.otlpProtocol` setting. -- **`GEMINI_TELEMETRY_LOG_PROMPTS`**: - - Set to `true` or `1` to enable or disable logging of user prompts. Any other value is treated as disabling it. - - Overrides the `telemetry.logPrompts` setting. -- **`GEMINI_TELEMETRY_OUTFILE`**: - - Sets the file path to write telemetry to when the target is `local`. - - Overrides the `telemetry.outfile` setting. -- **`GEMINI_TELEMETRY_USE_COLLECTOR`**: - - Set to `true` or `1` to enable or disable using an external OTLP collector. Any other value is treated as disabling it. - - Overrides the `telemetry.useCollector` setting. -- **`GEMINI_SANDBOX`**: - - Alternative to the `sandbox` setting in `settings.json`. - - Accepts `true`, `false`, `docker`, `podman`, or a custom command string. -- **`SEATBELT_PROFILE`** (macOS specific): - - Switches the Seatbelt (`sandbox-exec`) profile on macOS. - - `permissive-open`: (Default) Restricts writes to the project folder (and a few other folders, see `packages/cli/src/utils/sandbox-macos-permissive-open.sb`) but allows other operations. - - `strict`: Uses a strict profile that declines operations by default. - - ``: Uses a custom profile. To define a custom profile, create a file named `sandbox-macos-.sb` in your project's `.qwen/` directory (e.g., `my-project/.qwen/sandbox-macos-custom.sb`). -- **`DEBUG` or `DEBUG_MODE`** (often used by underlying libraries or the CLI itself): - - Set to `true` or `1` to enable verbose debug logging, which can be helpful for troubleshooting. - - **Note:** These variables are automatically excluded from project `.env` files by default to prevent interference with the CLI behavior. Use `.qwen/.env` files if you need to set these for Qwen Code specifically. -- **`NO_COLOR`**: - - Set to any value to disable all color output in the CLI. -- **`CLI_TITLE`**: - - Set to a string to customize the title of the CLI. -- **`TAVILY_API_KEY`**: - - Your API key for the Tavily web search service. - - Used to enable the `web_search` tool functionality. - - **Note:** For Qwen OAuth users, DashScope provider is automatically available without any configuration. For other authentication types, configure Tavily or Google providers to enable web search. - - Example: `export TAVILY_API_KEY="tvly-your-api-key-here"` - -## Command-Line Arguments - -Arguments passed directly when running the CLI can override other configurations for that specific session. - -- **`--model `** (**`-m `**): - - Specifies the Qwen model to use for this session. - - Example: `npm start -- --model qwen3-coder-plus` -- **`--prompt `** (**`-p `**): - - Used to pass a prompt directly to the command. This invokes Qwen Code in a non-interactive mode. - - For scripting examples, use the `--output-format json` flag to get structured output. -- **`--prompt-interactive `** (**`-i `**): - - Starts an interactive session with the provided prompt as the initial input. - - The prompt is processed within the interactive session, not before it. - - Cannot be used when piping input from stdin. - - Example: `qwen -i "explain this code"` -- **`--continue`**: - - Resume the most recent session for the current project (current working directory). - - Works in interactive and headless modes (e.g., `qwen --continue -p "Keep going"`). -- **`--resume [sessionId]`**: - - Resume a specific session for the current project. When called without an ID, an interactive picker lists only this project's sessions with prompt preview, timestamps, message count, and optional git branch. - - If an ID is provided and not found for this project, the CLI exits with an error. -- **`--output-format `** (**`-o `**): - - **Description:** Specifies the format of the CLI output for non-interactive mode. - - **Values:** - - `text`: (Default) The standard human-readable output. - - `json`: A machine-readable JSON output emitted at the end of execution. - - `stream-json`: Streaming JSON messages emitted as they occur during execution. - - **Note:** For structured output and scripting, use the `--output-format json` or `--output-format stream-json` flag. See [Headless Mode](../features/headless.md) for detailed information. -- **`--input-format `**: - - **Description:** Specifies the format consumed from standard input. - - **Values:** - - `text`: (Default) Standard text input from stdin or command-line arguments. - - `stream-json`: JSON message protocol via stdin for bidirectional communication. - - **Requirement:** `--input-format stream-json` requires `--output-format stream-json` to be set. - - **Note:** When using `stream-json`, stdin is reserved for protocol messages. See [Headless Mode](../features/headless.md) for detailed information. -- **`--include-partial-messages`**: - - **Description:** Include partial assistant messages when using `stream-json` output format. When enabled, emits stream events (message_start, content_block_delta, etc.) as they occur during streaming. - - **Default:** `false` - - **Requirement:** Requires `--output-format stream-json` to be set. - - **Note:** See [Headless Mode](../features/headless.md) for detailed information about stream events. -- **`--sandbox`** (**`-s`**): - - Enables sandbox mode for this session. -- **`--sandbox-image`**: - - Sets the sandbox image URI. -- **`--debug`** (**`-d`**): - - Enables debug mode for this session, providing more verbose output. -- **`--all-files`** (**`-a`**): - - If set, recursively includes all files within the current directory as context for the prompt. -- **`--help`** (or **`-h`**): - - Displays help information about command-line arguments. -- **`--show-memory-usage`**: - - Displays the current memory usage. -- **`--yolo`**: - - Enables YOLO mode, which automatically approves all tool calls. -- **`--approval-mode `**: - - Sets the approval mode for tool calls. Supported modes: - - `plan`: Analyze only—do not modify files or execute commands. - - `default`: Require approval for file edits or shell commands (default behavior). - - `auto-edit`: Automatically approve edit tools (edit, write_file) while prompting for others. - - `yolo`: Automatically approve all tool calls (equivalent to `--yolo`). - - Cannot be used together with `--yolo`. Use `--approval-mode=yolo` instead of `--yolo` for the new unified approach. - - Example: `qwen --approval-mode auto-edit` -- **`--allowed-tools `**: - - A comma-separated list of tool names that will bypass the confirmation dialog. - - Example: `qwen --allowed-tools "Shell(git status)"` -- **`--telemetry`**: - - Enables [telemetry](../telemetry.md). -- **`--telemetry-target`**: - - Sets the telemetry target. See [telemetry](../telemetry.md) for more information. -- **`--telemetry-otlp-endpoint`**: - - Sets the OTLP endpoint for telemetry. See [telemetry](../telemetry.md) for more information. -- **`--telemetry-otlp-protocol`**: - - Sets the OTLP protocol for telemetry (`grpc` or `http`). Defaults to `grpc`. See [telemetry](../telemetry.md) for more information. -- **`--telemetry-log-prompts`**: - - Enables logging of prompts for telemetry. See [telemetry](../telemetry.md) for more information. -- **`--checkpointing`**: - - Enables [checkpointing](../checkpointing.md). -- **`--extensions `** (**`-e `**): - - Specifies a list of extensions to use for the session. If not provided, all available extensions are used. - - Use the special term `qwen -e none` to disable all extensions. - - Example: `qwen -e my-extension -e my-other-extension` -- **`--list-extensions`** (**`-l`**): - - Lists all available extensions and exits. -- **`--proxy`**: - - Sets the proxy for the CLI. - - Example: `--proxy http://localhost:7890`. -- **`--include-directories `**: - - Includes additional directories in the workspace for multi-directory support. - - Can be specified multiple times or as comma-separated values. - - 5 directories can be added at maximum. - - Example: `--include-directories /path/to/project1,/path/to/project2` or `--include-directories /path/to/project1 --include-directories /path/to/project2` -- **`--screen-reader`**: - - Enables screen reader mode, which adjusts the TUI for better compatibility with screen readers. -- **`--version`**: - - Displays the version of the CLI. -- **`--openai-logging`**: - - Enables logging of OpenAI API calls for debugging and analysis. This flag overrides the `enableOpenAILogging` setting in `settings.json`. -- **`--openai-logging-dir `**: - - Sets a custom directory path for OpenAI API logs. This flag overrides the `openAILoggingDir` setting in `settings.json`. Supports absolute paths, relative paths, and `~` expansion. - - **Example:** `qwen --openai-logging-dir "~/qwen-logs" --openai-logging` -- **`--tavily-api-key `**: - - Sets the Tavily API key for web search functionality for this session. - - Example: `qwen --tavily-api-key tvly-your-api-key-here` - -## Context Files (Hierarchical Instructional Context) - -While not strictly configuration for the CLI's _behavior_, context files (defaulting to `QWEN.md` but configurable via the `context.fileName` setting) are crucial for configuring the _instructional context_ (also referred to as "memory"). This powerful feature allows you to give project-specific instructions, coding style guides, or any relevant background information to the AI, making its responses more tailored and accurate to your needs. The CLI includes UI elements, such as an indicator in the footer showing the number of loaded context files, to keep you informed about the active context. - -- **Purpose:** These Markdown files contain instructions, guidelines, or context that you want the Qwen model to be aware of during your interactions. The system is designed to manage this instructional context hierarchically. - -### Example Context File Content (e.g., `QWEN.md`) - -Here's a conceptual example of what a context file at the root of a TypeScript project might contain: - -```markdown -# Project: My Awesome TypeScript Library - -## General Instructions: - -- When generating new TypeScript code, please follow the existing coding style. -- Ensure all new functions and classes have JSDoc comments. -- Prefer functional programming paradigms where appropriate. -- All code should be compatible with TypeScript 5.0 and Node.js 20+. - -## Coding Style: - -- Use 2 spaces for indentation. -- Interface names should be prefixed with `I` (e.g., `IUserService`). -- Private class members should be prefixed with an underscore (`_`). -- Always use strict equality (`===` and `!==`). - -## Specific Component: `src/api/client.ts` - -- This file handles all outbound API requests. -- When adding new API call functions, ensure they include robust error handling and logging. -- Use the existing `fetchWithRetry` utility for all GET requests. - -## Regarding Dependencies: - -- Avoid introducing new external dependencies unless absolutely necessary. -- If a new dependency is required, please state the reason. -``` - -This example demonstrates how you can provide general project context, specific coding conventions, and even notes about particular files or components. The more relevant and precise your context files are, the better the AI can assist you. Project-specific context files are highly encouraged to establish conventions and context. - -- **Hierarchical Loading and Precedence:** The CLI implements a sophisticated hierarchical memory system by loading context files (e.g., `QWEN.md`) from several locations. Content from files lower in this list (more specific) typically overrides or supplements content from files higher up (more general). The exact concatenation order and final context can be inspected using the `/memory show` command. The typical loading order is: - 1. **Global Context File:** - - Location: `~/.qwen/` (e.g., `~/.qwen/QWEN.md` in your user home directory). - - Scope: Provides default instructions for all your projects. - 2. **Project Root & Ancestors Context Files:** - - Location: The CLI searches for the configured context file in the current working directory and then in each parent directory up to either the project root (identified by a `.git` folder) or your home directory. - - Scope: Provides context relevant to the entire project or a significant portion of it. - 3. **Sub-directory Context Files (Contextual/Local):** - - Location: The CLI also scans for the configured context file in subdirectories _below_ the current working directory (respecting common ignore patterns like `node_modules`, `.git`, etc.). The breadth of this search is limited to 200 directories by default, but can be configured with the `context.discoveryMaxDirs` setting in your `settings.json` file. - - Scope: Allows for highly specific instructions relevant to a particular component, module, or subsection of your project. -- **Concatenation & UI Indication:** The contents of all found context files are concatenated (with separators indicating their origin and path) and provided as part of the system prompt. The CLI footer displays the count of loaded context files, giving you a quick visual cue about the active instructional context. -- **Importing Content:** You can modularize your context files by importing other Markdown files using the `@path/to/file.md` syntax. For more details, see the [Memory Import Processor documentation](../core/memport.md). -- **Commands for Memory Management:** - - Use `/memory refresh` to force a re-scan and reload of all context files from all configured locations. This updates the AI's instructional context. - - Use `/memory show` to display the combined instructional context currently loaded, allowing you to verify the hierarchy and content being used by the AI. - - See the [Commands documentation](./commands.md#memory) for full details on the `/memory` command and its sub-commands (`show` and `refresh`). - -By understanding and utilizing these configuration layers and the hierarchical nature of context files, you can effectively manage the AI's memory and tailor Qwen Code's responses to your specific needs and projects. - -## Sandboxing - -Qwen Code can execute potentially unsafe operations (like shell commands and file modifications) within a sandboxed environment to protect your system. - -Sandboxing is disabled by default, but you can enable it in a few ways: - -- Using `--sandbox` or `-s` flag. -- Setting `GEMINI_SANDBOX` environment variable. -- Sandbox is enabled when using `--yolo` or `--approval-mode=yolo` by default. - -By default, it uses a pre-built `qwen-code-sandbox` Docker image. - -For project-specific sandboxing needs, you can create a custom Dockerfile at `.qwen/sandbox.Dockerfile` in your project's root directory. This Dockerfile can be based on the base sandbox image: - -```dockerfile -FROM qwen-code-sandbox - -# Add your custom dependencies or configurations here -# For example: -# RUN apt-get update && apt-get install -y some-package -# COPY ./my-config /app/my-config -``` - -When `.qwen/sandbox.Dockerfile` exists, you can use `BUILD_SANDBOX` environment variable when running Qwen Code to automatically build the custom sandbox image: - -```bash -BUILD_SANDBOX=1 qwen -s -``` - -## Usage Statistics - -To help us improve Qwen Code, we collect anonymized usage statistics. This data helps us understand how the CLI is used, identify common issues, and prioritize new features. - -**What we collect:** - -- **Tool Calls:** We log the names of the tools that are called, whether they succeed or fail, and how long they take to execute. We do not collect the arguments passed to the tools or any data returned by them. -- **API Requests:** We log the model used for each request, the duration of the request, and whether it was successful. We do not collect the content of the prompts or responses. -- **Session Information:** We collect information about the configuration of the CLI, such as the enabled tools and the approval mode. - -**What we DON'T collect:** - -- **Personally Identifiable Information (PII):** We do not collect any personal information, such as your name, email address, or API keys. -- **Prompt and Response Content:** We do not log the content of your prompts or the responses from the model. -- **File Content:** We do not log the content of any files that are read or written by the CLI. - -**How to opt out:** - -You can opt out of usage statistics collection at any time by setting the `usageStatisticsEnabled` property to `false` under the `privacy` category in your `settings.json` file: - -```json -{ - "privacy": { - "usageStatisticsEnabled": false - } -} -``` - -Note: When usage statistics are enabled, events are sent to an Alibaba Cloud RUM collection endpoint. diff --git a/docs/cli/index.md b/docs/cli/index.md deleted file mode 100644 index e5d3ddc6b..000000000 --- a/docs/cli/index.md +++ /dev/null @@ -1,29 +0,0 @@ -# Qwen Code CLI - -Within Qwen Code, `packages/cli` is the frontend for users to send and receive prompts with Qwen and other AI models and their associated tools. For a general overview of Qwen Code - -## Navigating this section - -- **[Authentication](./authentication.md):** A guide to setting up authentication with Qwen OAuth and OpenAI-compatible providers. -- **[Commands](./commands.md):** A reference for Qwen Code CLI commands (e.g., `/help`, `/tools`, `/theme`). -- **[Configuration](./configuration.md):** A guide to tailoring Qwen Code CLI behavior using configuration files. -- **[Themes](./themes.md)**: A guide to customizing the CLI's appearance with different themes. -- **[Tutorials](tutorials.md)**: A tutorial showing how to use Qwen Code to automate a development task. - -## Non-interactive mode - -Qwen Code can be run in a non-interactive mode, which is useful for scripting and automation. In this mode, you pipe input to the CLI, it executes the command, and then it exits. - -The following example pipes a command to Qwen Code from your terminal: - -```bash -echo "What is fine tuning?" | qwen -``` - -You can also use the `--prompt` or `-p` flag: - -```bash -qwen -p "What is fine tuning?" -``` - -For comprehensive documentation on headless usage, scripting, automation, and advanced examples, see the **[Headless Mode](../headless.md)** guide. diff --git a/docs/cli/language.md b/docs/cli/language.md deleted file mode 100644 index 7fb1e7f05..000000000 --- a/docs/cli/language.md +++ /dev/null @@ -1,71 +0,0 @@ -# Language Command - -The `/language` command allows you to customize the language settings for both the Qwen Code user interface (UI) and the language model's output. This command supports two distinct functionalities: - -1. Setting the UI language for the Qwen Code interface -2. Setting the output language for the language model (LLM) - -## UI Language Settings - -To change the UI language of Qwen Code, use the `ui` subcommand: - -``` -/language ui [zh-CN|en-US] -``` - -### Available UI Languages - -- **zh-CN**: Simplified Chinese (简体中文) -- **en-US**: English - -### Examples - -``` -/language ui zh-CN # Set UI language to Simplified Chinese -/language ui en-US # Set UI language to English -``` - -### UI Language Subcommands - -You can also use direct subcommands for convenience: - -- `/language ui zh-CN` or `/language ui zh` or `/language ui 中文` -- `/language ui en-US` or `/language ui en` or `/language ui english` - -## LLM Output Language Settings - -To set the language for the language model's responses, use the `output` subcommand: - -``` -/language output -``` - -This command generates a language rule file that instructs the LLM to respond in the specified language. The rule file is saved to `~/.qwen/output-language.md`. - -### Examples - -``` -/language output 中文 # Set LLM output language to Chinese -/language output English # Set LLM output language to English -/language output 日本語 # Set LLM output language to Japanese -``` - -## Viewing Current Settings - -When used without arguments, the `/language` command displays the current language settings: - -``` -/language -``` - -This will show: - -- Current UI language -- Current LLM output language (if set) -- Available subcommands - -## Notes - -- UI language changes take effect immediately and reload all command descriptions -- LLM output language settings are persisted in a rule file that is automatically included in the model's context -- To request additional UI language packs, please open an issue on GitHub diff --git a/docs/cli/openai-auth.md b/docs/cli/openai-auth.md deleted file mode 100644 index 9dd8c0caa..000000000 --- a/docs/cli/openai-auth.md +++ /dev/null @@ -1,76 +0,0 @@ -# OpenAI Authentication - -Qwen Code CLI supports OpenAI authentication for users who want to use OpenAI models instead of Google's Gemini models. - -## Authentication Methods - -### 1. Interactive Authentication (Recommended) - -When you first run the CLI and select OpenAI as your authentication method, you'll be prompted to enter: - -- **API Key**: Your OpenAI API key from [https://platform.openai.com/api-keys](https://platform.openai.com/api-keys) -- **Base URL**: The base URL for OpenAI API (defaults to `https://api.openai.com/v1`) -- **Model**: The OpenAI model to use (defaults to `gpt-4o`) - -The CLI will guide you through each field: - -1. Enter your API key and press Enter -2. Review/modify the base URL and press Enter -3. Review/modify the model name and press Enter - -**Note**: You can paste your API key directly - the CLI supports paste functionality and will display the full key for verification. - -### 2. Command Line Arguments - -You can also provide the OpenAI credentials via command line arguments: - -```bash -# Basic usage with API key -qwen-code --openai-api-key "your-api-key-here" - -# With custom base URL -qwen-code --openai-api-key "your-api-key-here" --openai-base-url "https://your-custom-endpoint.com/v1" - -# With custom model -qwen-code --openai-api-key "your-api-key-here" --model "gpt-4-turbo" -``` - -### 3. Environment Variables - -Set the following environment variables in your shell or `.env` file: - -```bash -export OPENAI_API_KEY="your-api-key-here" -export OPENAI_BASE_URL="https://api.openai.com/v1" # Optional, defaults to this value -export OPENAI_MODEL="gpt-4o" # Optional, defaults to gpt-4o -``` - -## Supported Models - -The CLI supports all OpenAI models that are available through the OpenAI API, including: - -- `gpt-4o` (default) -- `gpt-4o-mini` -- `gpt-4-turbo` -- `gpt-4` -- `gpt-3.5-turbo` -- And other available models - -## Custom Endpoints - -You can use custom endpoints by setting the `OPENAI_BASE_URL` environment variable or using the `--openai-base-url` command line argument. This is useful for: - -- Using Azure OpenAI -- Using other OpenAI-compatible APIs -- Using local OpenAI-compatible servers - -## Switching Authentication Methods - -To switch between authentication methods, use the `/auth` command in the CLI interface. - -## Security Notes - -- API keys are stored in memory during the session -- For persistent storage, use environment variables or `.env` files -- Never commit API keys to version control -- The CLI displays API keys in plain text for verification - ensure your terminal is secure diff --git a/docs/cli/tutorials.md b/docs/cli/tutorials.md deleted file mode 100644 index bc43d6d6c..000000000 --- a/docs/cli/tutorials.md +++ /dev/null @@ -1,69 +0,0 @@ -# Tutorials - -This page contains tutorials for interacting with Qwen Code. - -## Setting up a Model Context Protocol (MCP) server - -> [!CAUTION] -> Before using a third-party MCP server, ensure you trust its source and understand the tools it provides. Your use of third-party servers is at your own risk. - -This tutorial demonstrates how to set up a MCP server, using the [GitHub MCP server](https://github.com/github/github-mcp-server) as an example. The GitHub MCP server provides tools for interacting with GitHub repositories, such as creating issues and commenting on pull requests. - -### Prerequisites - -Before you begin, ensure you have the following installed and configured: - -- **Docker:** Install and run [Docker]. -- **GitHub Personal Access Token (PAT):** Create a new [classic] or [fine-grained] PAT with the necessary scopes. - -[Docker]: https://www.docker.com/ -[classic]: https://github.com/settings/tokens/new -[fine-grained]: https://github.com/settings/personal-access-tokens/new - -### Guide - -#### Configure the MCP server in `settings.json` - -In your project's root directory, create or open the [`.qwen/settings.json` file](./configuration.md). Within the file, add the `mcpServers` configuration block, which provides instructions for how to launch the GitHub MCP server. - -```json -{ - "mcpServers": { - "github": { - "command": "docker", - "args": [ - "run", - "-i", - "--rm", - "-e", - "GITHUB_PERSONAL_ACCESS_TOKEN", - "ghcr.io/github/github-mcp-server" - ], - "env": { - "GITHUB_PERSONAL_ACCESS_TOKEN": "${GITHUB_PERSONAL_ACCESS_TOKEN}" - } - } - } -} -``` - -#### Set your GitHub token - -> [!CAUTION] -> Using a broadly scoped personal access token that has access to personal and private repositories can lead to information from the private repository being leaked into the public repository. We recommend using a fine-grained access token that doesn't share access to both public and private repositories. - -Use an environment variable to store your GitHub PAT: - -```bash -GITHUB_PERSONAL_ACCESS_TOKEN="pat_YourActualGitHubTokenHere" -``` - -Qwen Code uses this value in the `mcpServers` configuration that you defined in the `settings.json` file. - -#### Launch Qwen Code and verify the connection - -When you launch Qwen Code, it automatically reads your configuration and launches the GitHub MCP server in the background. You can then use natural language prompts to ask Qwen Code to perform GitHub actions. For example: - -```bash -"get all open issues assigned to me in the 'foo/bar' repo and prioritize them" -``` diff --git a/docs/core/index.md b/docs/core/index.md deleted file mode 100644 index 8ac4d4d1c..000000000 --- a/docs/core/index.md +++ /dev/null @@ -1,55 +0,0 @@ -# Qwen Code Core - -Qwen Code's core package (`packages/core`) is the backend portion of Qwen Code, handling communication with model APIs, managing tools, and processing requests sent from `packages/cli`. For a general overview of Qwen Code, see the [main documentation page](../index.md). - -## Navigating this section - -- **[Core tools API](./tools-api.md):** Information on how tools are defined, registered, and used by the core. -- **[Memory Import Processor](./memport.md):** Documentation for the modular QWEN.md import feature using @file.md syntax. - -## Role of the core - -While the `packages/cli` portion of Qwen Code provides the user interface, `packages/core` is responsible for: - -- **Model API interaction:** Securely communicating with the configured model provider, sending user prompts, and receiving model responses. -- **Prompt engineering:** Constructing effective prompts for the model, potentially incorporating conversation history, tool definitions, and instructional context from context files (e.g., `QWEN.md`). -- **Tool management & orchestration:** - - Registering available tools (e.g., file system tools, shell command execution). - - Interpreting tool use requests from the model. - - Executing the requested tools with the provided arguments. - - Returning tool execution results to the model for further processing. -- **Session and state management:** Keeping track of the conversation state, including history and any relevant context required for coherent interactions. -- **Configuration:** Managing core-specific configurations, such as API key access, model selection, and tool settings. - -## Security considerations - -The core plays a vital role in security: - -- **API key management:** It handles provider credentials and ensures they're used securely when communicating with APIs. -- **Tool execution:** When tools interact with the local system (e.g., `run_shell_command`), the core (and its underlying tool implementations) must do so with appropriate caution, often involving sandboxing mechanisms to prevent unintended modifications. - -## Chat history compression - -To ensure that long conversations don't exceed the token limits of the selected model, the core includes a chat history compression feature. - -When a conversation approaches the token limit for the configured model, the core automatically compresses the conversation history before sending it to the model. This compression is designed to be lossless in terms of the information conveyed, but it reduces the overall number of tokens used. - -You can find token limits for each provider's models in their documentation. - -## Model fallback - -Qwen Code includes a model fallback mechanism to ensure that you can continue to use the CLI even if the default model is rate-limited. - -If you are using the default "pro" model and the CLI detects that you are being rate-limited, it automatically switches to the "flash" model for the current session. This allows you to continue working without interruption. - -## File discovery service - -The file discovery service is responsible for finding files in the project that are relevant to the current context. It is used by the `@` command and other tools that need to access files. - -## Memory discovery service - -The memory discovery service is responsible for finding and loading the context files (default: `QWEN.md`) that provide context to the model. It searches for these files in a hierarchical manner, starting from the current working directory and moving up to the project root and the user's home directory. It also searches in subdirectories. - -This allows you to have global, project-level, and component-level context files, which are all combined to provide the model with the most relevant information. - -You can use the [`/memory` command](../cli/commands.md) to `show`, `add`, and `refresh` the content of loaded context files. diff --git a/docs/core/memport.md b/docs/core/memport.md deleted file mode 100644 index 3431653aa..000000000 --- a/docs/core/memport.md +++ /dev/null @@ -1,215 +0,0 @@ -# Memory Import Processor - -The Memory Import Processor is a feature that allows you to modularize your context files (e.g., `QWEN.md`) by importing content from other files using the `@file.md` syntax. - -## Overview - -This feature enables you to break down large context files (e.g., `QWEN.md`) into smaller, more manageable components that can be reused across different contexts. The import processor supports both relative and absolute paths, with built-in safety features to prevent circular imports and ensure file access security. - -## Syntax - -Use the `@` symbol followed by the path to the file you want to import: - -```markdown -# Main QWEN.md file - -This is the main content. - -@./components/instructions.md - -More content here. - -@./shared/configuration.md -``` - -## Supported Path Formats - -### Relative Paths - -- `@./file.md` - Import from the same directory -- `@../file.md` - Import from parent directory -- `@./components/file.md` - Import from subdirectory - -### Absolute Paths - -- `@/absolute/path/to/file.md` - Import using absolute path - -## Examples - -### Basic Import - -```markdown -# My QWEN.md - -Welcome to my project! - -@./getting-started.md - -## Features - -@./features/overview.md -``` - -### Nested Imports - -The imported files can themselves contain imports, creating a nested structure: - -```markdown -# main.md - -@./header.md -@./content.md -@./footer.md -``` - -```markdown -# header.md - -# Project Header - -@./shared/title.md -``` - -## Safety Features - -### Circular Import Detection - -The processor automatically detects and prevents circular imports: - -```markdown -# file-a.md - -@./file-b.md - -# file-b.md - -@./file-a.md -``` - -### File Access Security - -The `validateImportPath` function ensures that imports are only allowed from specified directories, preventing access to sensitive files outside the allowed scope. - -### Maximum Import Depth - -To prevent infinite recursion, there's a configurable maximum import depth (default: 5 levels). - -## Error Handling - -### Missing Files - -If a referenced file doesn't exist, the import will fail gracefully with an error comment in the output. - -### File Access Errors - -Permission issues or other file system errors are handled gracefully with appropriate error messages. - -## Code Region Detection - -The import processor uses the `marked` library to detect code blocks and inline code spans, ensuring that `@` imports inside these regions are properly ignored. This provides robust handling of nested code blocks and complex Markdown structures. - -## Import Tree Structure - -The processor returns an import tree that shows the hierarchy of imported files. This helps users debug problems with their context files by showing which files were read and their import relationships. - -Example tree structure: - -``` - Memory Files - L project: QWEN.md - L a.md - L b.md - L c.md - L d.md - L e.md - L f.md - L included.md -``` - -The tree preserves the order that files were imported and shows the complete import chain for debugging purposes. - -## Comparison to Claude Code's `/memory` (`claude.md`) Approach - -Claude Code's `/memory` feature (as seen in `claude.md`) produces a flat, linear document by concatenating all included files, always marking file boundaries with clear comments and path names. It does not explicitly present the import hierarchy, but the LLM receives all file contents and paths, which is sufficient for reconstructing the hierarchy if needed. - -Note: The import tree is mainly for clarity during development and has limited relevance to LLM consumption. - -## API Reference - -### `processImports(content, basePath, debugMode?, importState?)` - -Processes import statements in context file content. - -**Parameters:** - -- `content` (string): The content to process for imports -- `basePath` (string): The directory path where the current file is located -- `debugMode` (boolean, optional): Whether to enable debug logging (default: false) -- `importState` (ImportState, optional): State tracking for circular import prevention - -**Returns:** Promise<ProcessImportsResult> - Object containing processed content and import tree - -### `ProcessImportsResult` - -```typescript -interface ProcessImportsResult { - content: string; // The processed content with imports resolved - importTree: MemoryFile; // Tree structure showing the import hierarchy -} -``` - -### `MemoryFile` - -```typescript -interface MemoryFile { - path: string; // The file path - imports?: MemoryFile[]; // Direct imports, in the order they were imported -} -``` - -### `validateImportPath(importPath, basePath, allowedDirectories)` - -Validates import paths to ensure they are safe and within allowed directories. - -**Parameters:** - -- `importPath` (string): The import path to validate -- `basePath` (string): The base directory for resolving relative paths -- `allowedDirectories` (string[]): Array of allowed directory paths - -**Returns:** boolean - Whether the import path is valid - -### `findProjectRoot(startDir)` - -Finds the project root by searching for a `.git` directory upwards from the given start directory. Implemented as an **async** function using non-blocking file system APIs to avoid blocking the Node.js event loop. - -**Parameters:** - -- `startDir` (string): The directory to start searching from - -**Returns:** Promise<string> - The project root directory (or the start directory if no `.git` is found) - -## Best Practices - -1. **Use descriptive file names** for imported components -2. **Keep imports shallow** - avoid deeply nested import chains -3. **Document your structure** - maintain a clear hierarchy of imported files -4. **Test your imports** - ensure all referenced files exist and are accessible -5. **Use relative paths** when possible for better portability - -## Troubleshooting - -### Common Issues - -1. **Import not working**: Check that the file exists and the path is correct -2. **Circular import warnings**: Review your import structure for circular references -3. **Permission errors**: Ensure the files are readable and within allowed directories -4. **Path resolution issues**: Use absolute paths if relative paths aren't resolving correctly - -### Debug Mode - -Enable debug mode to see detailed logging of the import process: - -```typescript -const result = await processImports(content, basePath, true); -``` diff --git a/docs/core/tools-api.md b/docs/core/tools-api.md deleted file mode 100644 index 70266b885..000000000 --- a/docs/core/tools-api.md +++ /dev/null @@ -1,79 +0,0 @@ -# Qwen Code Core: Tools API - -The Qwen Code core (`packages/core`) features a robust system for defining, registering, and executing tools. These tools extend the capabilities of the model, allowing it to interact with the local environment, fetch web content, and perform various actions beyond simple text generation. - -## Core Concepts - -- **Tool (`tools.ts`):** An interface and base class (`BaseTool`) that defines the contract for all tools. Each tool must have: - - `name`: A unique internal name (used in API calls to the model). - - `displayName`: A user-friendly name. - - `description`: A clear explanation of what the tool does, which is provided to the model. - - `parameterSchema`: A JSON schema defining the parameters that the tool accepts. This is crucial for the model to understand how to call the tool correctly. - - `validateToolParams()`: A method to validate incoming parameters. - - `getDescription()`: A method to provide a human-readable description of what the tool will do with specific parameters before execution. - - `shouldConfirmExecute()`: A method to determine if user confirmation is required before execution (e.g., for potentially destructive operations). - - `execute()`: The core method that performs the tool's action and returns a `ToolResult`. - -- **`ToolResult` (`tools.ts`):** An interface defining the structure of a tool's execution outcome: - - `llmContent`: The factual content to be included in the history sent back to the LLM for context. This can be a simple string or a `PartListUnion` (an array of `Part` objects and strings) for rich content. - - `returnDisplay`: A user-friendly string (often Markdown) or a special object (like `FileDiff`) for display in the CLI. - -- **Returning Rich Content:** Tools are not limited to returning simple text. The `llmContent` can be a `PartListUnion`, which is an array that can contain a mix of `Part` objects (for images, audio, etc.) and `string`s. This allows a single tool execution to return multiple pieces of rich content. - -- **Tool Registry (`tool-registry.ts`):** A class (`ToolRegistry`) responsible for: - - **Registering Tools:** Holding a collection of all available built-in tools (e.g., `ListFiles`, `ReadFile`). - - **Discovering Tools:** It can also discover tools dynamically: - - **Command-based Discovery:** If `tools.toolDiscoveryCommand` is configured in settings, this command is executed. It's expected to output JSON describing custom tools, which are then registered as `DiscoveredTool` instances. - - **MCP-based Discovery:** If `mcp.mcpServerCommand` is configured, the registry can connect to a Model Context Protocol (MCP) server to list and register tools (`DiscoveredMCPTool`). - - **Providing Schemas:** Exposing the `FunctionDeclaration` schemas of all registered tools to the model, so it knows what tools are available and how to use them. - - **Retrieving Tools:** Allowing the core to get a specific tool by name for execution. - -## Built-in Tools - -The core comes with a suite of pre-defined tools, typically found in `packages/core/src/tools/`. These include: - -- **File System Tools:** - - `ListFiles` (`ls.ts`): Lists directory contents. - - `ReadFile` (`read-file.ts`): Reads the content of a single file. It takes an `absolute_path` parameter, which must be an absolute path. - - `WriteFile` (`write-file.ts`): Writes content to a file. - - `ReadManyFiles` (`read-many-files.ts`): Reads and concatenates content from multiple files or glob patterns (used by the `@` command in CLI). - - `Grep` (`grep.ts`): Searches for patterns in files. - - `Glob` (`glob.ts`): Finds files matching glob patterns. - - `Edit` (`edit.ts`): Performs in-place modifications to files (often requiring confirmation). -- **Execution Tools:** - - `Shell` (`shell.ts`): Executes arbitrary shell commands (requires careful sandboxing and user confirmation). -- **Web Tools:** - - `WebFetch` (`web-fetch.ts`): Fetches content from a URL. - - `WebSearch` (`web-search.ts`): Performs a web search. -- **Memory Tools:** - - `SaveMemory` (`memoryTool.ts`): Interacts with the AI's memory. -- **Planning Tools:** - - `Task` (`task.ts`): Delegates tasks to specialized subagents. - - `TodoWrite` (`todoWrite.ts`): Creates and manages a structured task list. - - `ExitPlanMode` (`exitPlanMode.ts`): Exits plan mode and returns to normal operation. - -Each of these tools extends `BaseTool` and implements the required methods for its specific functionality. - -## Tool Execution Flow - -1. **Model Request:** The model, based on the user's prompt and the provided tool schemas, decides to use a tool and returns a `FunctionCall` part in its response, specifying the tool name and arguments. -2. **Core Receives Request:** The core parses this `FunctionCall`. -3. **Tool Retrieval:** It looks up the requested tool in the `ToolRegistry`. -4. **Parameter Validation:** The tool's `validateToolParams()` method is called. -5. **Confirmation (if needed):** - - The tool's `shouldConfirmExecute()` method is called. - - If it returns details for confirmation, the core communicates this back to the CLI, which prompts the user. - - The user's decision (e.g., proceed, cancel) is sent back to the core. -6. **Execution:** If validated and confirmed (or if no confirmation is needed), the core calls the tool's `execute()` method with the provided arguments and an `AbortSignal` (for potential cancellation). -7. **Result Processing:** The `ToolResult` from `execute()` is received by the core. -8. **Response to Model:** The `llmContent` from the `ToolResult` is packaged as a `FunctionResponse` and sent back to the model so it can continue generating a user-facing response. -9. **Display to User:** The `returnDisplay` from the `ToolResult` is sent to the CLI to show the user what the tool did. - -## Extending with Custom Tools - -While direct programmatic registration of new tools by users isn't explicitly detailed as a primary workflow in the provided files for typical end-users, the architecture supports extension through: - -- **Command-based Discovery:** Advanced users or project administrators can define a `tools.toolDiscoveryCommand` in `settings.json`. This command, when run by the core, should output a JSON array of `FunctionDeclaration` objects. The core will then make these available as `DiscoveredTool` instances. The corresponding `tools.toolCallCommand` would then be responsible for actually executing these custom tools. -- **MCP Server(s):** For more complex scenarios, one or more MCP servers can be set up and configured via the `mcpServers` setting in `settings.json`. The core can then discover and use tools exposed by these servers. As mentioned, if you have multiple MCP servers, the tool names will be prefixed with the server name from your configuration (e.g., `serverAlias__actualToolName`). - -This tool system provides a flexible and powerful way to augment the model's capabilities, making Qwen Code a versatile assistant for a wide range of tasks. diff --git a/docs/developers/_meta.ts b/docs/developers/_meta.ts new file mode 100644 index 000000000..956e1ad98 --- /dev/null +++ b/docs/developers/_meta.ts @@ -0,0 +1,27 @@ +export default { + 'Contribute to Qwen Code': { + title: 'Contribute to Qwen Code', + type: 'separator', + }, + architecture: 'Architecture', + roadmap: 'Roadmap', + contributing: 'Contributing Guide', + 'Qwen Code SDK': { + title: 'Agent SDK', + type: 'separator', + }, + 'sdk-typescript': 'Typescript SDK', + 'Dive Into Qwen Code': { + title: 'Dive Into Qwen Code', + type: 'separator', + }, + + tools: 'Tools', + + extensions: { + display: 'hidden', + }, + examples: { + display: 'hidden', + }, +}; diff --git a/docs/developers/architecture.md b/docs/developers/architecture.md new file mode 100644 index 000000000..8d9540c09 --- /dev/null +++ b/docs/developers/architecture.md @@ -0,0 +1,95 @@ +# Qwen Code Architecture Overview + +This document provides a high-level overview of Qwen Code's architecture. + +## Core Components + +Qwen Code is primarily composed of two main packages, along with a suite of tools that can be used by the system in the course of handling command-line input: + +### 1. CLI Package (`packages/cli`) + +**Purpose:** This contains the user-facing portion of Qwen Code, such as handling the initial user input, presenting the final output, and managing the overall user experience. + +**Key Functions:** + +- **Input Processing:** Handles user input through various methods including direct text entry, slash commands (e.g., `/help`, `/clear`, `/model`), at commands (`@file` for including file content), and exclamation mark commands (`!command` for shell execution). +- **History Management:** Maintains conversation history and enables features like session resumption. +- **Display Rendering:** Formats and presents responses to the user in the terminal with syntax highlighting and proper formatting. +- **Theme and UI Customization:** Supports customizable themes and UI elements for a personalized experience. +- **Configuration Settings:** Manages various configuration options through JSON settings files, environment variables, and command-line arguments. + +### 2. Core Package (`packages/core`) + +**Purpose:** This acts as the backend for Qwen Code. It receives requests sent from `packages/cli`, orchestrates interactions with the configured model API, and manages the execution of available tools. + +**Key Functions:** + +- **API Client:** Communicates with the Qwen model API to send prompts and receive responses. +- **Prompt Construction:** Builds appropriate prompts for the model, incorporating conversation history and available tool definitions. +- **Tool Registration and Execution:** Manages the registration of available tools and executes them based on model requests. +- **State Management:** Maintains conversation and session state information. +- **Server-side Configuration:** Handles server-side configuration and settings. + +### 3. Tools (`packages/core/src/tools/`) + +**Purpose:** These are individual modules that extend the capabilities of the Qwen model, allowing it to interact with the local environment (e.g., file system, shell commands, web fetching). + +**Interaction:** `packages/core` invokes these tools based on requests from the Qwen model. + +**Common Tools Include:** + +- **File Operations:** Reading, writing, and editing files +- **Shell Commands:** Executing system commands with user approval for potentially dangerous operations +- **Search Tools:** Finding files and searching content within the project +- **Web Tools:** Fetching content from the web +- **MCP Integration:** Connecting to Model Context Protocol servers for extended capabilities + +## Interaction Flow + +A typical interaction with Qwen Code follows this flow: + +1. **User Input:** The user types a prompt or command into the terminal, which is managed by `packages/cli`. +2. **Request to Core:** `packages/cli` sends the user's input to `packages/core`. +3. **Request Processing:** The core package: + - Constructs an appropriate prompt for the configured model API, possibly including conversation history and available tool definitions. + - Sends the prompt to the model API. +4. **Model API Response:** The model API processes the prompt and returns a response. This response might be a direct answer or a request to use one of the available tools. +5. **Tool Execution (if applicable):** + - When the model API requests a tool, the core package prepares to execute it. + - If the requested tool can modify the file system or execute shell commands, the user is first given details of the tool and its arguments, and the user must approve the execution. + - Read-only operations, such as reading files, might not require explicit user confirmation to proceed. + - Once confirmed, or if confirmation is not required, the core package executes the relevant action within the relevant tool, and the result is sent back to the model API by the core package. + - The model API processes the tool result and generates a final response. +6. **Response to CLI:** The core package sends the final response back to the CLI package. +7. **Display to User:** The CLI package formats and displays the response to the user in the terminal. + +## Configuration Options + +Qwen Code offers multiple ways to configure its behavior: + +### Configuration Layers (in order of precedence) + +1. Command-line arguments +2. Environment variables +3. Project settings file (`.qwen/settings.json`) +4. User settings file (`~/.qwen/settings.json`) +5. System settings files +6. Default values + +### Key Configuration Categories + +- **General Settings:** vim mode, preferred editor, auto-update preferences +- **UI Settings:** Theme customization, banner visibility, footer display +- **Model Settings:** Model selection, session turn limits, compression settings +- **Context Settings:** Context file names, directory inclusion, file filtering +- **Tool Settings:** Approval modes, sandboxing, tool restrictions +- **Privacy Settings:** Usage statistics collection +- **Advanced Settings:** Debug options, custom bug reporting commands + +## Key Design Principles + +- **Modularity:** Separating the CLI (frontend) from the Core (backend) allows for independent development and potential future extensions (e.g., different frontends for the same backend). +- **Extensibility:** The tool system is designed to be extensible, allowing new capabilities to be added through custom tools or MCP server integration. +- **User Experience:** The CLI focuses on providing a rich and interactive terminal experience with features like syntax highlighting, customizable themes, and intuitive command structures. +- **Security:** Implements approval mechanisms for potentially dangerous operations and sandboxing options to protect the user's system. +- **Flexibility:** Supports multiple configuration methods and can adapt to different workflows and environments. diff --git a/docs/developers/contributing.md b/docs/developers/contributing.md new file mode 100644 index 000000000..84aa5520b --- /dev/null +++ b/docs/developers/contributing.md @@ -0,0 +1,303 @@ +# How to Contribute + +We would love to accept your patches and contributions to this project. + +## Contribution Process + +### Code Reviews + +All submissions, including submissions by project members, require review. We +use [GitHub pull requests](https://docs.github.com/articles/about-pull-requests) +for this purpose. + +### Pull Request Guidelines + +To help us review and merge your PRs quickly, please follow these guidelines. PRs that do not meet these standards may be closed. + +#### 1. Link to an Existing Issue + +All PRs should be linked to an existing issue in our tracker. This ensures that every change has been discussed and is aligned with the project's goals before any code is written. + +- **For bug fixes:** The PR should be linked to the bug report issue. +- **For features:** The PR should be linked to the feature request or proposal issue that has been approved by a maintainer. + +If an issue for your change doesn't exist, please **open one first** and wait for feedback before you start coding. + +#### 2. Keep It Small and Focused + +We favor small, atomic PRs that address a single issue or add a single, self-contained feature. + +- **Do:** Create a PR that fixes one specific bug or adds one specific feature. +- **Don't:** Bundle multiple unrelated changes (e.g., a bug fix, a new feature, and a refactor) into a single PR. + +Large changes should be broken down into a series of smaller, logical PRs that can be reviewed and merged independently. + +#### 3. Use Draft PRs for Work in Progress + +If you'd like to get early feedback on your work, please use GitHub's **Draft Pull Request** feature. This signals to the maintainers that the PR is not yet ready for a formal review but is open for discussion and initial feedback. + +#### 4. Ensure All Checks Pass + +Before submitting your PR, ensure that all automated checks are passing by running `npm run preflight`. This command runs all tests, linting, and other style checks. + +#### 5. Update Documentation + +If your PR introduces a user-facing change (e.g., a new command, a modified flag, or a change in behavior), you must also update the relevant documentation in the `/docs` directory. + +#### 6. Write Clear Commit Messages and a Good PR Description + +Your PR should have a clear, descriptive title and a detailed description of the changes. Follow the [Conventional Commits](https://www.conventionalcommits.org/) standard for your commit messages. + +- **Good PR Title:** `feat(cli): Add --json flag to 'config get' command` +- **Bad PR Title:** `Made some changes` + +In the PR description, explain the "why" behind your changes and link to the relevant issue (e.g., `Fixes #123`). + +## Development Setup and Workflow + +This section guides contributors on how to build, modify, and understand the development setup of this project. + +### Setting Up the Development Environment + +**Prerequisites:** + +1. **Node.js**: + - **Development:** Please use Node.js `~20.19.0`. This specific version is required due to an upstream development dependency issue. You can use a tool like [nvm](https://github.com/nvm-sh/nvm) to manage Node.js versions. + - **Production:** For running the CLI in a production environment, any version of Node.js `>=20` is acceptable. +2. **Git** + +### Build Process + +To clone the repository: + +```bash +git clone https://github.com/QwenLM/qwen-code.git # Or your fork's URL +cd qwen-code +``` + +To install dependencies defined in `package.json` as well as root dependencies: + +```bash +npm install +``` + +To build the entire project (all packages): + +```bash +npm run build +``` + +This command typically compiles TypeScript to JavaScript, bundles assets, and prepares the packages for execution. Refer to `scripts/build.js` and `package.json` scripts for more details on what happens during the build. + +### Enabling Sandboxing + +[Sandboxing](#sandboxing) is highly recommended and requires, at a minimum, setting `QWEN_SANDBOX=true` in your `~/.env` and ensuring a sandboxing provider (e.g. `macOS Seatbelt`, `docker`, or `podman`) is available. See [Sandboxing](#sandboxing) for details. + +To build both the `qwen-code` CLI utility and the sandbox container, run `build:all` from the root directory: + +```bash +npm run build:all +``` + +To skip building the sandbox container, you can use `npm run build` instead. + +### Running + +To start the Qwen Code application from the source code (after building), run the following command from the root directory: + +```bash +npm start +``` + +If you'd like to run the source build outside of the qwen-code folder, you can utilize `npm link path/to/qwen-code/packages/cli` (see: [docs](https://docs.npmjs.com/cli/v9/commands/npm-link)) to run with `qwen-code` + +### Running Tests + +This project contains two types of tests: unit tests and integration tests. + +#### Unit Tests + +To execute the unit test suite for the project: + +```bash +npm run test +``` + +This will run tests located in the `packages/core` and `packages/cli` directories. Ensure tests pass before submitting any changes. For a more comprehensive check, it is recommended to run `npm run preflight`. + +#### Integration Tests + +The integration tests are designed to validate the end-to-end functionality of Qwen Code. They are not run as part of the default `npm run test` command. + +To run the integration tests, use the following command: + +```bash +npm run test:e2e +``` + +For more detailed information on the integration testing framework, please see the [Integration Tests documentation](./docs/integration-tests.md). + +### Linting and Preflight Checks + +To ensure code quality and formatting consistency, run the preflight check: + +```bash +npm run preflight +``` + +This command will run ESLint, Prettier, all tests, and other checks as defined in the project's `package.json`. + +_ProTip_ + +after cloning create a git precommit hook file to ensure your commits are always clean. + +```bash +echo " +# Run npm build and check for errors +if ! npm run preflight; then + echo "npm build failed. Commit aborted." + exit 1 +fi +" > .git/hooks/pre-commit && chmod +x .git/hooks/pre-commit +``` + +#### Formatting + +To separately format the code in this project by running the following command from the root directory: + +```bash +npm run format +``` + +This command uses Prettier to format the code according to the project's style guidelines. + +#### Linting + +To separately lint the code in this project, run the following command from the root directory: + +```bash +npm run lint +``` + +### Coding Conventions + +- Please adhere to the coding style, patterns, and conventions used throughout the existing codebase. +- **Imports:** Pay special attention to import paths. The project uses ESLint to enforce restrictions on relative imports between packages. + +### Project Structure + +- `packages/`: Contains the individual sub-packages of the project. + - `cli/`: The command-line interface. + - `core/`: The core backend logic for Qwen Code. +- `docs/`: Contains all project documentation. +- `scripts/`: Utility scripts for building, testing, and development tasks. + +For more detailed architecture, see `docs/architecture.md`. + +## Documentation Development + +This section describes how to develop and preview the documentation locally. + +### Prerequisites + +1. Ensure you have Node.js (version 18+) installed +2. Have npm or yarn available + +### Setup Documentation Site Locally + +To work on the documentation and preview changes locally: + +1. Navigate to the `docs-site` directory: + + ```bash + cd docs-site + ``` + +2. Install dependencies: + + ```bash + npm install + ``` + +3. Link the documentation content from the main `docs` directory: + + ```bash + npm run link + ``` + + This creates a symbolic link from `../docs` to `content` in the docs-site project, allowing the documentation content to be served by the Next.js site. + +4. Start the development server: + + ```bash + npm run dev + ``` + +5. Open [http://localhost:3000](http://localhost:3000) in your browser to see the documentation site with live updates as you make changes. + +Any changes made to the documentation files in the main `docs` directory will be reflected immediately in the documentation site. + +## Debugging + +### VS Code: + +0. Run the CLI to interactively debug in VS Code with `F5` +1. Start the CLI in debug mode from the root directory: + ```bash + npm run debug + ``` + This command runs `node --inspect-brk dist/index.js` within the `packages/cli` directory, pausing execution until a debugger attaches. You can then open `chrome://inspect` in your Chrome browser to connect to the debugger. +2. In VS Code, use the "Attach" launch configuration (found in `.vscode/launch.json`). + +Alternatively, you can use the "Launch Program" configuration in VS Code if you prefer to launch the currently open file directly, but 'F5' is generally recommended. + +To hit a breakpoint inside the sandbox container run: + +```bash +DEBUG=1 qwen-code +``` + +**Note:** If you have `DEBUG=true` in a project's `.env` file, it won't affect qwen-code due to automatic exclusion. Use `.qwen-code/.env` files for qwen-code specific debug settings. + +### React DevTools + +To debug the CLI's React-based UI, you can use React DevTools. Ink, the library used for the CLI's interface, is compatible with React DevTools version 4.x. + +1. **Start the Qwen Code application in development mode:** + + ```bash + DEV=true npm start + ``` + +2. **Install and run React DevTools version 4.28.5 (or the latest compatible 4.x version):** + + You can either install it globally: + + ```bash + npm install -g react-devtools@4.28.5 + react-devtools + ``` + + Or run it directly using npx: + + ```bash + npx react-devtools@4.28.5 + ``` + + Your running CLI application should then connect to React DevTools. + +## Sandboxing + +> TBD + +## Manual Publish + +We publish an artifact for each commit to our internal registry. But if you need to manually cut a local build, then run the following commands: + +``` +npm run clean +npm install +npm run auth +npm run prerelease:dev +npm publish --workspaces +``` diff --git a/docs/development/_meta.ts b/docs/developers/development/_meta.ts similarity index 72% rename from docs/development/_meta.ts rename to docs/developers/development/_meta.ts index 6428e766e..1a8f5b843 100644 --- a/docs/development/_meta.ts +++ b/docs/developers/development/_meta.ts @@ -1,8 +1,9 @@ export default { - architecture: 'Architecture', npm: 'NPM', - deployment: 'Deployment', telemetry: 'Telemetry', 'integration-tests': 'Integration Tests', 'issue-and-pr-automation': 'Issue and PR Automation', + deployment: { + display: 'hidden', + }, }; diff --git a/docs/development/deployment.md b/docs/developers/development/deployment.md similarity index 100% rename from docs/development/deployment.md rename to docs/developers/development/deployment.md diff --git a/docs/development/integration-tests.md b/docs/developers/development/integration-tests.md similarity index 100% rename from docs/development/integration-tests.md rename to docs/developers/development/integration-tests.md diff --git a/docs/development/issue-and-pr-automation.md b/docs/developers/development/issue-and-pr-automation.md similarity index 100% rename from docs/development/issue-and-pr-automation.md rename to docs/developers/development/issue-and-pr-automation.md diff --git a/docs/development/npm.md b/docs/developers/development/npm.md similarity index 72% rename from docs/development/npm.md rename to docs/developers/development/npm.md index 0a3c0af2a..76dfb72d4 100644 --- a/docs/development/npm.md +++ b/docs/developers/development/npm.md @@ -31,42 +31,57 @@ Releases are managed through the [release.yml](https://github.com/QwenLM/qwen-co - **Dry Run**: Leave as `true` to test the workflow without publishing, or set to `false` to perform a live release. 5. Click **Run workflow**. -## Nightly Releases +## Release Types -In addition to manual releases, this project has an automated nightly release process to provide the latest "bleeding edge" version for testing and development. +The project supports multiple types of releases: -### Process +### Stable Releases -Every night at midnight UTC, the [Release workflow](https://github.com/QwenLM/qwen-code/actions/workflows/release.yml) runs automatically on a schedule. It performs the following steps: +Regular stable releases for production use. -1. Checks out the latest code from the `main` branch. -2. Installs all dependencies. -3. Runs the full suite of `preflight` checks and integration tests. -4. If all tests succeed, it calculates the next nightly version number (e.g., `v0.2.1-nightly.20230101`). -5. It then builds and publishes the packages to npm with the `nightly` dist-tag. -6. Finally, it creates a GitHub Release for the nightly version. +### Preview Releases -### Failure Handling +Weekly preview releases every Tuesday at 23:59 UTC for early access to upcoming features. -If any step in the nightly workflow fails, it will automatically create a new issue in the repository with the labels `bug` and `nightly-failure`. The issue will contain a link to the failed workflow run for easy debugging. +### Nightly Releases -### How to Use the Nightly Build +Daily nightly releases at midnight UTC for bleeding-edge development testing. -To install the latest nightly build, use the `@nightly` tag: +## Automated Release Schedule + +- **Nightly**: Every day at midnight UTC +- **Preview**: Every Tuesday at 23:59 UTC +- **Stable**: Manual releases triggered by maintainers + +### How to Use Different Release Types + +To install the latest version of each type: ```bash +# Stable (default) +npm install -g @qwen-code/qwen-code + +# Preview +npm install -g @qwen-code/qwen-code@preview + +# Nightly npm install -g @qwen-code/qwen-code@nightly ``` -We also run a Google cloud build called [release-docker.yml](../.gcp/release-docker.yml). Which publishes the sandbox docker to match your release. This will also be moved to GH and combined with the main release file once service account permissions are sorted out. +### Release Process Details -### After the Release +Every scheduled or manual release follows these steps: -After the workflow has successfully completed, you can monitor its progress in the [GitHub Actions tab](https://github.com/QwenLM/qwen-code/actions/workflows/release.yml). Once complete, you should: +1. Checks out the specified code (latest from `main` branch or specific commit). +2. Installs all dependencies. +3. Runs the full suite of `preflight` checks and integration tests. +4. If all tests succeed, it calculates the appropriate version number based on release type. +5. Builds and publishes the packages to npm with the appropriate dist-tag. +6. Creates a GitHub Release for the version. -1. Go to the [pull requests page](https://github.com/QwenLM/qwen-code/pulls) of the repository. -2. Create a new pull request from the `release/vX.Y.Z` branch to `main`. -3. Review the pull request (it should only contain version updates in `package.json` files) and merge it. This keeps the version in `main` up-to-date. +### Failure Handling + +If any step in the release workflow fails, it will automatically create a new issue in the repository with the labels `bug` and a type-specific failure label (e.g., `nightly-failure`, `preview-failure`). The issue will contain a link to the failed workflow run for easy debugging. ## Release Validation @@ -155,7 +170,7 @@ By performing a dry run, you can be confident that your changes to the packaging ## Release Deep Dive The main goal of the release process is to take the source code from the packages/ directory, build it, and assemble a -clean, self-contained package in a temporary `bundle` directory at the root of the project. This `bundle` directory is what +clean, self-contained package in a temporary `dist` directory at the root of the project. This `dist` directory is what actually gets published to NPM. Here are the key stages: @@ -177,83 +192,46 @@ Stage 2: Building the Source Code - Why: The TypeScript code written during development needs to be converted into plain JavaScript that can be run by Node.js. The core package is built first as the cli package depends on it. -Stage 3: Assembling the Final Publishable Package +Stage 3: Bundling and Assembling the Final Publishable Package -This is the most critical stage where files are moved and transformed into their final state for publishing. A temporary -`bundle` folder is created at the project root to house the final package contents. +This is the most critical stage where files are moved and transformed into their final state for publishing. The process uses modern bundling techniques to create the final package. -1. The `package.json` is Transformed: - - What happens: The package.json from packages/cli/ is read, modified, and written into the root `bundle`/ directory. - - File movement: packages/cli/package.json -> (in-memory transformation) -> `bundle`/package.json - - Why: The final package.json must be different from the one used in development. Key changes include: - - Removing devDependencies. - - Removing workspace-specific "dependencies": { "@qwen-code/core": "workspace:\*" } and ensuring the core code is - bundled directly into the final JavaScript file. - - Ensuring the bin, main, and files fields point to the correct locations within the final package structure. +1. Bundle Creation: + - What happens: The prepare-package.js script creates a clean distribution package in the `dist` directory. + - Key transformations: + - Copies README.md and LICENSE to dist/ + - Copies locales folder for internationalization + - Creates a clean package.json for distribution with only necessary dependencies + - Includes runtime dependencies like tiktoken + - Maintains optional dependencies for node-pty 2. The JavaScript Bundle is Created: - What happens: The built JavaScript from both packages/core/dist and packages/cli/dist are bundled into a single, - executable JavaScript file. - - File movement: packages/cli/dist/index.js + packages/core/dist/index.js -> (bundled by esbuild) -> `bundle`/gemini.js (or a - similar name). + executable JavaScript file using esbuild. + - File location: dist/cli.js - Why: This creates a single, optimized file that contains all the necessary application code. It simplifies the package - by removing the need for the core package to be a separate dependency on NPM, as its code is now included directly. + by removing the need for complex dependency resolution at install time. 3. Static and Supporting Files are Copied: - What happens: Essential files that are not part of the source code but are required for the package to work correctly - or be well-described are copied into the `bundle` directory. + or be well-described are copied into the `dist` directory. - File movement: - - README.md -> `bundle`/README.md - - LICENSE -> `bundle`/LICENSE - - packages/cli/src/utils/\*.sb (sandbox profiles) -> `bundle`/ + - README.md -> dist/README.md + - LICENSE -> dist/LICENSE + - locales/ -> dist/locales/ + - Vendor files -> dist/vendor/ - Why: - The README.md and LICENSE are standard files that should be included in any NPM package. - - The sandbox profiles (.sb files) are critical runtime assets required for the CLI's sandboxing feature to - function. They must be located next to the final executable. + - Locales support internationalization features + - Vendor files contain necessary runtime dependencies Stage 4: Publishing to NPM -- What happens: The npm publish command is run from inside the root `bundle` directory. -- Why: By running npm publish from within the `bundle` directory, only the files we carefully assembled in Stage 3 are uploaded +- What happens: The npm publish command is run from inside the root `dist` directory. +- Why: By running npm publish from within the `dist` directory, only the files we carefully assembled in Stage 3 are uploaded to the NPM registry. This prevents any source code, test files, or development configurations from being accidentally published, resulting in a clean and minimal package for users. -Summary of File Flow - -```mermaid -graph TD - subgraph "Source Files" - A["packages/core/src/*.ts
packages/cli/src/*.ts"] - B["packages/cli/package.json"] - C["README.md
LICENSE
packages/cli/src/utils/*.sb"] - end - - subgraph "Process" - D(Build) - E(Transform) - F(Assemble) - G(Publish) - end - - subgraph "Artifacts" - H["Bundled JS"] - I["Final package.json"] - J["bundle/"] - end - - subgraph "Destination" - K["NPM Registry"] - end - - A --> D --> H - B --> E --> I - C --> F - H --> F - I --> F - F --> J - J --> G --> K -``` - This process ensures that the final published artifact is a purpose-built, clean, and efficient representation of the project, rather than a direct copy of the development workspace. diff --git a/docs/development/telemetry.md b/docs/developers/development/telemetry.md similarity index 68% rename from docs/development/telemetry.md rename to docs/developers/development/telemetry.md index 5ea185a38..f5faee40e 100644 --- a/docs/development/telemetry.md +++ b/docs/developers/development/telemetry.md @@ -6,13 +6,12 @@ Learn how to enable and setup OpenTelemetry for Qwen Code. - [Key Benefits](#key-benefits) - [OpenTelemetry Integration](#opentelemetry-integration) - [Configuration](#configuration) - - [Google Cloud Telemetry](#google-cloud-telemetry) + - [Aliyun Telemetry](#aliyun-telemetry) - [Prerequisites](#prerequisites) - [Direct Export (Recommended)](#direct-export-recommended) - - [Collector-Based Export (Advanced)](#collector-based-export-advanced) - [Local Telemetry](#local-telemetry) - [File-based Output (Recommended)](#file-based-output-recommended) - - [Collector-Based Export (Advanced)](#collector-based-export-advanced-1) + - [Collector-Based Export (Advanced)](#collector-based-export-advanced) - [Logs and Metrics](#logs-and-metrics) - [Logs](#logs) - [Metrics](#metrics) @@ -35,8 +34,8 @@ Learn how to enable and setup OpenTelemetry for Qwen Code. Built on **[OpenTelemetry]** — the vendor-neutral, industry-standard observability framework — Qwen Code's observability system provides: -- **Universal Compatibility**: Export to any OpenTelemetry backend (Google - Cloud, Jaeger, Prometheus, Datadog, etc.) +- **Universal Compatibility**: Export to any OpenTelemetry backend (Aliyun, + Jaeger, Prometheus, Datadog, etc.) - **Standardized Data**: Use consistent formats and collection methods across your toolchain - **Future-Proof Integration**: Connect with existing and future observability @@ -48,18 +47,22 @@ observability framework — Qwen Code's observability system provides: ## Configuration +> [!note] +> +> **⚠️ Special Note: This feature requires corresponding code changes. This documentation is provided in advance; please refer to future code updates for actual functionality.** + All telemetry behavior is controlled through your `.qwen/settings.json` file. These settings can be overridden by environment variables or CLI flags. -| Setting | Environment Variable | CLI Flag | Description | Values | Default | -| -------------- | -------------------------------- | -------------------------------------------------------- | ------------------------------------------------- | ----------------- | ----------------------- | -| `enabled` | `GEMINI_TELEMETRY_ENABLED` | `--telemetry` / `--no-telemetry` | Enable or disable telemetry | `true`/`false` | `false` | -| `target` | `GEMINI_TELEMETRY_TARGET` | `--telemetry-target ` | Where to send telemetry data | `"gcp"`/`"local"` | `"local"` | -| `otlpEndpoint` | `GEMINI_TELEMETRY_OTLP_ENDPOINT` | `--telemetry-otlp-endpoint ` | OTLP collector endpoint | URL string | `http://localhost:4317` | -| `otlpProtocol` | `GEMINI_TELEMETRY_OTLP_PROTOCOL` | `--telemetry-otlp-protocol ` | OTLP transport protocol | `"grpc"`/`"http"` | `"grpc"` | -| `outfile` | `GEMINI_TELEMETRY_OUTFILE` | `--telemetry-outfile ` | Save telemetry to file (overrides `otlpEndpoint`) | file path | - | -| `logPrompts` | `GEMINI_TELEMETRY_LOG_PROMPTS` | `--telemetry-log-prompts` / `--no-telemetry-log-prompts` | Include prompts in telemetry logs | `true`/`false` | `true` | -| `useCollector` | `GEMINI_TELEMETRY_USE_COLLECTOR` | - | Use external OTLP collector (advanced) | `true`/`false` | `false` | +| Setting | Environment Variable | CLI Flag | Description | Values | Default | +| -------------- | ------------------------------ | -------------------------------------------------------- | ------------------------------------------------- | ------------------ | ----------------------- | +| `enabled` | `QWEN_TELEMETRY_ENABLED` | `--telemetry` / `--no-telemetry` | Enable or disable telemetry | `true`/`false` | `false` | +| `target` | `QWEN_TELEMETRY_TARGET` | `--telemetry-target ` | Where to send telemetry data | `"qwen"`/`"local"` | `"local"` | +| `otlpEndpoint` | `QWEN_TELEMETRY_OTLP_ENDPOINT` | `--telemetry-otlp-endpoint ` | OTLP collector endpoint | URL string | `http://localhost:4317` | +| `otlpProtocol` | `QWEN_TELEMETRY_OTLP_PROTOCOL` | `--telemetry-otlp-protocol ` | OTLP transport protocol | `"grpc"`/`"http"` | `"grpc"` | +| `outfile` | `QWEN_TELEMETRY_OUTFILE` | `--telemetry-outfile ` | Save telemetry to file (overrides `otlpEndpoint`) | file path | - | +| `logPrompts` | `QWEN_TELEMETRY_LOG_PROMPTS` | `--telemetry-log-prompts` / `--no-telemetry-log-prompts` | Include prompts in telemetry logs | `true`/`false` | `true` | +| `useCollector` | `QWEN_TELEMETRY_USE_COLLECTOR` | - | Use external OTLP collector (advanced) | `true`/`false` | `false` | **Note on boolean environment variables:** For the boolean settings (`enabled`, `logPrompts`, `useCollector`), setting the corresponding environment variable to @@ -68,98 +71,23 @@ These settings can be overridden by environment variables or CLI flags. For detailed information about all configuration options, see the [Configuration Guide](./cli/configuration.md). -## Google Cloud Telemetry - -### Prerequisites - -Before using either method below, complete these steps: - -1. Set your Google Cloud project ID: - - For telemetry in a separate project from inference: - ```bash - export OTLP_GOOGLE_CLOUD_PROJECT="your-telemetry-project-id" - ``` - - For telemetry in the same project as inference: - ```bash - export GOOGLE_CLOUD_PROJECT="your-project-id" - ``` - -2. Authenticate with Google Cloud: - - If using a user account: - ```bash - gcloud auth application-default login - ``` - - If using a service account: - ```bash - export GOOGLE_APPLICATION_CREDENTIALS="/path/to/your/service-account.json" - ``` -3. Make sure your account or service account has these IAM roles: - - Cloud Trace Agent - - Monitoring Metric Writer - - Logs Writer - -4. Enable the required Google Cloud APIs (if not already enabled): - ```bash - gcloud services enable \ - cloudtrace.googleapis.com \ - monitoring.googleapis.com \ - logging.googleapis.com \ - --project="$OTLP_GOOGLE_CLOUD_PROJECT" - ``` +## Aliyun Telemetry ### Direct Export (Recommended) -Sends telemetry directly to Google Cloud services. No collector needed. +Sends telemetry directly to Aliyun services. No collector needed. 1. Enable telemetry in your `.qwen/settings.json`: ```json { "telemetry": { "enabled": true, - "target": "gcp" + "target": "qwen" } } ``` 2. Run Qwen Code and send prompts. -3. View logs and metrics: - - Open the Google Cloud Console in your browser after sending prompts: - - Logs: https://console.cloud.google.com/logs/ - - Metrics: https://console.cloud.google.com/monitoring/metrics-explorer - - Traces: https://console.cloud.google.com/traces/list - -### Collector-Based Export (Advanced) - -For custom processing, filtering, or routing, use an OpenTelemetry collector to -forward data to Google Cloud. - -1. Configure your `.qwen/settings.json`: - ```json - { - "telemetry": { - "enabled": true, - "target": "gcp", - "useCollector": true - } - } - ``` -2. Run the automation script: - ```bash - npm run telemetry -- --target=gcp - ``` - This will: - - Start a local OTEL collector that forwards to Google Cloud - - Configure your workspace - - Provide links to view traces, metrics, and logs in Google Cloud Console - - Save collector logs to `~/.qwen/tmp//otel/collector-gcp.log` - - Stop collector on exit (e.g. `Ctrl+C`) -3. Run Qwen Code and send prompts. -4. View logs and metrics: - - Open the Google Cloud Console in your browser after sending prompts: - - Logs: https://console.cloud.google.com/logs/ - - Metrics: https://console.cloud.google.com/monitoring/metrics-explorer - - Traces: https://console.cloud.google.com/traces/list - - Open `~/.qwen/tmp//otel/collector-gcp.log` to view local - collector logs. +3. View logs and metrics in the Aliyun Console. ## Local Telemetry diff --git a/docs/examples/proxy-script.md b/docs/developers/examples/proxy-script.md similarity index 100% rename from docs/examples/proxy-script.md rename to docs/developers/examples/proxy-script.md diff --git a/docs/extensions/extension-releasing.md b/docs/developers/extensions/extension-releasing.md similarity index 100% rename from docs/extensions/extension-releasing.md rename to docs/developers/extensions/extension-releasing.md diff --git a/docs/extensions/extension.md b/docs/developers/extensions/extension.md similarity index 100% rename from docs/extensions/extension.md rename to docs/developers/extensions/extension.md diff --git a/docs/extensions/getting-started-extensions.md b/docs/developers/extensions/getting-started-extensions.md similarity index 100% rename from docs/extensions/getting-started-extensions.md rename to docs/developers/extensions/getting-started-extensions.md diff --git a/docs/developers/roadmap.md b/docs/developers/roadmap.md new file mode 100644 index 000000000..0fb05f8ad --- /dev/null +++ b/docs/developers/roadmap.md @@ -0,0 +1,74 @@ +# Qwen Code RoadMap + +> **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 | + +> For more details, please see the list below. + +## Features + +#### 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+` | DashScope cache control | 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 | + +#### Features to Develop + +| Feature | Priority | Status | Description | Category | +| ---------------------------- | -------- | ----------- | --------------------------------- | --------------------------- | +| Better UI | P1 | Planned | Optimized terminal UI interaction | User Experience | +| OnBoarding | P1 | Planned | New user onboarding flow | User Experience | +| Permission | P1 | Planned | Permission system optimization | User Experience | +| 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 | + +#### Distinctive Features to Discuss + +| Feature | Status | Description | +| ---------------- | -------- | ----------------------------------------------------- | +| Home Spotlight | Research | Project discovery and quick launch | +| Competitive Mode | Research | Competitive mode | +| Pulse | Research | User activity pulse analysis (OpenAI Pulse reference) | +| Code Wiki | Research | Project codebase wiki/documentation system | diff --git a/docs/developers/sdk-typescript.md b/docs/developers/sdk-typescript.md new file mode 100644 index 000000000..46625e840 --- /dev/null +++ b/docs/developers/sdk-typescript.md @@ -0,0 +1,375 @@ +# Typescript SDK + +## @qwen-code/sdk + +A minimum experimental TypeScript SDK for programmatic access to Qwen Code. + +Feel free to submit a feature request/issue/PR. + +## Installation + +```bash +npm install @qwen-code/sdk +``` + +## Requirements + +- Node.js >= 20.0.0 +- [Qwen Code](https://github.com/QwenLM/qwen-code) >= 0.4.0 (stable) installed and accessible in PATH + +> **Note for nvm users**: If you use nvm to manage Node.js versions, the SDK may not be able to auto-detect the Qwen Code executable. You should explicitly set the `pathToQwenExecutable` option to the full path of the `qwen` binary. + +## Quick Start + +```typescript +import { query } from '@qwen-code/sdk'; + +// Single-turn query +const result = query({ + prompt: 'What files are in the current directory?', + options: { + cwd: '/path/to/project', + }, +}); + +// Iterate over messages +for await (const message of result) { + if (message.type === 'assistant') { + console.log('Assistant:', message.message.content); + } else if (message.type === 'result') { + console.log('Result:', message.result); + } +} +``` + +## API Reference + +### `query(config)` + +Creates a new query session with the Qwen Code. + +#### Parameters + +- `prompt`: `string | AsyncIterable` - The prompt to send. Use a string for single-turn queries or an async iterable for multi-turn conversations. +- `options`: `QueryOptions` - Configuration options for the query session. + +#### QueryOptions + +| Option | Type | Default | Description | +| ------------------------ | ---------------------------------------------- | ---------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `cwd` | `string` | `process.cwd()` | The working directory for the query session. Determines the context in which file operations and commands are executed. | +| `model` | `string` | - | The AI model to use (e.g., `'qwen-max'`, `'qwen-plus'`, `'qwen-turbo'`). Takes precedence over `OPENAI_MODEL` and `QWEN_MODEL` environment variables. | +| `pathToQwenExecutable` | `string` | Auto-detected | Path to the Qwen Code executable. Supports multiple formats: `'qwen'` (native binary from PATH), `'/path/to/qwen'` (explicit path), `'/path/to/cli.js'` (Node.js bundle), `'node:/path/to/cli.js'` (force Node.js runtime), `'bun:/path/to/cli.js'` (force Bun runtime). If not provided, auto-detects from: `QWEN_CODE_CLI_PATH` env var, `~/.volta/bin/qwen`, `~/.npm-global/bin/qwen`, `/usr/local/bin/qwen`, `~/.local/bin/qwen`, `~/node_modules/.bin/qwen`, `~/.yarn/bin/qwen`. | +| `permissionMode` | `'default' \| 'plan' \| 'auto-edit' \| 'yolo'` | `'default'` | Permission mode controlling tool execution approval. See [Permission Modes](#permission-modes) for details. | +| `canUseTool` | `CanUseTool` | - | Custom permission handler for tool execution approval. Invoked when a tool requires confirmation. Must respond within 60 seconds or the request will be auto-denied. See [Custom Permission Handler](#custom-permission-handler). | +| `env` | `Record` | - | Environment variables to pass to the Qwen Code process. Merged with the current process environment. | +| `mcpServers` | `Record` | - | MCP (Model Context Protocol) servers to connect. Supports external servers (stdio/SSE/HTTP) and SDK-embedded servers. External servers are configured with transport options like `command`, `args`, `url`, `httpUrl`, etc. SDK servers use `{ type: 'sdk', name: string, instance: Server }`. | +| `abortController` | `AbortController` | - | Controller to cancel the query session. Call `abortController.abort()` to terminate the session and cleanup resources. | +| `debug` | `boolean` | `false` | Enable debug mode for verbose logging from the CLI process. | +| `maxSessionTurns` | `number` | `-1` (unlimited) | Maximum number of conversation turns before the session automatically terminates. A turn consists of a user message and an assistant response. | +| `coreTools` | `string[]` | - | Equivalent to `tool.core` in settings.json. If specified, only these tools will be available to the AI. Example: `['read_file', 'write_file', 'run_terminal_cmd']`. | +| `excludeTools` | `string[]` | - | Equivalent to `tool.exclude` in settings.json. Excluded tools return a permission error immediately. Takes highest priority over all other permission settings. Supports pattern matching: tool name (`'write_file'`), tool class (`'ShellTool'`), or shell command prefix (`'ShellTool(rm )'`). | +| `allowedTools` | `string[]` | - | Equivalent to `tool.allowed` in settings.json. Matching tools bypass `canUseTool` callback and execute automatically. Only applies when tool requires confirmation. Supports same pattern matching as `excludeTools`. | +| `authType` | `'openai' \| 'qwen-oauth'` | `'openai'` | Authentication type for the AI service. Using `'qwen-oauth'` in SDK is not recommended as credentials are stored in `~/.qwen` and may need periodic refresh. | +| `agents` | `SubagentConfig[]` | - | Configuration for subagents that can be invoked during the session. Subagents are specialized AI agents for specific tasks or domains. | +| `includePartialMessages` | `boolean` | `false` | When `true`, the SDK emits incomplete messages as they are being generated, allowing real-time streaming of the AI's response. | + +### Timeouts + +The SDK enforces the following default timeouts: + +| Timeout | Default | Description | +| ---------------- | -------- | ---------------------------------------------------------------------------------------------------------------------------- | +| `canUseTool` | 1 minute | Maximum time for `canUseTool` callback to respond. If exceeded, the tool request is auto-denied. | +| `mcpRequest` | 1 minute | Maximum time for SDK MCP tool calls to complete. | +| `controlRequest` | 1 minute | Maximum time for control operations like `initialize()`, `setModel()`, `setPermissionMode()`, and `interrupt()` to complete. | +| `streamClose` | 1 minute | Maximum time to wait for initialization to complete before closing CLI stdin in multi-turn mode with SDK MCP servers. | + +You can customize these timeouts via the `timeout` option: + +```typescript +const query = qwen.query('Your prompt', { + timeout: { + canUseTool: 60000, // 60 seconds for permission callback + mcpRequest: 600000, // 10 minutes for MCP tool calls + controlRequest: 60000, // 60 seconds for control requests + streamClose: 15000, // 15 seconds for stream close wait + }, +}); +``` + +### Message Types + +The SDK provides type guards to identify different message types: + +```typescript +import { + isSDKUserMessage, + isSDKAssistantMessage, + isSDKSystemMessage, + isSDKResultMessage, + isSDKPartialAssistantMessage, +} from '@qwen-code/sdk'; + +for await (const message of result) { + if (isSDKAssistantMessage(message)) { + // Handle assistant message + } else if (isSDKResultMessage(message)) { + // Handle result message + } +} +``` + +### Query Instance Methods + +The `Query` instance returned by `query()` provides several methods: + +```typescript +const q = query({ prompt: 'Hello', options: {} }); + +// Get session ID +const sessionId = q.getSessionId(); + +// Check if closed +const closed = q.isClosed(); + +// Interrupt the current operation +await q.interrupt(); + +// Change permission mode mid-session +await q.setPermissionMode('yolo'); + +// Change model mid-session +await q.setModel('qwen-max'); + +// Close the session +await q.close(); +``` + +## Permission Modes + +The SDK supports different permission modes for controlling tool execution: + +- **`default`**: Write tools are denied unless approved via `canUseTool` callback or in `allowedTools`. Read-only tools execute without confirmation. +- **`plan`**: Blocks all write tools, instructing AI to present a plan first. +- **`auto-edit`**: Auto-approve edit tools (edit, write_file) while other tools require confirmation. +- **`yolo`**: All tools execute automatically without confirmation. + +### Permission Priority Chain + +1. `excludeTools` - Blocks tools completely +2. `permissionMode: 'plan'` - Blocks non-read-only tools +3. `permissionMode: 'yolo'` - Auto-approves all tools +4. `allowedTools` - Auto-approves matching tools +5. `canUseTool` callback - Custom approval logic +6. Default behavior - Auto-deny in SDK mode + +## Examples + +### Multi-turn Conversation + +```typescript +import { query, type SDKUserMessage } from '@qwen-code/sdk'; + +async function* generateMessages(): AsyncIterable { + yield { + type: 'user', + session_id: 'my-session', + message: { role: 'user', content: 'Create a hello.txt file' }, + parent_tool_use_id: null, + }; + + // Wait for some condition or user input + yield { + type: 'user', + session_id: 'my-session', + message: { role: 'user', content: 'Now read the file back' }, + parent_tool_use_id: null, + }; +} + +const result = query({ + prompt: generateMessages(), + options: { + permissionMode: 'auto-edit', + }, +}); + +for await (const message of result) { + console.log(message); +} +``` + +### Custom Permission Handler + +```typescript +import { query, type CanUseTool } from '@qwen-code/sdk'; + +const canUseTool: CanUseTool = async (toolName, input, { signal }) => { + // Allow all read operations + if (toolName.startsWith('read_')) { + return { behavior: 'allow', updatedInput: input }; + } + + // Prompt user for write operations (in a real app) + const userApproved = await promptUser(`Allow ${toolName}?`); + + if (userApproved) { + return { behavior: 'allow', updatedInput: input }; + } + + return { behavior: 'deny', message: 'User denied the operation' }; +}; + +const result = query({ + prompt: 'Create a new file', + options: { + canUseTool, + }, +}); +``` + +### With External MCP Servers + +```typescript +import { query } from '@qwen-code/sdk'; + +const result = query({ + prompt: 'Use the custom tool from my MCP server', + options: { + mcpServers: { + 'my-server': { + command: 'node', + args: ['path/to/mcp-server.js'], + env: { PORT: '3000' }, + }, + }, + }, +}); +``` + +### With SDK-Embedded MCP Servers + +The SDK provides `tool` and `createSdkMcpServer` to create MCP servers that run in the same process as your SDK application. This is useful when you want to expose custom tools to the AI without running a separate server process. + +#### `tool(name, description, inputSchema, handler)` + +Creates a tool definition with Zod schema type inference. + +| Parameter | Type | Description | +| ------------- | ---------------------------------- | ------------------------------------------------------------------------ | +| `name` | `string` | Tool name (1-64 chars, starts with letter, alphanumeric and underscores) | +| `description` | `string` | Human-readable description of what the tool does | +| `inputSchema` | `ZodRawShape` | Zod schema object defining the tool's input parameters | +| `handler` | `(args, extra) => Promise` | Async function that executes the tool and returns MCP content blocks | + +The handler must return a `CallToolResult` object with the following structure: + +```typescript +{ + content: Array< + | { type: 'text'; text: string } + | { type: 'image'; data: string; mimeType: string } + | { type: 'resource'; uri: string; mimeType?: string; text?: string } + >; + isError?: boolean; +} +``` + +#### `createSdkMcpServer(options)` + +Creates an SDK-embedded MCP server instance. + +| Option | Type | Default | Description | +| --------- | ------------------------ | --------- | ------------------------------------ | +| `name` | `string` | Required | Unique name for the MCP server | +| `version` | `string` | `'1.0.0'` | Server version | +| `tools` | `SdkMcpToolDefinition[]` | - | Array of tools created with `tool()` | + +Returns a `McpSdkServerConfigWithInstance` object that can be passed directly to the `mcpServers` option. + +#### Example + +```typescript +import { z } from 'zod'; +import { query, tool, createSdkMcpServer } from '@qwen-code/sdk'; + +// Define a tool with Zod schema +const calculatorTool = tool( + 'calculate_sum', + 'Add two numbers', + { a: z.number(), b: z.number() }, + async (args) => ({ + content: [{ type: 'text', text: String(args.a + args.b) }], + }), +); + +// Create the MCP server +const server = createSdkMcpServer({ + name: 'calculator', + tools: [calculatorTool], +}); + +// Use the server in a query +const result = query({ + prompt: 'What is 42 + 17?', + options: { + permissionMode: 'yolo', + mcpServers: { + calculator: server, + }, + }, +}); + +for await (const message of result) { + console.log(message); +} +``` + +### Abort a Query + +```typescript +import { query, isAbortError } from '@qwen-code/sdk'; + +const abortController = new AbortController(); + +const result = query({ + prompt: 'Long running task...', + options: { + abortController, + }, +}); + +// Abort after 5 seconds +setTimeout(() => abortController.abort(), 5000); + +try { + for await (const message of result) { + console.log(message); + } +} catch (error) { + if (isAbortError(error)) { + console.log('Query was aborted'); + } else { + throw error; + } +} +``` + +## Error Handling + +The SDK provides an `AbortError` class for handling aborted queries: + +```typescript +import { AbortError, isAbortError } from '@qwen-code/sdk'; + +try { + // ... query operations +} catch (error) { + if (isAbortError(error)) { + // Handle abort + } else { + // Handle other errors + } +} +``` diff --git a/docs/tools/_meta.ts b/docs/developers/tools/_meta.ts similarity index 73% rename from docs/tools/_meta.ts rename to docs/developers/tools/_meta.ts index dc18f5e07..9964b9976 100644 --- a/docs/tools/_meta.ts +++ b/docs/developers/tools/_meta.ts @@ -1,9 +1,11 @@ export default { - index: 'Introduction', + introduction: 'Introduction', 'file-system': 'File System', 'multi-file': 'Multi-File Read', shell: 'Shell', 'todo-write': 'Todo Write', + task: 'Task', + 'exit-plan-mode': 'Exit Plan Mode', 'web-fetch': 'Web Fetch', 'web-search': 'Web Search', memory: 'Memory', diff --git a/docs/developers/tools/exit-plan-mode.md b/docs/developers/tools/exit-plan-mode.md new file mode 100644 index 000000000..ac28fa3a6 --- /dev/null +++ b/docs/developers/tools/exit-plan-mode.md @@ -0,0 +1,149 @@ +# Exit Plan Mode Tool (`exit_plan_mode`) + +This document describes the `exit_plan_mode` tool for Qwen Code. + +## Description + +Use `exit_plan_mode` when you are in plan mode and have finished presenting your implementation plan. This tool prompts the user to approve or reject the plan and transitions from planning mode to implementation mode. + +The tool is specifically designed for tasks that require planning implementation steps before writing code. It should NOT be used for research or information-gathering tasks. + +### Arguments + +`exit_plan_mode` takes one argument: + +- `plan` (string, required): The implementation plan you want to present to the user for approval. This should be a concise, markdown-formatted plan describing the implementation steps. + +## How to use `exit_plan_mode` with Qwen Code + +The Exit Plan Mode tool is part of Qwen Code's planning workflow. When you're in plan mode (typically after exploring a codebase and designing an implementation approach), you use this tool to: + +1. Present your implementation plan to the user +2. Request approval to proceed with implementation +3. Transition from plan mode to implementation mode based on user response + +The tool will prompt the user with your plan and provide options to: + +- **Proceed Once**: Approve the plan for this session only +- **Proceed Always**: Approve the plan and enable auto-approval for future edit operations +- **Cancel**: Reject the plan and remain in planning mode + +Usage: + +``` +exit_plan_mode(plan="Your detailed implementation plan here...") +``` + +## When to Use This Tool + +Use `exit_plan_mode` when: + +1. **Implementation tasks**: You are planning the implementation steps for a coding task +2. **Plan completion**: You have finished exploring and designing your implementation approach +3. **User approval needed**: You need user confirmation before proceeding with code changes +4. **Code writing tasks**: The task involves writing, modifying, or refactoring code + +### Examples of appropriate usage: + +- "Help me implement user authentication" → Use after planning the auth system implementation +- "Add a new API endpoint for user management" → Use after designing the endpoint structure +- "Refactor the database layer to use TypeORM" → Use after planning the refactoring approach + +## When NOT to Use This Tool + +Do NOT use `exit_plan_mode` for: + +1. **Research tasks**: Tasks focused on understanding or exploring existing code +2. **Information gathering**: When you're searching, reading, or analyzing without implementing +3. **Documentation tasks**: When creating documentation without code changes +4. **Analysis tasks**: When providing explanations or reviews without implementation + +### Examples of inappropriate usage: + +- "Search for and understand the implementation of vim mode in the codebase" → Don't use (research task) +- "Explain how the authentication system works" → Don't use (analysis task) +- "Find all TODO comments in the project" → Don't use (information gathering) + +## `exit_plan_mode` examples + +### Implementation planning example + +``` +exit_plan_mode(plan="## Implementation Plan for User Dashboard + +### 1. Create Dashboard Components +- Create `UserDashboard.tsx` with main dashboard layout +- Add `DashboardStats.tsx` for displaying user statistics +- Implement `ActivityFeed.tsx` for recent user activities + +### 2. Add API Integration +- Create `dashboardApi.ts` for dashboard data fetching +- Add endpoints for user stats and activity data +- Implement error handling and loading states + +### 3. Update Routing +- Add dashboard route in `App.tsx` +- Update navigation component with dashboard link +- Add route protection for authenticated users + +### 4. Testing and Documentation +- Write unit tests for dashboard components +- Add integration tests for API calls +- Update user documentation with dashboard features + +This implementation will take approximately 4-6 hours and requires changes to 8-10 files.") +``` + +### Refactoring plan example + +``` +exit_plan_mode(plan="## Database Layer Refactoring Plan + +### Phase 1: Setup and Configuration +- Install and configure TypeORM dependencies +- Create database connection configuration +- Set up entity definitions for existing models + +### Phase 2: Migration +- Create migration scripts for existing data +- Implement new repository pattern using TypeORM +- Replace raw SQL queries with TypeORM query builder + +### Phase 3: Testing and Validation +- Update all database tests to use TypeORM +- Validate data integrity after migration +- Performance testing to ensure no regressions + +This refactoring will modernize our database layer while maintaining backward compatibility.") +``` + +## User Response Handling + +After calling `exit_plan_mode`, the user can respond in several ways: + +- **Proceed Once**: The plan is approved for immediate implementation with default confirmation settings +- **Proceed Always**: The plan is approved and auto-approval is enabled for subsequent edit operations +- **Cancel**: The plan is rejected, and the system remains in plan mode for further planning + +The tool automatically adjusts the approval mode based on the user's choice, streamlining the implementation process according to user preferences. + +## Important Notes + +- **Plan mode only**: This tool should only be used when you are currently in plan mode +- **Implementation focus**: Only use for tasks that involve writing or modifying code +- **Concise plans**: Keep plans focused and concise - aim for clarity over exhaustive detail +- **Markdown support**: Plans support markdown formatting for better readability +- **Single use**: The tool should be used once per planning session when ready to proceed +- **User control**: The final decision to proceed always rests with the user + +## Integration with Planning Workflow + +The Exit Plan Mode tool is part of a larger planning workflow: + +1. **Enter Plan Mode**: User requests or system determines planning is needed +2. **Exploration Phase**: Analyze codebase, understand requirements, explore options +3. **Plan Design**: Create implementation strategy based on exploration +4. **Plan Presentation**: Use `exit_plan_mode` to present plan to user +5. **Implementation Phase**: Upon approval, proceed with planned implementation + +This workflow ensures thoughtful implementation approaches and gives users control over significant code changes. diff --git a/docs/tools/file-system.md b/docs/developers/tools/file-system.md similarity index 96% rename from docs/tools/file-system.md rename to docs/developers/tools/file-system.md index 3c5097dff..bfa6de8d0 100644 --- a/docs/tools/file-system.md +++ b/docs/developers/tools/file-system.md @@ -83,7 +83,7 @@ Qwen Code provides a comprehensive suite of tools for interacting with the local - **Tool name:** `grep_search` - **Display name:** Grep -- **File:** `ripGrep.ts` (with `grep.ts` as fallback) +- **File:** `grep.ts` (with `ripGrep.ts` as fallback) - **Parameters:** - `pattern` (string, required): The regular expression pattern to search for in file contents (e.g., `"function\\s+myFunction"`, `"log.*Error"`). - `path` (string, optional): File or directory to search in. Defaults to current working directory. @@ -141,7 +141,7 @@ grep_search(pattern="function", glob="*.js", limit=10) - `file_path` (string, required): The absolute path to the file to modify. - `old_string` (string, required): The exact literal text to replace. - **CRITICAL:** This string must uniquely identify the single instance to change. It should include at least 3 lines of context _before_ and _after_ the target text, matching whitespace and indentation precisely. If `old_string` is empty, the tool attempts to create a new file at `file_path` with `new_string` as content. + **CRITICAL:** This string must uniquely identify the single instance to change. It should include sufficient context around the target text, matching whitespace and indentation precisely. If `old_string` is empty, the tool attempts to create a new file at `file_path` with `new_string` as content. - `new_string` (string, required): The exact literal text to replace `old_string` with. - `replace_all` (boolean, optional): Replace all occurrences of `old_string`. Defaults to `false`. diff --git a/docs/tools/index.md b/docs/developers/tools/introduction.md similarity index 86% rename from docs/tools/index.md rename to docs/developers/tools/introduction.md index 6798ec6c3..9c7325552 100644 --- a/docs/tools/index.md +++ b/docs/developers/tools/introduction.md @@ -50,7 +50,13 @@ Qwen Code's built-in tools can be broadly categorized as follows: - **[Multi-File Read Tool](./multi-file.md) (`read_many_files`):** A specialized tool for reading content from multiple files or directories, often used by the `@` command. - **[Memory Tool](./memory.md) (`save_memory`):** For saving and recalling information across sessions. - **[Todo Write Tool](./todo-write.md) (`todo_write`):** For creating and managing structured task lists during coding sessions. +- **[Task Tool](./task.md) (`task`):** For delegating complex tasks to specialized subagents. +- **[Exit Plan Mode Tool](./exit-plan-mode.md) (`exit_plan_mode`):** For exiting plan mode and proceeding with implementation. Additionally, these tools incorporate: - **[MCP servers](./mcp-server.md)**: MCP servers act as a bridge between the model and your local environment or other services like APIs. + - **[MCP Quick Start Guide](../mcp-quick-start.md)**: Get started with MCP in 5 minutes with practical examples + - **[MCP Example Configurations](../mcp-example-configs.md)**: Ready-to-use configurations for common scenarios + - **[MCP Testing & Validation](../mcp-testing-validation.md)**: Test and validate your MCP server setups +- **[Sandboxing](../sandbox.md)**: Sandboxing isolates the model and its changes from your environment to reduce potential risk. diff --git a/docs/tools/mcp-server.md b/docs/developers/tools/mcp-server.md similarity index 98% rename from docs/tools/mcp-server.md rename to docs/developers/tools/mcp-server.md index 4a75b1fc4..8d48970a7 100644 --- a/docs/tools/mcp-server.md +++ b/docs/developers/tools/mcp-server.md @@ -627,7 +627,12 @@ The MCP integration tracks several states: ### Schema Compatibility -- **Property stripping:** The system automatically removes certain schema properties (`$schema`, `additionalProperties`) for Qwen API compatibility +- **Schema compliance mode:** By default (`schemaCompliance: "auto"`), tool schemas are passed through as-is. Set `"model": { "generationConfig": { "schemaCompliance": "openapi_30" } }` in your `settings.json` to convert models to Strict OpenAPI 3.0 format. +- **OpenAPI 3.0 transformations:** When `openapi_30` mode is enabled, the system handles: + - Nullable types: `["string", "null"]` -> `type: "string", nullable: true` + - Const values: `const: "foo"` -> `enum: ["foo"]` + - Exclusive limits: numeric `exclusiveMinimum` -> boolean form with `minimum` + - Keyword removal: `$schema`, `$id`, `dependencies`, `patternProperties` - **Name sanitization:** Tool names are automatically sanitized to meet API requirements - **Conflict resolution:** Tool name conflicts between servers are resolved through automatic prefixing diff --git a/docs/tools/memory.md b/docs/developers/tools/memory.md similarity index 100% rename from docs/tools/memory.md rename to docs/developers/tools/memory.md diff --git a/docs/tools/multi-file.md b/docs/developers/tools/multi-file.md similarity index 100% rename from docs/tools/multi-file.md rename to docs/developers/tools/multi-file.md diff --git a/docs/tools/shell.md b/docs/developers/tools/shell.md similarity index 100% rename from docs/tools/shell.md rename to docs/developers/tools/shell.md diff --git a/docs/developers/tools/task.md b/docs/developers/tools/task.md new file mode 100644 index 000000000..138501886 --- /dev/null +++ b/docs/developers/tools/task.md @@ -0,0 +1,145 @@ +# Task Tool (`task`) + +This document describes the `task` tool for Qwen Code. + +## Description + +Use `task` to launch a specialized subagent to handle complex, multi-step tasks autonomously. The Task tool delegates work to specialized agents that can work independently with access to their own set of tools, allowing for parallel task execution and specialized expertise. + +### Arguments + +`task` takes the following arguments: + +- `description` (string, required): A short (3-5 word) description of the task for user visibility and tracking purposes. +- `prompt` (string, required): The detailed task prompt for the subagent to execute. Should contain comprehensive instructions for autonomous execution. +- `subagent_type` (string, required): The type of specialized agent to use for this task. Must match one of the available configured subagents. + +## How to use `task` with Qwen Code + +The Task tool dynamically loads available subagents from your configuration and delegates tasks to them. Each subagent runs independently and can use its own set of tools, allowing for specialized expertise and parallel execution. + +When you use the Task tool, the subagent will: + +1. Receive the task prompt with full autonomy +2. Execute the task using its available tools +3. Return a final result message +4. Terminate (subagents are stateless and single-use) + +Usage: + +``` +task(description="Brief task description", prompt="Detailed task instructions for the subagent", subagent_type="agent_name") +``` + +## Available Subagents + +The available subagents depend on your configuration. Common subagent types might include: + +- **general-purpose**: For complex multi-step tasks requiring various tools +- **code-reviewer**: For reviewing and analyzing code quality +- **test-runner**: For running tests and analyzing results +- **documentation-writer**: For creating and updating documentation + +You can view available subagents by using the `/agents` command in Qwen Code. + +## Task Tool Features + +### Real-time Progress Updates + +The Task tool provides live updates showing: + +- Subagent execution status +- Individual tool calls being made by the subagent +- Tool call results and any errors +- Overall task progress and completion status + +### Parallel Execution + +You can launch multiple subagents concurrently by calling the Task tool multiple times in a single message, allowing for parallel task execution and improved efficiency. + +### Specialized Expertise + +Each subagent can be configured with: + +- Specific tool access permissions +- Specialized system prompts and instructions +- Custom model configurations +- Domain-specific knowledge and capabilities + +## `task` examples + +### Delegating to a general-purpose agent + +``` +task( + description="Code refactoring", + prompt="Please refactor the authentication module in src/auth/ to use modern async/await patterns instead of callbacks. Ensure all tests still pass and update any related documentation.", + subagent_type="general-purpose" +) +``` + +### Running parallel tasks + +``` +# Launch code review and test execution in parallel +task( + description="Code review", + prompt="Review the recent changes in the user management module for code quality, security issues, and best practices compliance.", + subagent_type="code-reviewer" +) + +task( + description="Run tests", + prompt="Execute the full test suite and analyze any failures. Provide a summary of test coverage and recommendations for improvement.", + subagent_type="test-runner" +) +``` + +### Documentation generation + +``` +task( + description="Update docs", + prompt="Generate comprehensive API documentation for the newly implemented REST endpoints in the orders module. Include request/response examples and error codes.", + subagent_type="documentation-writer" +) +``` + +## When to Use the Task Tool + +Use the Task tool when: + +1. **Complex multi-step tasks** - Tasks requiring multiple operations that can be handled autonomously +2. **Specialized expertise** - Tasks that benefit from domain-specific knowledge or tools +3. **Parallel execution** - When you have multiple independent tasks that can run simultaneously +4. **Delegation needs** - When you want to hand off a complete task rather than micromanaging steps +5. **Resource-intensive operations** - Tasks that might take significant time or computational resources + +## When NOT to Use the Task Tool + +Don't use the Task tool for: + +- **Simple, single-step operations** - Use direct tools like Read, Edit, etc. +- **Interactive tasks** - Tasks requiring back-and-forth communication +- **Specific file reads** - Use Read tool directly for better performance +- **Simple searches** - Use Grep or Glob tools directly + +## Important Notes + +- **Stateless execution**: Each subagent invocation is independent with no memory of previous executions +- **Single communication**: Subagents provide one final result message - no ongoing communication +- **Comprehensive prompts**: Your prompt should contain all necessary context and instructions for autonomous execution +- **Tool access**: Subagents only have access to tools configured in their specific configuration +- **Parallel capability**: Multiple subagents can run simultaneously for improved efficiency +- **Configuration dependent**: Available subagent types depend on your system configuration + +## Configuration + +Subagents are configured through Qwen Code's agent configuration system. Use the `/agents` command to: + +- View available subagents +- Create new subagent configurations +- Modify existing subagent settings +- Set tool permissions and capabilities + +For more information on configuring subagents, refer to the subagents documentation. diff --git a/docs/tools/todo-write.md b/docs/developers/tools/todo-write.md similarity index 84% rename from docs/tools/todo-write.md rename to docs/developers/tools/todo-write.md index 5da90b121..157e36590 100644 --- a/docs/tools/todo-write.md +++ b/docs/developers/tools/todo-write.md @@ -11,9 +11,9 @@ Use `todo_write` to create and manage a structured task list for your current co `todo_write` takes one argument: - `todos` (array, required): An array of todo items, where each item contains: - - `id` (string, required): A unique identifier for the todo item. - `content` (string, required): The description of the task. - `status` (string, required): The current status (`pending`, `in_progress`, or `completed`). + - `activeForm` (string, required): The present continuous form describing what is being done (e.g., "Running tests", "Building the project"). ## How to use `todo_write` with Qwen Code @@ -39,19 +39,19 @@ Creating a feature implementation plan: ``` todo_write(todos=[ { - "id": "create-model", "content": "Create user preferences model", - "status": "pending" + "status": "pending", + "activeForm": "Creating user preferences model" }, { - "id": "add-endpoints", "content": "Add API endpoints for preferences", - "status": "pending" + "status": "pending", + "activeForm": "Adding API endpoints for preferences" }, { - "id": "implement-ui", "content": "Implement frontend components", - "status": "pending" + "status": "pending", + "activeForm": "Implementing frontend components" } ]) ``` diff --git a/docs/tools/web-fetch.md b/docs/developers/tools/web-fetch.md similarity index 100% rename from docs/tools/web-fetch.md rename to docs/developers/tools/web-fetch.md diff --git a/docs/tools/web-search.md b/docs/developers/tools/web-search.md similarity index 100% rename from docs/tools/web-search.md rename to docs/developers/tools/web-search.md diff --git a/docs/development/architecture.md b/docs/development/architecture.md deleted file mode 100644 index f970cba34..000000000 --- a/docs/development/architecture.md +++ /dev/null @@ -1,54 +0,0 @@ -# Qwen Code Architecture Overview - -This document provides a high-level overview of Qwen Code's architecture. - -## Core components - -Qwen Code is primarily composed of two main packages, along with a suite of tools that can be used by the system in the course of handling command-line input: - -1. **CLI package (`packages/cli`):** - - **Purpose:** This contains the user-facing portion of Qwen Code, such as handling the initial user input, presenting the final output, and managing the overall user experience. - - **Key functions contained in the package:** - - [Input processing](./cli/commands.md) - - History management - - Display rendering - - [Theme and UI customization](./cli/themes.md) - - [CLI configuration settings](./cli/configuration.md) - -2. **Core package (`packages/core`):** - - **Purpose:** This acts as the backend for Qwen Code. It receives requests sent from `packages/cli`, orchestrates interactions with the configured model API, and manages the execution of available tools. - - **Key functions contained in the package:** - - API client for communicating with the Google Gemini API - - Prompt construction and management - - Tool registration and execution logic - - State management for conversations or sessions - - Server-side configuration - -3. **Tools (`packages/core/src/tools/`):** - - **Purpose:** These are individual modules that extend the capabilities of the Gemini model, allowing it to interact with the local environment (e.g., file system, shell commands, web fetching). - - **Interaction:** `packages/core` invokes these tools based on requests from the Gemini model. - -## Interaction Flow - -A typical interaction with Qwen Code follows this flow: - -1. **User input:** The user types a prompt or command into the terminal, which is managed by `packages/cli`. -2. **Request to core:** `packages/cli` sends the user's input to `packages/core`. -3. **Request processed:** The core package: - - Constructs an appropriate prompt for the configured model API, possibly including conversation history and available tool definitions. - - Sends the prompt to the model API. -4. **Model API response:** The model API processes the prompt and returns a response. This response might be a direct answer or a request to use one of the available tools. -5. **Tool execution (if applicable):** - - When the model API requests a tool, the core package prepares to execute it. - - If the requested tool can modify the file system or execute shell commands, the user is first given details of the tool and its arguments, and the user must approve the execution. - - Read-only operations, such as reading files, might not require explicit user confirmation to proceed. - - Once confirmed, or if confirmation is not required, the core package executes the relevant action within the relevant tool, and the result is sent back to the model API by the core package. - - The model API processes the tool result and generates a final response. -6. **Response to CLI:** The core package sends the final response back to the CLI package. -7. **Display to user:** The CLI package formats and displays the response to the user in the terminal. - -## Key Design Principles - -- **Modularity:** Separating the CLI (frontend) from the Core (backend) allows for independent development and potential future extensions (e.g., different frontends for the same backend). -- **Extensibility:** The tool system is designed to be extensible, allowing new capabilities to be added. -- **User experience:** The CLI focuses on providing a rich and interactive terminal experience. diff --git a/docs/features/_meta.ts b/docs/features/_meta.ts deleted file mode 100644 index 7ad3361c4..000000000 --- a/docs/features/_meta.ts +++ /dev/null @@ -1,8 +0,0 @@ -export default { - subagents: 'Subagents', - checkpointing: 'Checkpointing', - sandbox: 'Sandbox Support', - headless: 'Headless Mode', - 'welcome-back': 'Welcome Back', - 'token-caching': 'Token Caching', -}; diff --git a/docs/features/sandbox.md b/docs/features/sandbox.md deleted file mode 100644 index f67ddae6c..000000000 --- a/docs/features/sandbox.md +++ /dev/null @@ -1,157 +0,0 @@ -# Sandboxing in Qwen Code - -This document provides a guide to sandboxing in Qwen Code, including prerequisites, quickstart, and configuration. - -## Prerequisites - -Before using sandboxing, you need to install and set up Qwen Code: - -```bash -npm install -g @qwen-code/qwen-code -``` - -To verify the installation - -```bash -qwen --version -``` - -## Overview of sandboxing - -Sandboxing isolates potentially dangerous operations (such as shell commands or file modifications) from your host system, providing a security barrier between AI operations and your environment. - -The benefits of sandboxing include: - -- **Security**: Prevent accidental system damage or data loss. -- **Isolation**: Limit file system access to project directory. -- **Consistency**: Ensure reproducible environments across different systems. -- **Safety**: Reduce risk when working with untrusted code or experimental commands. - -## Sandboxing methods - -Your ideal method of sandboxing may differ depending on your platform and your preferred container solution. - -### 1. macOS Seatbelt (macOS only) - -Lightweight, built-in sandboxing using `sandbox-exec`. - -**Default profile**: `permissive-open` - restricts writes outside project directory but allows most other operations. - -### 2. Container-based (Docker/Podman) - -Cross-platform sandboxing with complete process isolation. - -**Note**: Requires building the sandbox image locally or using a published image from your organization's registry. - -## Quickstart - -```bash -# Enable sandboxing with command flag -qwen -s -p "analyze the code structure" - -# Use environment variable -export GEMINI_SANDBOX=true -qwen -p "run the test suite" - -# Configure in settings.json -{ - "tools": { - "sandbox": "docker" - } -} -``` - -## Configuration - -### Enable sandboxing (in order of precedence) - -1. **Command flag**: `-s` or `--sandbox` -2. **Environment variable**: `GEMINI_SANDBOX=true|docker|podman|sandbox-exec` -3. **Settings file**: `"sandbox": true` in the `tools` object of your `settings.json` file (e.g., `{"tools": {"sandbox": true}}`). - -### macOS Seatbelt profiles - -Built-in profiles (set via `SEATBELT_PROFILE` env var): - -- `permissive-open` (default): Write restrictions, network allowed -- `permissive-closed`: Write restrictions, no network -- `permissive-proxied`: Write restrictions, network via proxy -- `restrictive-open`: Strict restrictions, network allowed -- `restrictive-closed`: Maximum restrictions - -### Custom Sandbox Flags - -For container-based sandboxing, you can inject custom flags into the `docker` or `podman` command using the `SANDBOX_FLAGS` environment variable. This is useful for advanced configurations, such as disabling security features for specific use cases. - -**Example (Podman)**: - -To disable SELinux labeling for volume mounts, you can set the following: - -```bash -export SANDBOX_FLAGS="--security-opt label=disable" -``` - -Multiple flags can be provided as a space-separated string: - -```bash -export SANDBOX_FLAGS="--flag1 --flag2=value" -``` - -## Linux UID/GID handling - -The sandbox automatically handles user permissions on Linux. Override these permissions with: - -```bash -export SANDBOX_SET_UID_GID=true # Force host UID/GID -export SANDBOX_SET_UID_GID=false # Disable UID/GID mapping -``` - -## Troubleshooting - -### Common issues - -**"Operation not permitted"** - -- Operation requires access outside sandbox. -- Try more permissive profile or add mount points. - -**Missing commands** - -- Add to custom Dockerfile. -- Install via `sandbox.bashrc`. - -**Network issues** - -- Check sandbox profile allows network. -- Verify proxy configuration. - -### Debug mode - -```bash -DEBUG=1 qwen -s -p "debug command" -``` - -**Note:** If you have `DEBUG=true` in a project's `.env` file, it won't affect the CLI due to automatic exclusion. Use `.qwen/.env` files for Qwen Code-specific debug settings. - -### Inspect sandbox - -```bash -# Check environment -qwen -s -p "run shell command: env | grep SANDBOX" - -# List mounts -qwen -s -p "run shell command: mount | grep workspace" -``` - -## Security notes - -- Sandboxing reduces but doesn't eliminate all risks. -- Use the most restrictive profile that allows your work. -- Container overhead is minimal after first build. -- GUI applications may not work in sandboxes. - -## Related documentation - -- [Configuration](./cli/configuration.md): Full configuration options. -- [Commands](./cli/commands.md): Available commands. -- [Troubleshooting](./troubleshooting.md): General troubleshooting. diff --git a/docs/features/token-caching.md b/docs/features/token-caching.md deleted file mode 100644 index 8d6dc5ec8..000000000 --- a/docs/features/token-caching.md +++ /dev/null @@ -1,14 +0,0 @@ -# Token Caching and Cost Optimization - -Qwen Code automatically optimizes API costs through token caching when using API key authentication (e.g., OpenAI-compatible providers). This feature reuses previous system instructions and context to reduce the number of tokens processed in subsequent requests. - -**Token caching is available for:** - -- API key users (Qwen API key) -- Vertex AI users (with project and location setup) - -**Token caching is not available for:** - -- OAuth users (Google Personal/Enterprise accounts) - the Code Assist API does not support cached content creation at this time - -You can view your token usage and cached token savings using the `/stats` command. When cached tokens are available, they will be displayed in the stats output. diff --git a/docs/features/welcome-back.md b/docs/features/welcome-back.md deleted file mode 100644 index 1ce552ee4..000000000 --- a/docs/features/welcome-back.md +++ /dev/null @@ -1,125 +0,0 @@ -# Welcome Back Feature - -The Welcome Back feature helps you seamlessly resume your work by automatically detecting when you return to a project with existing conversation history and offering to continue from where you left off. - -## Overview - -When you start Qwen Code in a project directory that contains a previously generated project summary (`.qwen/PROJECT_SUMMARY.md`), the Welcome Back dialog will automatically appear, giving you the option to either start fresh or continue your previous conversation. - -## How It Works - -### Automatic Detection - -The Welcome Back feature automatically detects: - -- **Project Summary File:** Looks for `.qwen/PROJECT_SUMMARY.md` in your current project directory -- **Conversation History:** Checks if there's meaningful conversation history to resume -- **Settings:** Respects your `enableWelcomeBack` setting (enabled by default) - -### Welcome Back Dialog - -When a project summary is found, you'll see a dialog with: - -- **Last Updated Time:** Shows when the summary was last generated -- **Overall Goal:** Displays the main objective from your previous session -- **Current Plan:** Shows task progress with status indicators: - - `[DONE]` - Completed tasks - - `[IN PROGRESS]` - Currently working on - - `[TODO]` - Planned tasks -- **Task Statistics:** Summary of total tasks, completed, in progress, and pending - -### Options - -You have two choices when the Welcome Back dialog appears: - -1. **Start new chat session** - - Closes the dialog and begins a fresh conversation - - No previous context is loaded - -2. **Continue previous conversation** - - Automatically fills the input with: `@.qwen/PROJECT_SUMMARY.md, Based on our previous conversation, Let's continue?` - - Loads the project summary as context for the AI - - Allows you to seamlessly pick up where you left off - -## Configuration - -### Enable/Disable Welcome Back - -You can control the Welcome Back feature through settings: - -**Via Settings Dialog:** - -1. Run `/settings` in Qwen Code -2. Find "Enable Welcome Back" in the UI category -3. Toggle the setting on/off - -**Via Settings File:** -Add to your `.qwen/settings.json`: - -```json -{ - "enableWelcomeBack": true -} -``` - -**Settings Locations:** - -- **User settings:** `~/.qwen/settings.json` (affects all projects) -- **Project settings:** `.qwen/settings.json` (project-specific) - -### Keyboard Shortcuts - -- **Escape:** Close the Welcome Back dialog (defaults to "Start new chat session") - -## Integration with Other Features - -### Project Summary Generation - -The Welcome Back feature works seamlessly with the `/summary` command: - -1. **Generate Summary:** Use `/summary` to create a project summary -2. **Automatic Detection:** Next time you start Qwen Code in this project, Welcome Back will detect the summary -3. **Resume Work:** Choose to continue and the summary will be loaded as context - -## File Structure - -The Welcome Back feature creates and uses: - -``` -your-project/ -├── .qwen/ -│ └── PROJECT_SUMMARY.md # Generated project summary -``` - -### PROJECT_SUMMARY.md Format - -The generated summary follows this structure: - -```markdown -# Project Summary - -## Overall Goal - - - -## Key Knowledge - - - - -## Recent Actions - - - - -## Current Plan - - - - ---- - -## Summary Metadata - -**Update time**: 2025-01-10T15:30:00.000Z -``` diff --git a/docs/index.md b/docs/index.md index 07fc1db6e..ff8a48037 100644 --- a/docs/index.md +++ b/docs/index.md @@ -1,344 +1,25 @@ -# Welcome to Qwen Code documentation +# Qwen Code Documentation -Qwen Code is a powerful command-line AI workflow tool adapted from [**Gemini CLI**](https://github.com/google-gemini/gemini-cli) ([details](./README.gemini.md)), specifically optimized for [Qwen3-Coder](https://github.com/QwenLM/Qwen3-Coder) models. It enhances your development workflow with advanced code understanding, automated tasks, and intelligent assistance. +Welcome to the Qwen Code documentation. Qwen Code is an agentic coding tool that lives in your terminal and helps you turn ideas into code faster than ever before. -## 🚀 Why Choose Qwen Code? +## Documentation Sections -- 🎯 **Free Tier:** Up to 60 requests/min and 2,000 requests/day with your [QwenChat](https://chat.qwen.ai/) account. -- 🧠 **Advanced Model:** Specially optimized for [Qwen3-Coder](https://github.com/QwenLM/Qwen3-Coder) for superior code understanding and assistance. -- 🏆 **Comprehensive Features:** Includes subagents, Plan Mode, TodoWrite, vision model support, and full OpenAI API compatibility—all seamlessly integrated. -- 🔧 **Built-in & Extensible Tools:** Includes file system operations, shell command execution, web fetch/search, and more—all easily extended via the Model Context Protocol (MCP) for custom integrations. -- 💻 **Developer-Centric:** Built for terminal-first workflows—perfect for command-line enthusiasts. -- 🛡️ **Open Source:** Apache 2.0 licensed for maximum freedom and transparency. +### [User Guide](./users/overview) -## Installation +Learn how to use Qwen Code as an end user. This section covers: -### Prerequisites +- Basic installation and setup +- Common usage patterns +- Features and capabilities +- Configuration options +- Troubleshooting -Ensure you have [Node.js version 20](https://nodejs.org/en/download) or higher installed. +### [Developer Guide](./developers/architecture) -```bash -curl -qL https://www.npmjs.com/install.sh | sh -``` +Learn how to contribute to and develop Qwen Code. This section covers: -### Install from npm - -```bash -npm install -g @qwen-code/qwen-code@latest -qwen --version -``` - -### Install from source - -```bash -git clone https://github.com/QwenLM/qwen-code.git -cd qwen-code -npm install -npm install -g . -``` - -### Install globally with Homebrew (macOS/Linux) - -```bash -brew install qwen-code -``` - -## Quick Start - -```bash -# Start Qwen Code -qwen - -# Example commands -> Explain this codebase structure -> Help me refactor this function -> Generate unit tests for this module -``` - -### Session Management - -Control your token usage with configurable session limits to optimize costs and performance. - -#### Configure Session Token Limit - -Create or edit `.qwen/settings.json` in your home directory: - -```json -{ - "sessionTokenLimit": 32000 -} -``` - -#### Session Commands - -- **`/compress`** - Compress conversation history to continue within token limits -- **`/clear`** (aliases: `/reset`, `/new`) - Clear conversation history, start a fresh session, and free up context -- **`/stats`** - Check current token usage and limits - -> 📝 **Note**: Session token limit applies to a single conversation, not cumulative API calls. - -### Vision Model Configuration - -Qwen Code includes intelligent vision model auto-switching that detects images in your input and can automatically switch to vision-capable models for multimodal analysis. **This feature is enabled by default** - when you include images in your queries, you'll see a dialog asking how you'd like to handle the vision model switch. - -#### Skip the Switch Dialog (Optional) - -If you don't want to see the interactive dialog each time, configure the default behavior in your `.qwen/settings.json`: - -```json -{ - "experimental": { - "vlmSwitchMode": "once" - } -} -``` - -**Available modes:** - -- **`"once"`** - Switch to vision model for this query only, then revert -- **`"session"`** - Switch to vision model for the entire session -- **`"persist"`** - Continue with current model (no switching) -- **Not set** - Show interactive dialog each time (default) - -#### Command Line Override - -You can also set the behavior via command line: - -```bash -# Switch once per query -qwen --vlm-switch-mode once - -# Switch for entire session -qwen --vlm-switch-mode session - -# Never switch automatically -qwen --vlm-switch-mode persist -``` - -#### Disable Vision Models (Optional) - -To completely disable vision model support, add to your `.qwen/settings.json`: - -```json -{ - "experimental": { - "visionModelPreview": false - } -} -``` - -> 💡 **Tip**: In YOLO mode (`--yolo`), vision switching happens automatically without prompts when images are detected. - -### Authorization - -Choose your preferred authentication method based on your needs: - -#### 1. Qwen OAuth (🚀 Recommended - Start in 30 seconds) - -The easiest way to get started - completely free with generous quotas: - -```bash -# Just run this command and follow the browser authentication -qwen -``` - -**What happens:** - -1. **Instant Setup**: CLI opens your browser automatically -2. **One-Click Login**: Authenticate with your qwen.ai account -3. **Automatic Management**: Credentials cached locally for future use -4. **No Configuration**: Zero setup required - just start coding! - -**Free Tier Benefits:** - -- ✅ **2,000 requests/day** (no token counting needed) -- ✅ **60 requests/minute** rate limit -- ✅ **Automatic credential refresh** -- ✅ **Zero cost** for individual users -- ℹ️ **Note**: Model fallback may occur to maintain service quality - -#### 2. OpenAI-Compatible API - -Use API keys for OpenAI or other compatible providers: - -**Configuration Methods:** - -1. **Environment Variables** - - ```bash - export OPENAI_API_KEY="your_api_key_here" - export OPENAI_BASE_URL="your_api_endpoint" - export OPENAI_MODEL="your_model_choice" - ``` - -2. **Project `.env` File** - Create a `.env` file in your project root: - ```env - OPENAI_API_KEY=your_api_key_here - OPENAI_BASE_URL=your_api_endpoint - OPENAI_MODEL=your_model_choice - ``` - -**API Provider Options** - -> ⚠️ **Regional Notice:** -> -> - **Mainland China**: Use Alibaba Cloud Bailian or ModelScope -> - **International**: Use Alibaba Cloud ModelStudio or OpenRouter - -
-🇨🇳 For Users in Mainland China - -**Option 1: Alibaba Cloud Bailian** ([Apply for API Key](https://bailian.console.aliyun.com/)) - -```bash -export OPENAI_API_KEY="your_api_key_here" -export OPENAI_BASE_URL="https://dashscope.aliyuncs.com/compatible-mode/v1" -export OPENAI_MODEL="qwen3-coder-plus" -``` - -**Option 2: ModelScope (Free Tier)** ([Apply for API Key](https://modelscope.cn/docs/model-service/API-Inference/intro)) - -- ✅ **2,000 free API calls per day** -- ⚠️ Connect your Aliyun account to avoid authentication errors - -```bash -export OPENAI_API_KEY="your_api_key_here" -export OPENAI_BASE_URL="https://api-inference.modelscope.cn/v1" -export OPENAI_MODEL="Qwen/Qwen3-Coder-480B-A35B-Instruct" -``` - -
- -
-🌍 For International Users - -**Option 1: Alibaba Cloud ModelStudio** ([Apply for API Key](https://modelstudio.console.alibabacloud.com/)) - -```bash -export OPENAI_API_KEY="your_api_key_here" -export OPENAI_BASE_URL="https://dashscope-intl.aliyuncs.com/compatible-mode/v1" -export OPENAI_MODEL="qwen3-coder-plus" -``` - -**Option 2: OpenRouter (Free Tier Available)** ([Apply for API Key](https://openrouter.ai/)) - -```bash -export OPENAI_API_KEY="your_api_key_here" -export OPENAI_BASE_URL="https://openrouter.ai/api/v1" -export OPENAI_MODEL="qwen/qwen3-coder:free" -``` - -
- -## Usage Examples - -### 🔍 Explore Codebases - -```bash -cd your-project/ -qwen - -# Architecture analysis -> Describe the main pieces of this system's architecture -> What are the key dependencies and how do they interact? -> Find all API endpoints and their authentication methods -``` - -### 💻 Code Development - -```bash -# Refactoring -> Refactor this function to improve readability and performance -> Convert this class to use dependency injection -> Split this large module into smaller, focused components - -# Code generation -> Create a REST API endpoint for user management -> Generate unit tests for the authentication module -> Add error handling to all database operations -``` - -### 🔄 Automate Workflows - -```bash -# Git automation -> Analyze git commits from the last 7 days, grouped by feature -> Create a changelog from recent commits -> Find all TODO comments and create GitHub issues - -# File operations -> Convert all images in this directory to PNG format -> Rename all test files to follow the *.test.ts pattern -> Find and remove all console.log statements -``` - -### 🐛 Debugging & Analysis - -```bash -# Performance analysis -> Identify performance bottlenecks in this React component -> Find all N+1 query problems in the codebase - -# Security audit -> Check for potential SQL injection vulnerabilities -> Find all hardcoded credentials or API keys -``` - -## Popular Tasks - -### 📚 Understand New Codebases - -```text -> What are the core business logic components? -> What security mechanisms are in place? -> How does the data flow through the system? -> What are the main design patterns used? -> Generate a dependency graph for this module -``` - -### 🔨 Code Refactoring & Optimization - -```text -> What parts of this module can be optimized? -> Help me refactor this class to follow SOLID principles -> Add proper error handling and logging -> Convert callbacks to async/await pattern -> Implement caching for expensive operations -``` - -### 📝 Documentation & Testing - -```text -> Generate comprehensive JSDoc comments for all public APIs -> Write unit tests with edge cases for this component -> Create API documentation in OpenAPI format -> Add inline comments explaining complex algorithms -> Generate a README for this module -``` - -### 🚀 Development Acceleration - -```text -> Set up a new Express server with authentication -> Create a React component with TypeScript and tests -> Implement a rate limiter middleware -> Add database migrations for new schema -> Configure CI/CD pipeline for this project -``` - -## Commands & Shortcuts - -### Session Commands - -- `/help` - Display available commands -- `/clear` (aliases: `/reset`, `/new`) - Clear conversation history and start a fresh session -- `/compress` - Compress history to save tokens -- `/stats` - Show current session information -- `/exit` or `/quit` - Exit Qwen Code - -### Keyboard Shortcuts - -- `Ctrl+C` - Cancel current operation -- `Ctrl+D` - Exit (on empty line) -- `Up/Down` - Navigate command history +- Architecture overview +- Contributing guidelines +- Core concepts and implementation details +- Tools and development workflow +- Extension and plugin development diff --git a/docs/mermaid/context.mmd b/docs/mermaid/context.mmd deleted file mode 100644 index ebe4fbee1..000000000 --- a/docs/mermaid/context.mmd +++ /dev/null @@ -1,103 +0,0 @@ -graph LR - %% --- Style Definitions --- - classDef new fill:#98fb98,color:#000 - classDef changed fill:#add8e6,color:#000 - classDef unchanged fill:#f0f0f0,color:#000 - - %% --- Subgraphs --- - subgraph "Context Providers" - direction TB - A["gemini.tsx"] - B["AppContainer.tsx"] - end - - subgraph "Contexts" - direction TB - CtxSession["SessionContext"] - CtxVim["VimModeContext"] - CtxSettings["SettingsContext"] - CtxApp["AppContext"] - CtxConfig["ConfigContext"] - CtxUIState["UIStateContext"] - CtxUIActions["UIActionsContext"] - end - - subgraph "Component Consumers" - direction TB - ConsumerApp["App"] - ConsumerAppContainer["AppContainer"] - ConsumerAppHeader["AppHeader"] - ConsumerDialogManager["DialogManager"] - ConsumerHistoryItem["HistoryItemDisplay"] - ConsumerComposer["Composer"] - ConsumerMainContent["MainContent"] - ConsumerNotifications["Notifications"] - end - - %% --- Provider -> Context Connections --- - A -.-> CtxSession - A -.-> CtxVim - A -.-> CtxSettings - - B -.-> CtxApp - B -.-> CtxConfig - B -.-> CtxUIState - B -.-> CtxUIActions - B -.-> CtxSettings - - %% --- Context -> Consumer Connections --- - CtxSession -.-> ConsumerAppContainer - CtxSession -.-> ConsumerApp - - CtxVim -.-> ConsumerAppContainer - CtxVim -.-> ConsumerComposer - CtxVim -.-> ConsumerApp - - CtxSettings -.-> ConsumerAppContainer - CtxSettings -.-> ConsumerAppHeader - CtxSettings -.-> ConsumerDialogManager - CtxSettings -.-> ConsumerApp - - CtxApp -.-> ConsumerAppHeader - CtxApp -.-> ConsumerNotifications - - CtxConfig -.-> ConsumerAppHeader - CtxConfig -.-> ConsumerHistoryItem - CtxConfig -.-> ConsumerComposer - CtxConfig -.-> ConsumerDialogManager - - - - CtxUIState -.-> ConsumerApp - CtxUIState -.-> ConsumerMainContent - CtxUIState -.-> ConsumerComposer - CtxUIState -.-> ConsumerDialogManager - - CtxUIActions -.-> ConsumerComposer - CtxUIActions -.-> ConsumerDialogManager - - %% --- Apply Styles --- - %% New Elements (Green) - class B,CtxApp,CtxConfig,CtxUIState,CtxUIActions,ConsumerAppHeader,ConsumerDialogManager,ConsumerComposer,ConsumerMainContent,ConsumerNotifications new - - %% Heavily Changed Elements (Blue) - class A,ConsumerApp,ConsumerAppContainer,ConsumerHistoryItem changed - - %% Mostly Unchanged Elements (Gray) - class CtxSession,CtxVim,CtxSettings unchanged - - %% --- Link Styles --- - %% CtxSession (Red) - linkStyle 0,8,9 stroke:#e57373,stroke-width:2px - %% CtxVim (Orange) - linkStyle 1,10,11,12 stroke:#ffb74d,stroke-width:2px - %% CtxSettings (Yellow) - linkStyle 2,7,13,14,15,16 stroke:#fff176,stroke-width:2px - %% CtxApp (Green) - linkStyle 3,17,18 stroke:#81c784,stroke-width:2px - %% CtxConfig (Blue) - linkStyle 4,19,20,21,22 stroke:#64b5f6,stroke-width:2px - %% CtxUIState (Indigo) - linkStyle 5,23,24,25,26 stroke:#7986cb,stroke-width:2px - %% CtxUIActions (Violet) - linkStyle 6,27,28 stroke:#ba68c8,stroke-width:2px diff --git a/docs/mermaid/render-path.mmd b/docs/mermaid/render-path.mmd deleted file mode 100644 index 5f4c62044..000000000 --- a/docs/mermaid/render-path.mmd +++ /dev/null @@ -1,64 +0,0 @@ -graph TD - %% --- Style Definitions --- - classDef new fill:#98fb98,color:#000 - classDef changed fill:#add8e6,color:#000 - classDef unchanged fill:#f0f0f0,color:#000 - classDef dispatcher fill:#f9e79f,color:#000,stroke:#333,stroke-width:1px - classDef container fill:#f5f5f5,color:#000,stroke:#ccc - - %% --- Component Tree --- - subgraph "Entry Point" - A["gemini.tsx"] - end - - subgraph "State & Logic Wrapper" - B["AppContainer.tsx"] - end - - subgraph "Primary Layout" - C["App.tsx"] - end - - A -.-> B - B -.-> C - - subgraph "UI Containers" - direction LR - C -.-> D["MainContent"] - C -.-> G["Composer"] - C -.-> F["DialogManager"] - C -.-> E["Notifications"] - end - - subgraph "MainContent" - direction TB - D -.-> H["AppHeader"] - D -.-> I["HistoryItemDisplay"]:::dispatcher - D -.-> L["ShowMoreLines"] - end - - subgraph "Composer" - direction TB - G -.-> K_Prompt["InputPrompt"] - G -.-> K_Footer["Footer"] - end - - subgraph "DialogManager" - F -.-> J["Various Dialogs
(Auth, Theme, Settings, etc.)"] - end - - %% --- Apply Styles --- - class B,D,E,F,G,H,J,K_Prompt,L new - class A,C,I changed - class K_Footer unchanged - - %% --- Link Styles --- - %% MainContent Branch (Blue) - linkStyle 2,6,7,8 stroke:#64b5f6,stroke-width:2px - %% Composer Branch (Green) - linkStyle 3,9,10 stroke:#81c784,stroke-width:2px - %% DialogManager Branch (Orange) - linkStyle 4,11 stroke:#ffb74d,stroke-width:2px - %% Notifications Branch (Violet) - linkStyle 5 stroke:#ba68c8,stroke-width:2px - diff --git a/docs/sidebar.json b/docs/sidebar.json deleted file mode 100644 index b4a750529..000000000 --- a/docs/sidebar.json +++ /dev/null @@ -1,68 +0,0 @@ -[ - { - "label": "Overview", - "items": [ - { "label": "Welcome", "slug": "docs" }, - { "label": "Execution and Deployment", "slug": "docs/deployment" }, - { "label": "Architecture Overview", "slug": "docs/architecture" } - ] - }, - { - "label": "CLI", - "items": [ - { "label": "Introduction", "slug": "docs/cli" }, - { "label": "Authentication", "slug": "docs/cli/authentication" }, - { "label": "Commands", "slug": "docs/cli/commands" }, - { "label": "Configuration", "slug": "docs/cli/configuration" }, - { "label": "Checkpointing", "slug": "docs/checkpointing" }, - { "label": "Extensions", "slug": "docs/extension" }, - { "label": "Headless Mode", "slug": "docs/headless" }, - { "label": "IDE Integration", "slug": "docs/ide-integration" }, - { - "label": "IDE Companion Spec", - "slug": "docs/ide-companion-spec" - }, - { "label": "Telemetry", "slug": "docs/telemetry" }, - { "label": "Themes", "slug": "docs/cli/themes" }, - { "label": "Token Caching", "slug": "docs/cli/token-caching" }, - { "label": "Trusted Folders", "slug": "docs/trusted-folders" }, - { "label": "Tutorials", "slug": "docs/cli/tutorials" } - ] - }, - { - "label": "Core", - "items": [ - { "label": "Introduction", "slug": "docs/core" }, - { "label": "Tools API", "slug": "docs/core/tools-api" }, - { "label": "Memory Import Processor", "slug": "docs/core/memport" } - ] - }, - { - "label": "Tools", - "items": [ - { "label": "Overview", "slug": "docs/tools" }, - { "label": "File System", "slug": "docs/tools/file-system" }, - { "label": "Multi-File Read", "slug": "docs/tools/multi-file" }, - { "label": "Shell", "slug": "docs/tools/shell" }, - { "label": "Web Fetch", "slug": "docs/tools/web-fetch" }, - { "label": "Web Search", "slug": "docs/tools/web-search" }, - { "label": "Memory", "slug": "docs/tools/memory" }, - { "label": "MCP Servers", "slug": "docs/tools/mcp-server" }, - { "label": "Sandboxing", "slug": "docs/sandbox" } - ] - }, - { - "label": "Development", - "items": [ - { "label": "NPM", "slug": "docs/npm" }, - { "label": "Releases", "slug": "docs/releases" } - ] - }, - { - "label": "Support", - "items": [ - { "label": "Troubleshooting", "slug": "docs/troubleshooting" }, - { "label": "Terms of Service", "slug": "docs/tos-privacy" } - ] - } -] diff --git a/docs/users/_meta.ts b/docs/users/_meta.ts new file mode 100644 index 000000000..a44167cff --- /dev/null +++ b/docs/users/_meta.ts @@ -0,0 +1,28 @@ +export default { + 'Getting started': { + type: 'separator', + title: 'Getting started', // Title is optional + }, + overview: 'Overview', + quickstart: 'QuickStart', + 'common-workflow': 'Command Workflows', + 'Outside of the terminal': { + type: 'separator', + title: 'Outside of the terminal', // Title is optional + }, + 'integration-vscode': 'Visual Studio Code', + 'integration-zed': 'Zed IDE', + 'integration-github-action': 'Github Actions', + 'Code with Qwen Code': { + type: 'separator', + title: 'Code with Qwen Code', // Title is optional + }, + features: 'Features', + configuration: 'Configuration', + reference: 'Reference', + support: 'Support', + // need refine + 'ide-integration': { + display: 'hidden', + }, +}; diff --git a/docs/users/common-workflow.md b/docs/users/common-workflow.md new file mode 100644 index 000000000..078447cf1 --- /dev/null +++ b/docs/users/common-workflow.md @@ -0,0 +1,571 @@ +# Common workflows + +> Learn about common workflows with Qwen Code. + +Each task in this document includes clear instructions, example commands, and best practices to help you get the most from Qwen Code. + +## Understand new codebases + +### Get a quick codebase overview + +Suppose you've just joined a new project and need to understand its structure quickly. + +**1. Navigate to the project root directory** + +```bash +cd /path/to/project +``` + +**2. Start Qwen Code** + +```bash +qwen +``` + +**3. Ask for a high-level overview** + +``` +give me an overview of this codebase +``` + +**4. Dive deeper into specific components** + +``` +explain the main architecture patterns used here +``` + +``` +what are the key data models? +``` + +``` +how is authentication handled? +``` + +> [!tip] +> +> - Start with broad questions, then narrow down to specific areas +> - Ask about coding conventions and patterns used in the project +> - Request a glossary of project-specific terms + +### Find relevant code + +Suppose you need to locate code related to a specific feature or functionality. + +**1. Ask Qwen Code to find relevant files** + +``` +find the files that handle user authentication +``` + +**2. Get context on how components interact** + +``` +how do these authentication files work together? +``` + +**3. Understand the execution flow** + +``` +trace the login process from front-end to database +``` + +> [!tip] +> +> - Be specific about what you're looking for +> - Use domain language from the project + +## Fix bugs efficiently + +Suppose you've encountered an error message and need to find and fix its source. + +**1. Share the error with Qwen Code** + +``` +I'm seeing an error when I run npm test +``` + +**2. Ask for fix recommendations** + +``` +suggest a few ways to fix the @ts-ignore in user.ts +``` + +**3. Apply the fix** + +``` +update user.tsto add the null check you suggested +``` + +> [!tip] +> +> - Tell Qwen Code the command to reproduce the issue and get a stack trace +> - Mention any steps to reproduce the error +> - Let Qwen Code know if the error is intermittent or consistent + +## Refactor code + +Suppose you need to update old code to use modern patterns and practices. + +**1. Identify legacy code for refactoring** + +``` +find deprecated API usage in our codebase +``` + +**2. Get refactoring recommendations** + +``` +suggest how to refactor utils.js to use modern JavaScript features +``` + +**3. Apply the changes safely** + +``` +refactor utils.js to use ES 2024 features while maintaining the same behavior +``` + +**4. Verify the refactoring** + +``` +run tests for the refactored code +``` + +> [!tip] +> +> - Ask Qwen Code to explain the benefits of the modern approach +> - Request that changes maintain backward compatibility when needed +> - Do refactoring in small, testable increments + +## Use specialized subagents + +Suppose you want to use specialized AI subagents to handle specific tasks more effectively. + +**1. View available subagents** + +``` +/agents +``` + +This shows all available subagents and lets you create new ones. + +**2. Use subagents automatically** + +Qwen Code automatically delegates appropriate tasks to specialized subagents: + +``` +review my recent code changes for security issues +``` + +``` +run all tests and fix any failures +``` + +**3. Explicitly request specific subagents** + +``` +use the code-reviewer subagent to check the auth module +``` + +``` +have the debugger subagent investigate why users can't log in +``` + +**4. Create custom subagents for your workflow** + +``` +/agents +``` + +Then select "create" and follow the prompts to define: + +- A unique identifier that describes the subagent's purpose (for example, `code-reviewer`, `api-designer`). +- When Qwen Code should use this agent +- Which tools it can access +- A system prompt describing the agent's role and behavior + +> [!tip] +> +> - Create project-specific subagents in `.qwen/agents/` for team sharing +> - Use descriptive `description` fields to enable automatic delegation +> - Limit tool access to what each subagent actually needs +> - Know more about [Sub Agents](./features/sub-agents) +> - Know more about [Approval Mode](./features/approval-mode) + +## Work with tests + +Suppose you need to add tests for uncovered code. + +**1. Identify untested code** + +``` +find functions in NotificationsService.swift that are not covered by tests +``` + +**2. Generate test scaffolding** + +``` +add tests for the notification service +``` + +**3. Add meaningful test cases** + +``` +add test cases for edge conditions in the notification service +``` + +**4. Run and verify tests** + +``` +run the new tests and fix any failures +``` + +Qwen Code can generate tests that follow your project's existing patterns and conventions. When asking for tests, be specific about what behavior you want to verify. Qwen Code examines your existing test files to match the style, frameworks, and assertion patterns already in use. + +For comprehensive coverage, ask Qwen Code to identify edge cases you might have missed. Qwen Code can analyze your code paths and suggest tests for error conditions, boundary values, and unexpected inputs that are easy to overlook. + +## Create pull requests + +Suppose you need to create a well-documented pull request for your changes. + +**1. Summarize your changes** + +``` +summarize the changes I've made to the authentication module +``` + +**2. Generate a pull request with Qwen Code** + +``` +create a pr +``` + +**3. Review and refine** + +``` +enhance the PR description with more context about the security improvements +``` + +**4. Add testing details** + +``` +add information about how these changes were tested +``` + +> [!tip] +> +> - Ask Qwen Code directly to make a PR for you +> - Review Qwen Code's generated PR before submitting +> - Ask Qwen Code to highlight potential risks or considerations + +## Handle documentation + +Suppose you need to add or update documentation for your code. + +**1. Identify undocumented code** + +``` +find functions without proper JSDoc comments in the auth module +``` + +**2. Generate documentation** + +``` +add JSDoc comments to the undocumented functions in auth.js +``` + +**3. Review and enhance** + +``` +improve the generated documentation with more context and examples +``` + +**4. Verify documentation** + +``` +check if the documentation follows our project standards +``` + +> [!tip] +> +> - Specify the documentation style you want (JSDoc, docstrings, etc.) +> - Ask for examples in the documentation +> - Request documentation for public APIs, interfaces, and complex logic + +## Reference files and directories + +Use `@` to quickly include files or directories without waiting for Qwen Code to read them. + +**1. Reference a single file** + +``` +Explain the logic in @src/utils/auth.js +``` + +This includes the full content of the file in the conversation. + +**2. Reference a directory** + +``` +What's the structure of @src/components? +``` + +This provides a directory listing with file information. + +**3. Reference MCP resources** + +``` +Show me the data from @github: repos/owner/repo/issues +``` + +This fetches data from connected MCP servers using the format @server: resource. See [MCP](./features/mcp) for details. + +> [!tip] +> +> - File paths can be relative or absolute +> - @ file references add `QWEN.md` in the file's directory and parent directories to context +> - Directory references show file listings, not contents +> - You can reference multiple files in a single message (for example, "`@file 1.js` and `@file 2.js`") + +## Resume previous conversations + +Suppose you've been working on a task with Qwen Code and need to continue where you left off in a later session. + +Qwen Code provides two options for resuming previous conversations: + +- `--continue` to automatically continue the most recent conversation +- `--resume` to display a conversation picker + +**1. Continue the most recent conversation** + +```bash +qwen --continue +``` + +This immediately resumes your most recent conversation without any prompts. + +**2. Continue in non-interactive mode** + +```bash +qwen --continue --p "Continue with my task" +``` + +Use `--print` with `--continue` to resume the most recent conversation in non-interactive mode, perfect for scripts or automation. + +**3. Show conversation picker** + +```bash +qwen --resume +``` + +This displays an interactive conversation selector with a clean list view showing: + +- Session summary (or initial prompt) +- Metadata: time elapsed, message count, and git branch + +Use arrow keys to navigate and press Enter to select a conversation. Press Esc to exit. + +> [!tip] +> +> - Conversation history is stored locally on your machine +> - Use `--continue` for quick access to your most recent conversation +> - Use `--resume` when you need to select a specific past conversation +> - When resuming, you'll see the entire conversation history before continuing +> - The resumed conversation starts with the same model and configuration as the original +> +> **How it works**: +> +> 1. **Conversation Storage**: All conversations are automatically saved locally with their full message history +> 2. **Message Deserialization**: When resuming, the entire message history is restored to maintain context +> 3. **Tool State**: Tool usage and results from the previous conversation are preserved +> 4. **Context Restoration**: The conversation resumes with all previous context intact +> +> **Examples**: +> +> ```bash +> # Continue most recent conversation +> qwen --continue +> +> # Continue most recent conversation with a specific prompt +> qwen --continue --p "Show me our progress" +> +> # Show conversation picker +> qwen --resume +> +> # Continue most recent conversation in non-interactive mode +> qwen --continue --p "Run the tests again" +> ``` + +## Run parallel Qwen Code sessions with Git worktrees + +Suppose you need to work on multiple tasks simultaneously with complete code isolation between Qwen Code instances. + +**1. Understand Git worktrees** + +Git worktrees allow you to check out multiple branches from the same repository into separate directories. Each worktree has its own working directory with isolated files, while sharing the same Git history. Learn more in the [official Git worktree documentation](https://git-scm.com/docs/git-worktree). + +**2. Create a new worktree** + +```bash +# Create a new worktree with a new branch +git worktree add ../project-feature-a -b feature-a + +# Or create a worktree with an existing branch +git worktree add ../project-bugfix bugfix-123 +``` + +This creates a new directory with a separate working copy of your repository. + +**3. Run Qwen Code in each worktree** + +```bash +# Navigate to your worktree +cd ../project-feature-a + +# Run Qwen Code in this isolated environment +qwen +``` + +**4. Run Qwen Code in another worktree** + +```bash +cd ../project-bugfix +qwen +``` + +**5. Manage your worktrees** + +```bash +# List all worktrees +git worktree list + +# Remove a worktree when done +git worktree remove ../project-feature-a +``` + +> [!tip] +> +> - Each worktree has its own independent file state, making it perfect for parallel Qwen Code sessions +> - Changes made in one worktree won't affect others, preventing Qwen Code instances from interfering with each other +> - All worktrees share the same Git history and remote connections +> - For long-running tasks, you can have Qwen Code working in one worktree while you continue development in another +> - Use descriptive directory names to easily identify which task each worktree is for +> - Remember to initialize your development environment in each new worktree according to your project's setup. Depending on your stack, this might include: +> - JavaScript projects: Running dependency installation (`npm install`, `yarn`) +> - Python projects: Setting up virtual environments or installing with package managers +> - Other languages: Following your project's standard setup process + +## Use Qwen Code as a unix-style utility + +### Add Qwen Code to your verification process + +Suppose you want to use Qwen Code as a linter or code reviewer. + +**Add Qwen Code to your build script:** + +```json +// package.json +{ + ... + "scripts": { + ... + "lint:Qwen Code": "qwen -p 'you are a linter. please look at the changes vs. main and report any issues related to typos. report the filename and line number on one line, and a description of the issue on the second line. do not return any other text.'" + } +} +``` + +> [!tip] +> +> - Use Qwen Code for automated code review in your CI/CD pipeline +> - Customize the prompt to check for specific issues relevant to your project +> - Consider creating multiple scripts for different types of verification + +### Pipe in, pipe out + +Suppose you want to pipe data into Qwen Code, and get back data in a structured format. + +**Pipe data through Qwen Code:** + +```bash +cat build-error.txt | qwen -p 'concisely explain the root cause of this build error' > output.txt +``` + +> [!tip] +> +> - Use pipes to integrate Qwen-Code into existing shell scripts +> - Combine with other Unix tools for powerful workflows +> - Consider using --output-format for structured output + +### Control output format + +Suppose you need Qwen Code's output in a specific format, especially when integrating Qwen Code into scripts or other tools. + +**1. Use text format (default)** + +```bash +cat data.txt | qwen -p 'summarize this data' --output-format text > summary.txt +``` + +This outputs just Qwen Code's plain text response (default behavior). + +**2. Use JSON format** + +```bash +cat code.py | qwen -p 'analyze this code for bugs' --output-format json > analysis.json +``` + +This outputs a JSON array of messages with metadata including cost and duration. + +**3. Use streaming JSON format** + +```bash +cat log.txt | qwen -p 'parse this log file for errors' --output-format stream-json +``` + +This outputs a series of JSON objects in real-time as Qwen Code processes the request. Each message is a valid JSON object, but the entire output is not valid JSON if concatenated. + +> [!tip] +> +> - Use `--output-format text` for simple integrations where you just need Qwen Code's response +> - Use `--output-format json` when you need the full conversation log +> - Use `--output-format stream-json` for real-time output of each conversation turn + +## Ask Qwen Code about its capabilities + +Qwen Code has built-in access to its documentation and can answer questions about its own features and limitations. + +### Example questions + +``` +can Qwen Code create pull requests? +``` + +``` +how does Qwen Code handle permissions? +``` + +``` +what slash commands are available? +``` + +``` +how do I use MCP with Qwen Code? +``` + +``` +how do I configure Qwen Code for Amazon Bedrock? +``` + +``` +what are the limitations of Qwen Code? +``` + +> [!note] +> +> Qwen Code provides documentation-based answers to these questions. For executable examples and hands-on demonstrations, refer to the specific workflow sections above. + +> [!tip] +> +> - Qwen Code always has access to the latest Qwen Code documentation, regardless of the version you're using +> - Ask specific questions to get detailed answers +> - Qwen Code can explain complex features like MCP integration, enterprise configurations, and advanced workflows diff --git a/docs/users/configuration/_meta.ts b/docs/users/configuration/_meta.ts new file mode 100644 index 000000000..8899eb91f --- /dev/null +++ b/docs/users/configuration/_meta.ts @@ -0,0 +1,10 @@ +export default { + settings: 'Settings', + auth: 'Authentication', + memory: { + display: 'hidden', + }, + 'qwen-ignore': 'Ignoring Files', + 'trusted-folders': 'Trusted Folders', + themes: 'Themes', +}; diff --git a/docs/users/configuration/auth.md b/docs/users/configuration/auth.md new file mode 100644 index 000000000..82bc66b26 --- /dev/null +++ b/docs/users/configuration/auth.md @@ -0,0 +1,119 @@ +# Authentication + +Qwen Code supports two authentication methods. Pick the one that matches how you want to run the CLI: + +- **Qwen OAuth (recommended)**: sign in with your `qwen.ai` account in a browser. +- **OpenAI-compatible API**: use an API key (OpenAI or any OpenAI-compatible provider / endpoint). + +## Option 1: Qwen OAuth (recommended & free) 👍 + +Use this if you want the simplest setup and you’re using Qwen models. + +- **How it works**: on first start, Qwen Code opens a browser login page. After you finish, credentials are cached locally so you usually won’t need to log in again. +- **Requirements**: a `qwen.ai` account + internet access (at least for the first login). +- **Benefits**: no API key management, automatic credential refresh. +- **Cost & quota**: free, with a quota of **60 requests/minute** and **2,000 requests/day**. + +Start the CLI and follow the browser flow: + +```bash +qwen +``` + +## Option 2: OpenAI-compatible API (API key) + +Use this if you want to use OpenAI models or any provider that exposes an OpenAI-compatible API (e.g. OpenAI, Azure OpenAI, OpenRouter, ModelScope, Alibaba Cloud Bailian, or a self-hosted compatible endpoint). + +### Quick start (interactive, recommended for local use) + +When you choose the OpenAI-compatible option in the CLI, it will prompt you for: + +- **API key** +- **Base URL** (default: `https://api.openai.com/v1`) +- **Model** (default: `gpt-4o`) + +> **Note:** the CLI may display the key in plain text for verification. Make sure your terminal is not being recorded or shared. + +### Configure via command-line arguments + +```bash +# API key only +qwen-code --openai-api-key "your-api-key-here" + +# Custom base URL (OpenAI-compatible endpoint) +qwen-code --openai-api-key "your-api-key-here" --openai-base-url "https://your-endpoint.com/v1" + +# Custom model +qwen-code --openai-api-key "your-api-key-here" --model "gpt-4o-mini" +``` + +### Configure via environment variables + +You can set these in your shell profile, CI, or an `.env` file: + +```bash +export OPENAI_API_KEY="your-api-key-here" +export OPENAI_BASE_URL="https://api.openai.com/v1" # optional +export OPENAI_MODEL="gpt-4o" # optional +``` + +#### Persisting env vars with `.env` / `.qwen/.env` + +Qwen Code will auto-load environment variables from the **first** `.env` file it finds (variables are **not merged** across multiple files). + +Search order: + +1. From the **current directory**, walking upward toward `/`: + 1. `.qwen/.env` + 2. `.env` +2. If nothing is found, it falls back to your **home directory**: + - `~/.qwen/.env` + - `~/.env` + +`.qwen/.env` is recommended to keep Qwen Code variables isolated from other tools. Some variables (like `DEBUG` and `DEBUG_MODE`) are excluded from project `.env` files to avoid interfering with qwen-code behavior. + +Examples: + +```bash +# Project-specific settings (recommended) +mkdir -p .qwen +cat >> .qwen/.env <<'EOF' +OPENAI_API_KEY="your-api-key" +OPENAI_BASE_URL="https://api-inference.modelscope.cn/v1" +OPENAI_MODEL="Qwen/Qwen3-Coder-480B-A35B-Instruct" +EOF +``` + +```bash +# User-wide settings (available everywhere) +mkdir -p ~/.qwen +cat >> ~/.qwen/.env <<'EOF' +OPENAI_API_KEY="your-api-key" +OPENAI_BASE_URL="https://dashscope.aliyuncs.com/compatible-mode/v1" +OPENAI_MODEL="qwen3-coder-plus" +EOF +``` + +## Switch authentication method (without restarting) + +In the Qwen Code UI, run: + +```bash +/auth +``` + +## Non-interactive / headless environments (CI, SSH, containers) + +In a non-interactive terminal you typically **cannot** complete the OAuth browser login flow. +Use the OpenAI-compatible API method via environment variables: + +- Set at least `OPENAI_API_KEY`. +- Optionally set `OPENAI_BASE_URL` and `OPENAI_MODEL`. + +If none of these are set in a non-interactive session, Qwen Code will exit with an error. + +## Security notes + +- Don’t commit API keys to version control. +- Prefer `.qwen/.env` for project-local secrets (and keep it out of git). +- Treat your terminal output as sensitive if it prints credentials for verification. diff --git a/docs/users/configuration/memory.md b/docs/users/configuration/memory.md new file mode 100644 index 000000000..e69de29bb diff --git a/docs/cli/qwen-ignore.md b/docs/users/configuration/qwen-ignore.md similarity index 73% rename from docs/cli/qwen-ignore.md rename to docs/users/configuration/qwen-ignore.md index 1b8709108..9b992bda2 100644 --- a/docs/cli/qwen-ignore.md +++ b/docs/users/configuration/qwen-ignore.md @@ -6,7 +6,7 @@ Qwen Code includes the ability to automatically ignore files, similar to `.gitig ## How it works -When you add a path to your `.qwenignore` file, tools that respect this file will exclude matching files and directories from their operations. For example, when you use the [`read_many_files`](./tools/multi-file.md) command, any paths in your `.qwenignore` file will be automatically excluded. +When you add a path to your `.qwenignore` file, tools that respect this file will exclude matching files and directories from their operations. For example, when you use the [`read_many_files`](../../developers/tools/multi-file) command, any paths in your `.qwenignore` file will be automatically excluded. For the most part, `.qwenignore` follows the conventions of `.gitignore` files: @@ -20,14 +20,10 @@ You can update your `.qwenignore` file at any time. To apply the changes, you mu ## How to use `.qwenignore` -To enable `.qwenignore`: - -1. Create a file named `.qwenignore` in the root of your project directory. - -To add a file or directory to `.qwenignore`: - -1. Open your `.qwenignore` file. -2. Add the path or file you want to ignore, for example: `/archive/` or `apikeys.txt`. +| Step | Description | +| ---------------------- | -------------------------------------------------------------------------------------- | +| **Enable .qwenignore** | Create a file named `.qwenignore` in your project root directory | +| **Add ignore rules** | Open `.qwenignore` file and add paths to ignore, example: `/archive/` or `apikeys.txt` | ### `.qwenignore` examples diff --git a/docs/users/configuration/settings.md b/docs/users/configuration/settings.md new file mode 100644 index 000000000..3e87985b8 --- /dev/null +++ b/docs/users/configuration/settings.md @@ -0,0 +1,510 @@ +# Qwen Code Configuration + +> [!tip] +> +> **Authentication / API keys:** Authentication (Qwen OAuth vs OpenAI-compatible API) and auth-related environment variables (like `OPENAI_API_KEY`) are documented in **[Authentication](../configuration/auth)**. + +> [!note] +> +> **Note on New Configuration Format**: The format of the `settings.json` file has been updated to a new, more organized structure. The old format will be migrated automatically. +> Qwen Code offers several ways to configure its behavior, including environment variables, command-line arguments, and settings files. This document outlines the different configuration methods and available settings. + +## Configuration layers + +Configuration is applied in the following order of precedence (lower numbers are overridden by higher numbers): + +| Level | Configuration Source | Description | +| ----- | ---------------------- | ------------------------------------------------------------------------------- | +| 1 | Default values | Hardcoded defaults within the application | +| 2 | System defaults file | System-wide default settings that can be overridden by other settings files | +| 3 | User settings file | Global settings for the current user | +| 4 | Project settings file | Project-specific settings | +| 5 | System settings file | System-wide settings that override all other settings files | +| 6 | Environment variables | System-wide or session-specific variables, potentially loaded from `.env` files | +| 7 | Command-line arguments | Values passed when launching the CLI | + +## Settings files + +Qwen Code uses JSON settings files for persistent configuration. There are four locations for these files: + +| File Type | Location | Scope | +| --------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| System defaults file | Linux: `/etc/qwen-code/system-defaults.json`
Windows: `C:\ProgramData\qwen-code\system-defaults.json`
macOS: `/Library/Application Support/QwenCode/system-defaults.json`
The path can be overridden using the `QWEN_CODE_SYSTEM_DEFAULTS_PATH` environment variable. | Provides a base layer of system-wide default settings. These settings have the lowest precedence and are intended to be overridden by user, project, or system override settings. | +| User settings file | `~/.qwen/settings.json` (where `~` is your home directory). | Applies to all Qwen Code sessions for the current user. | +| Project settings file | `.qwen/settings.json` within your project's root directory. | Applies only when running Qwen Code from that specific project. Project settings override user settings. | +| System settings file | Linux: `/etc/qwen-code/settings.json`
Windows: `C:\ProgramData\qwen-code\settings.json`
macOS: `/Library/Application Support/QwenCode/settings.json`
The path can be overridden using the `QWEN_CODE_SYSTEM_SETTINGS_PATH` environment variable. | Applies to all Qwen Code sessions on the system, for all users. System settings override user and project settings. May be useful for system administrators at enterprises to have controls over users' Qwen Code setups. | + +> [!note] +> +> **Note on environment variables in settings:** String values within your `settings.json` files can reference environment variables using either `$VAR_NAME` or `${VAR_NAME}` syntax. These variables will be automatically resolved when the settings are loaded. For example, if you have an environment variable `MY_API_TOKEN`, you could use it in `settings.json` like this: `"apiKey": "$MY_API_TOKEN"`. + +### The `.qwen` directory in your project + +In addition to a project settings file, a project's `.qwen` directory can contain other project-specific files related to Qwen Code's operation, such as: + +- [Custom sandbox profiles](../features/sandbox) (e.g. `.qwen/sandbox-macos-custom.sb`, `.qwen/sandbox.Dockerfile`). +- [Agent Skills](../features/skills) (experimental) under `.qwen/skills/` (each Skill is a directory containing a `SKILL.md`). + +### Available settings in `settings.json` + +Settings are organized into categories. All settings should be placed within their corresponding top-level category object in your `settings.json` file. + +#### general + +| Setting | Type | Description | Default | +| ------------------------------- | ------- | ---------------------------------------------------------------------------------------------------------- | ----------- | +| `general.preferredEditor` | string | The preferred editor to open files in. | `undefined` | +| `general.vimMode` | boolean | Enable Vim keybindings. | `false` | +| `general.disableAutoUpdate` | boolean | Disable automatic updates. | `false` | +| `general.disableUpdateNag` | boolean | Disable update notification prompts. | `false` | +| `general.gitCoAuthor` | boolean | Automatically add a Co-authored-by trailer to git commit messages when commits are made through Qwen Code. | `true` | +| `general.checkpointing.enabled` | boolean | Enable session checkpointing for recovery. | `false` | + +#### output + +| Setting | Type | Description | Default | Possible Values | +| --------------- | ------ | ----------------------------- | -------- | ------------------ | +| `output.format` | string | The format of the CLI output. | `"text"` | `"text"`, `"json"` | + +#### ui + +| Setting | Type | Description | Default | +| ---------------------------------------- | ---------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ----------- | +| `ui.theme` | string | The color theme for the UI. See [Themes](../configuration/themes) for available options. | `undefined` | +| `ui.customThemes` | object | Custom theme definitions. | `{}` | +| `ui.hideWindowTitle` | boolean | Hide the window title bar. | `false` | +| `ui.hideTips` | boolean | Hide helpful tips in the UI. | `false` | +| `ui.hideBanner` | boolean | Hide the application banner. | `false` | +| `ui.hideFooter` | boolean | Hide the footer from the UI. | `false` | +| `ui.showMemoryUsage` | boolean | Display memory usage information in the UI. | `false` | +| `ui.showLineNumbers` | boolean | Show line numbers in code blocks in the CLI output. | `true` | +| `ui.showCitations` | boolean | Show citations for generated text in the chat. | `true` | +| `enableWelcomeBack` | boolean | Show welcome back dialog when returning to a project with conversation history. When enabled, Qwen Code will automatically detect if you're returning to a project with a previously generated project summary (`.qwen/PROJECT_SUMMARY.md`) and show a dialog allowing you to continue your previous conversation or start fresh. This feature integrates with the `/summary` command and quit confirmation dialog. | `true` | +| `ui.accessibility.disableLoadingPhrases` | boolean | Disable loading phrases for accessibility. | `false` | +| `ui.accessibility.screenReader` | boolean | Enables screen reader mode, which adjusts the TUI for better compatibility with screen readers. | `false` | +| `ui.customWittyPhrases` | array of strings | A list of custom phrases to display during loading states. When provided, the CLI will cycle through these phrases instead of the default ones. | `[]` | + +#### ide + +| Setting | Type | Description | Default | +| ------------------ | ------- | ---------------------------------------------------- | ------- | +| `ide.enabled` | boolean | Enable IDE integration mode. | `false` | +| `ide.hasSeenNudge` | boolean | Whether the user has seen the IDE integration nudge. | `false` | + +#### privacy + +| Setting | Type | Description | Default | +| -------------------------------- | ------- | -------------------------------------- | ------- | +| `privacy.usageStatisticsEnabled` | boolean | Enable collection of usage statistics. | `true` | + +#### model + +| Setting | Type | Description | Default | +| -------------------------------------------------- | ------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ----------- | +| `model.name` | string | The Qwen model to use for conversations. | `undefined` | +| `model.maxSessionTurns` | number | Maximum number of user/model/tool turns to keep in a session. -1 means unlimited. | `-1` | +| `model.summarizeToolOutput` | object | Enables or disables the summarization of tool output. You can specify the token budget for the summarization using the `tokenBudget` setting. Note: Currently only the `run_shell_command` tool is supported. For example `{"run_shell_command": {"tokenBudget": 2000}}` | `undefined` | +| `model.generationConfig` | object | Advanced overrides passed to the underlying content generator. Supports request controls such as `timeout`, `maxRetries`, and `disableCacheControl`, along with fine-tuning knobs under `samplingParams` (for example `temperature`, `top_p`, `max_tokens`). Leave unset to rely on provider defaults. | `undefined` | +| `model.chatCompression.contextPercentageThreshold` | number | Sets the threshold for chat history compression as a percentage of the model's total token limit. This is a value between 0 and 1 that applies to both automatic compression and the manual `/compress` command. For example, a value of `0.6` will trigger compression when the chat history exceeds 60% of the token limit. Use `0` to disable compression entirely. | `0.7` | +| `model.skipNextSpeakerCheck` | boolean | Skip the next speaker check. | `false` | +| `model.skipLoopDetection` | boolean | Disables loop detection checks. Loop detection prevents infinite loops in AI responses but can generate false positives that interrupt legitimate workflows. Enable this option if you experience frequent false positive loop detection interruptions. | `false` | +| `model.skipStartupContext` | boolean | Skips sending the startup workspace context (environment summary and acknowledgement) at the beginning of each session. Enable this if you prefer to provide context manually or want to save tokens on startup. | `false` | +| `model.enableOpenAILogging` | boolean | Enables logging of OpenAI API calls for debugging and analysis. When enabled, API requests and responses are logged to JSON files. | `false` | +| `model.openAILoggingDir` | string | Custom directory path for OpenAI API logs. If not specified, defaults to `logs/openai` in the current working directory. Supports absolute paths, relative paths (resolved from current working directory), and `~` expansion (home directory). | `undefined` | + +**Example model.generationConfig:** + +``` +{ + "model": { + "generationConfig": { + "timeout": 60000, + "disableCacheControl": false, + "samplingParams": { + "temperature": 0.2, + "top_p": 0.8, + "max_tokens": 1024 + } + } + } +} +``` + +**model.openAILoggingDir examples:** + +- `"~/qwen-logs"` - Logs to `~/qwen-logs` directory +- `"./custom-logs"` - Logs to `./custom-logs` relative to current directory +- `"/tmp/openai-logs"` - Logs to absolute path `/tmp/openai-logs` + +#### context + +| Setting | Type | Description | Default | +| ------------------------------------------------- | -------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ----------- | +| `context.fileName` | string or array of strings | The name of the context file(s). | `undefined` | +| `context.importFormat` | string | The format to use when importing memory. | `undefined` | +| `context.discoveryMaxDirs` | number | Maximum number of directories to search for memory. | `200` | +| `context.includeDirectories` | array | Additional directories to include in the workspace context. Specifies an array of additional absolute or relative paths to include in the workspace context. Missing directories will be skipped with a warning by default. Paths can use `~` to refer to the user's home directory. This setting can be combined with the `--include-directories` command-line flag. | `[]` | +| `context.loadFromIncludeDirectories` | boolean | Controls the behavior of the `/memory refresh` command. If set to `true`, `QWEN.md` files should be loaded from all directories that are added. If set to `false`, `QWEN.md` should only be loaded from the current directory. | `false` | +| `context.fileFiltering.respectGitIgnore` | boolean | Respect .gitignore files when searching. | `true` | +| `context.fileFiltering.respectQwenIgnore` | boolean | Respect .qwenignore files when searching. | `true` | +| `context.fileFiltering.enableRecursiveFileSearch` | boolean | Whether to enable searching recursively for filenames under the current tree when completing `@` prefixes in the prompt. | `true` | +| `context.fileFiltering.disableFuzzySearch` | boolean | When `true`, disables the fuzzy search capabilities when searching for files, which can improve performance on projects with a large number of files. | `false` | + +#### Troubleshooting File Search Performance + +If you are experiencing performance issues with file searching (e.g., with `@` completions), especially in projects with a very large number of files, here are a few things you can try in order of recommendation: + +1. **Use `.qwenignore`:** Create a `.qwenignore` file in your project root to exclude directories that contain a large number of files that you don't need to reference (e.g., build artifacts, logs, `node_modules`). Reducing the total number of files crawled is the most effective way to improve performance. +2. **Disable Fuzzy Search:** If ignoring files is not enough, you can disable fuzzy search by setting `disableFuzzySearch` to `true` in your `settings.json` file. This will use a simpler, non-fuzzy matching algorithm, which can be faster. +3. **Disable Recursive File Search:** As a last resort, you can disable recursive file search entirely by setting `enableRecursiveFileSearch` to `false`. This will be the fastest option as it avoids a recursive crawl of your project. However, it means you will need to type the full path to files when using `@` completions. + +#### tools + +| Setting | Type | Description | Default | Notes | +| ------------------------------------ | ----------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ----------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `tools.sandbox` | boolean or string | Sandbox execution environment (can be a boolean or a path string). | `undefined` | | +| `tools.shell.enableInteractiveShell` | boolean | Use `node-pty` for an interactive shell experience. Fallback to `child_process` still applies. | `false` | | +| `tools.core` | array of strings | This can be used to restrict the set of built-in tools with an allowlist. You can also specify command-specific restrictions for tools that support it, like the `run_shell_command` tool. For example, `"tools.core": ["run_shell_command(ls -l)"]` will only allow the `ls -l` command to be executed. | `undefined` | | +| `tools.exclude` | array of strings | Tool names to exclude from discovery. You can also specify command-specific restrictions for tools that support it, like the `run_shell_command` tool. For example, `"tools.exclude": ["run_shell_command(rm -rf)"]` will block the `rm -rf` command. **Security Note:** Command-specific restrictions in `tools.exclude` for `run_shell_command` are based on simple string matching and can be easily bypassed. This feature is **not a security mechanism** and should not be relied upon to safely execute untrusted code. It is recommended to use `tools.core` to explicitly select commands that can be executed. | `undefined` | | +| `tools.allowed` | array of strings | A list of tool names that will bypass the confirmation dialog. This is useful for tools that you trust and use frequently. For example, `["run_shell_command(git)", "run_shell_command(npm test)"]` will skip the confirmation dialog to run any `git` and `npm test` commands. | `undefined` | | +| `tools.approvalMode` | string | Sets the default approval mode for tool usage. | `default` | Possible values: `plan` (analyze only, do not modify files or execute commands), `default` (require approval before file edits or shell commands run), `auto-edit` (automatically approve file edits), `yolo` (automatically approve all tool calls) | +| `tools.discoveryCommand` | string | Command to run for tool discovery. | `undefined` | | +| `tools.callCommand` | string | Defines a custom shell command for calling a specific tool that was discovered using `tools.discoveryCommand`. The shell command must meet the following criteria: It must take function `name` (exactly as in [function declaration](https://ai.google.dev/gemini-api/docs/function-calling#function-declarations)) as first command line argument. It must read function arguments as JSON on `stdin`, analogous to [`functionCall.args`](https://cloud.google.com/vertex-ai/generative-ai/docs/model-reference/inference#functioncall). It must return function output as JSON on `stdout`, analogous to [`functionResponse.response.content`](https://cloud.google.com/vertex-ai/generative-ai/docs/model-reference/inference#functionresponse). | `undefined` | | +| `tools.useRipgrep` | boolean | Use ripgrep for file content search instead of the fallback implementation. Provides faster search performance. | `true` | | +| `tools.useBuiltinRipgrep` | boolean | Use the bundled ripgrep binary. When set to `false`, the system-level `rg` command will be used instead. This setting is only effective when `tools.useRipgrep` is `true`. | `true` | | +| `tools.enableToolOutputTruncation` | boolean | Enable truncation of large tool outputs. | `true` | Requires restart: Yes | +| `tools.truncateToolOutputThreshold` | number | Truncate tool output if it is larger than this many characters. Applies to Shell, Grep, Glob, ReadFile and ReadManyFiles tools. | `25000` | Requires restart: Yes | +| `tools.truncateToolOutputLines` | number | Maximum lines or entries kept when truncating tool output. Applies to Shell, Grep, Glob, ReadFile and ReadManyFiles tools. | `1000` | Requires restart: Yes | +| `tools.autoAccept` | boolean | Controls whether the CLI automatically accepts and executes tool calls that are considered safe (e.g., read-only operations) without explicit user confirmation. If set to `true`, the CLI will bypass the confirmation prompt for tools deemed safe. | `false` | | + +#### mcp + +| Setting | Type | Description | Default | +| ------------------- | ---------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ----------- | +| `mcp.serverCommand` | string | Command to start an MCP server. | `undefined` | +| `mcp.allowed` | array of strings | An allowlist of MCP servers to allow. Allows you to specify a list of MCP server names that should be made available to the model. This can be used to restrict the set of MCP servers to connect to. Note that this will be ignored if `--allowed-mcp-server-names` is set. | `undefined` | +| `mcp.excluded` | array of strings | A denylist of MCP servers to exclude. A server listed in both `mcp.excluded` and `mcp.allowed` is excluded. Note that this will be ignored if `--allowed-mcp-server-names` is set. | `undefined` | + +> [!note] +> +> **Security Note for MCP servers:** These settings use simple string matching on MCP server names, which can be modified. If you're a system administrator looking to prevent users from bypassing this, consider configuring the `mcpServers` at the system settings level such that the user will not be able to configure any MCP servers of their own. This should not be used as an airtight security mechanism. + +#### security + +| Setting | Type | Description | Default | +| ------------------------------ | ------- | ------------------------------------------------- | ----------- | +| `security.folderTrust.enabled` | boolean | Setting to track whether Folder trust is enabled. | `false` | +| `security.auth.selectedType` | string | The currently selected authentication type. | `undefined` | +| `security.auth.enforcedType` | string | The required auth type (useful for enterprises). | `undefined` | +| `security.auth.useExternal` | boolean | Whether to use an external authentication flow. | `undefined` | + +#### advanced + +| Setting | Type | Description | Default | +| ------------------------------ | ---------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------ | +| `advanced.autoConfigureMemory` | boolean | Automatically configure Node.js memory limits. | `false` | +| `advanced.dnsResolutionOrder` | string | The DNS resolution order. | `undefined` | +| `advanced.excludedEnvVars` | array of strings | Environment variables to exclude from project context. Specifies environment variables that should be excluded from being loaded from project `.env` files. This prevents project-specific environment variables (like `DEBUG=true`) from interfering with the CLI behavior. Variables from `.qwen/.env` files are never excluded. | `["DEBUG","DEBUG_MODE"]` | +| `advanced.bugCommand` | object | Configuration for the bug report command. Overrides the default URL for the `/bug` command. Properties: `urlTemplate` (string): A URL that can contain `{title}` and `{info}` placeholders. Example: `"bugCommand": { "urlTemplate": "https://bug.example.com/new?title={title}&info={info}" }` | `undefined` | +| `advanced.tavilyApiKey` | string | API key for Tavily web search service. Used to enable the `web_search` tool functionality. | `undefined` | + +> [!note] +> +> **Note about advanced.tavilyApiKey:** This is a legacy configuration format. For Qwen OAuth users, DashScope provider is automatically available without any configuration. For other authentication types, configure Tavily or Google providers using the new `webSearch` configuration format. + +#### mcpServers + +Configures connections to one or more Model-Context Protocol (MCP) servers for discovering and using custom tools. Qwen Code attempts to connect to each configured MCP server to discover available tools. If multiple MCP servers expose a tool with the same name, the tool names will be prefixed with the server alias you defined in the configuration (e.g., `serverAlias__actualToolName`) to avoid conflicts. Note that the system might strip certain schema properties from MCP tool definitions for compatibility. At least one of `command`, `url`, or `httpUrl` must be provided. If multiple are specified, the order of precedence is `httpUrl`, then `url`, then `command`. + +| Property | Type | Description | Optional | +| --------------------------------------- | ---------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | -------- | +| `mcpServers..command` | string | The command to execute to start the MCP server via standard I/O. | Yes | +| `mcpServers..args` | array of strings | Arguments to pass to the command. | Yes | +| `mcpServers..env` | object | Environment variables to set for the server process. | Yes | +| `mcpServers..cwd` | string | The working directory in which to start the server. | Yes | +| `mcpServers..url` | string | The URL of an MCP server that uses Server-Sent Events (SSE) for communication. | Yes | +| `mcpServers..httpUrl` | string | The URL of an MCP server that uses streamable HTTP for communication. | Yes | +| `mcpServers..headers` | object | A map of HTTP headers to send with requests to `url` or `httpUrl`. | Yes | +| `mcpServers..timeout` | number | Timeout in milliseconds for requests to this MCP server. | Yes | +| `mcpServers..trust` | boolean | Trust this server and bypass all tool call confirmations. | Yes | +| `mcpServers..description` | string | A brief description of the server, which may be used for display purposes. | Yes | +| `mcpServers..includeTools` | array of strings | List of tool names to include from this MCP server. When specified, only the tools listed here will be available from this server (allowlist behavior). If not specified, all tools from the server are enabled by default. | Yes | +| `mcpServers..excludeTools` | array of strings | List of tool names to exclude from this MCP server. Tools listed here will not be available to the model, even if they are exposed by the server. **Note:** `excludeTools` takes precedence over `includeTools` - if a tool is in both lists, it will be excluded. | Yes | + +#### telemetry + +Configures logging and metrics collection for Qwen Code. For more information, see [telemetry](/developers/development/telemetry). + +| Setting | Type | Description | Default | +| ------------------------ | ------- | -------------------------------------------------------------------------------- | ------- | +| `telemetry.enabled` | boolean | Whether or not telemetry is enabled. | | +| `telemetry.target` | string | The destination for collected telemetry. Supported values are `local` and `gcp`. | | +| `telemetry.otlpEndpoint` | string | The endpoint for the OTLP Exporter. | | +| `telemetry.otlpProtocol` | string | The protocol for the OTLP Exporter (`grpc` or `http`). | | +| `telemetry.logPrompts` | boolean | Whether or not to include the content of user prompts in the logs. | | +| `telemetry.outfile` | string | The file to write telemetry to when `target` is `local`. | | +| `telemetry.useCollector` | boolean | Whether to use an external OTLP collector. | | + +### Example `settings.json` + +Here is an example of a `settings.json` file with the nested structure, new as of v0.3.0: + +``` +{ + "general": { + "vimMode": true, + "preferredEditor": "code" + }, + "ui": { + "theme": "GitHub", + "hideBanner": true, + "hideTips": false, + "customWittyPhrases": [ + "You forget a thousand things every day. Make sure this is one of 'em", + "Connecting to AGI" + ] + }, + "tools": { + "approvalMode": "yolo", + "sandbox": "docker", + "discoveryCommand": "bin/get_tools", + "callCommand": "bin/call_tool", + "exclude": ["write_file"] + }, + "mcpServers": { + "mainServer": { + "command": "bin/mcp_server.py" + }, + "anotherServer": { + "command": "node", + "args": ["mcp_server.js", "--verbose"] + } + }, + "telemetry": { + "enabled": true, + "target": "local", + "otlpEndpoint": "http://localhost:4317", + "logPrompts": true + }, + "privacy": { + "usageStatisticsEnabled": true + }, + "model": { + "name": "qwen3-coder-plus", + "maxSessionTurns": 10, + "enableOpenAILogging": false, + "openAILoggingDir": "~/qwen-logs", + "summarizeToolOutput": { + "run_shell_command": { + "tokenBudget": 100 + } + } + }, + "context": { + "fileName": ["CONTEXT.md", "QWEN.md"], + "includeDirectories": ["path/to/dir1", "~/path/to/dir2", "../path/to/dir3"], + "loadFromIncludeDirectories": true, + "fileFiltering": { + "respectGitIgnore": false + } + }, + "advanced": { + "excludedEnvVars": ["DEBUG", "DEBUG_MODE", "NODE_ENV"] + } +} +``` + +## Shell History + +The CLI keeps a history of shell commands you run. To avoid conflicts between different projects, this history is stored in a project-specific directory within your user's home folder. + +- **Location:** `~/.qwen/tmp//shell_history` + - `` is a unique identifier generated from your project's root path. + - The history is stored in a file named `shell_history`. + +## Environment Variables & `.env` Files + +Environment variables are a common way to configure applications, especially for sensitive information (like tokens) or for settings that might change between environments. + +Qwen Code can automatically load environment variables from `.env` files. +For authentication-related variables (like `OPENAI_*`) and the recommended `.qwen/.env` approach, see **[Authentication](../configuration/auth)**. + +> [!tip] +> +> **Environment Variable Exclusion:** Some environment variables (like `DEBUG` and `DEBUG_MODE`) are automatically excluded from project `.env` files by default to prevent interference with the CLI behavior. Variables from `.qwen/.env` files are never excluded. You can customize this behavior using the `advanced.excludedEnvVars`setting in your `settings.json` file. + +### Environment Variables Table + +| Variable | Description | Notes | +| -------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------ | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `GEMINI_TELEMETRY_ENABLED` | Set to `true` or `1` to enable telemetry. Any other value is treated as disabling it. | Overrides the `telemetry.enabled` setting. | +| `GEMINI_TELEMETRY_TARGET` | Sets the telemetry target (`local` or `gcp`). | Overrides the `telemetry.target` setting. | +| `GEMINI_TELEMETRY_OTLP_ENDPOINT` | Sets the OTLP endpoint for telemetry. | Overrides the `telemetry.otlpEndpoint` setting. | +| `GEMINI_TELEMETRY_OTLP_PROTOCOL` | Sets the OTLP protocol (`grpc` or `http`). | Overrides the `telemetry.otlpProtocol` setting. | +| `GEMINI_TELEMETRY_LOG_PROMPTS` | Set to `true` or `1` to enable or disable logging of user prompts. Any other value is treated as disabling it. | Overrides the `telemetry.logPrompts` setting. | +| `GEMINI_TELEMETRY_OUTFILE` | Sets the file path to write telemetry to when the target is `local`. | Overrides the `telemetry.outfile` setting. | +| `GEMINI_TELEMETRY_USE_COLLECTOR` | Set to `true` or `1` to enable or disable using an external OTLP collector. Any other value is treated as disabling it. | Overrides the `telemetry.useCollector` setting. | +| `GEMINI_SANDBOX` | Alternative to the `sandbox` setting in `settings.json`. | Accepts `true`, `false`, `docker`, `podman`, or a custom command string. | +| `SEATBELT_PROFILE` | (macOS specific) Switches the Seatbelt (`sandbox-exec`) profile on macOS. | `permissive-open`: (Default) Restricts writes to the project folder (and a few other folders, see `packages/cli/src/utils/sandbox-macos-permissive-open.sb`) but allows other operations. `strict`: Uses a strict profile that declines operations by default. ``: Uses a custom profile. To define a custom profile, create a file named `sandbox-macos-.sb` in your project's `.qwen/` directory (e.g., `my-project/.qwen/sandbox-macos-custom.sb`). | +| `DEBUG` or `DEBUG_MODE` | (often used by underlying libraries or the CLI itself) Set to `true` or `1` to enable verbose debug logging, which can be helpful for troubleshooting. | **Note:** These variables are automatically excluded from project `.env` files by default to prevent interference with the CLI behavior. Use `.qwen/.env` files if you need to set these for Qwen Code specifically. | +| `NO_COLOR` | Set to any value to disable all color output in the CLI. | | +| `CLI_TITLE` | Set to a string to customize the title of the CLI. | | +| `CODE_ASSIST_ENDPOINT` | Specifies the endpoint for the code assist server. | This is useful for development and testing. | +| `TAVILY_API_KEY` | Your API key for the Tavily web search service. | Used to enable the `web_search` tool functionality. Example: `export TAVILY_API_KEY="tvly-your-api-key-here"` | + +## Command-Line Arguments + +Arguments passed directly when running the CLI can override other configurations for that specific session. + +### Command-Line Arguments Table + +| Argument | Alias | Description | Possible Values | Notes | +| ---------------------------- | ----- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `--model` | `-m` | Specifies the Qwen model to use for this session. | Model name | Example: `npm start -- --model qwen3-coder-plus` | +| `--prompt` | `-p` | Used to pass a prompt directly to the command. This invokes Qwen Code in a non-interactive mode. | Your prompt text | For scripting examples, use the `--output-format json` flag to get structured output. | +| `--prompt-interactive` | `-i` | Starts an interactive session with the provided prompt as the initial input. | Your prompt text | The prompt is processed within the interactive session, not before it. Cannot be used when piping input from stdin. Example: `qwen -i "explain this code"` | +| `--output-format` | `-o` | Specifies the format of the CLI output for non-interactive mode. | `text`, `json`, `stream-json` | `text`: (Default) The standard human-readable output. `json`: A machine-readable JSON output emitted at the end of execution. `stream-json`: Streaming JSON messages emitted as they occur during execution. For structured output and scripting, use the `--output-format json` or `--output-format stream-json` flag. See [Headless Mode](../features/headless) for detailed information. | +| `--input-format` | | Specifies the format consumed from standard input. | `text`, `stream-json` | `text`: (Default) Standard text input from stdin or command-line arguments. `stream-json`: JSON message protocol via stdin for bidirectional communication. Requirement: `--input-format stream-json` requires `--output-format stream-json` to be set. When using `stream-json`, stdin is reserved for protocol messages. See [Headless Mode](../features/headless) for detailed information. | +| `--include-partial-messages` | | Include partial assistant messages when using `stream-json` output format. When enabled, emits stream events (message_start, content_block_delta, etc.) as they occur during streaming. | | Default: `false`. Requirement: Requires `--output-format stream-json` to be set. See [Headless Mode](../features/headless) for detailed information about stream events. | +| `--sandbox` | `-s` | Enables sandbox mode for this session. | | | +| `--sandbox-image` | | Sets the sandbox image URI. | | | +| `--debug` | `-d` | Enables debug mode for this session, providing more verbose output. | | | +| `--all-files` | `-a` | If set, recursively includes all files within the current directory as context for the prompt. | | | +| `--help` | `-h` | Displays help information about command-line arguments. | | | +| `--show-memory-usage` | | Displays the current memory usage. | | | +| `--yolo` | | Enables YOLO mode, which automatically approves all tool calls. | | | +| `--approval-mode` | | Sets the approval mode for tool calls. | `plan`, `default`, `auto-edit`, `yolo` | Supported modes: `plan`: Analyze only—do not modify files or execute commands. `default`: Require approval for file edits or shell commands (default behavior). `auto-edit`: Automatically approve edit tools (edit, write_file) while prompting for others. `yolo`: Automatically approve all tool calls (equivalent to `--yolo`). Cannot be used together with `--yolo`. Use `--approval-mode=yolo` instead of `--yolo` for the new unified approach. Example: `qwen --approval-mode auto-edit`
See more about [Approval Mode](../features/approval-mode). | +| `--allowed-tools` | | A comma-separated list of tool names that will bypass the confirmation dialog. | Tool names | Example: `qwen --allowed-tools "Shell(git status)"` | +| `--telemetry` | | Enables [telemetry](/developers/development/telemetry). | | | +| `--telemetry-target` | | Sets the telemetry target. | | See [telemetry](/developers/development/telemetry) for more information. | +| `--telemetry-otlp-endpoint` | | Sets the OTLP endpoint for telemetry. | | See [telemetry](../../developers/development/telemetry) for more information. | +| `--telemetry-otlp-protocol` | | Sets the OTLP protocol for telemetry (`grpc` or `http`). | | Defaults to `grpc`. See [telemetry](../../developers/development/telemetry) for more information. | +| `--telemetry-log-prompts` | | Enables logging of prompts for telemetry. | | See [telemetry](../../developers/development/telemetry) for more information. | +| `--checkpointing` | | Enables [checkpointing](../features/checkpointing). | | | +| `--experimental-acp` | | Enables ACP mode (Agent Control Protocol). Useful for IDE/editor integrations like [Zed](../integration-zed). | | Experimental. | +| `--experimental-skills` | | Enables experimental [Agent Skills](../features/skills) (registers the `skill` tool and loads Skills from `.qwen/skills/` and `~/.qwen/skills/`). | | Experimental. | +| `--extensions` | `-e` | Specifies a list of extensions to use for the session. | Extension names | If not provided, all available extensions are used. Use the special term `qwen -e none` to disable all extensions. Example: `qwen -e my-extension -e my-other-extension` | +| `--list-extensions` | `-l` | Lists all available extensions and exits. | | | +| `--proxy` | | Sets the proxy for the CLI. | Proxy URL | Example: `--proxy http://localhost:7890`. | +| `--include-directories` | | Includes additional directories in the workspace for multi-directory support. | Directory paths | Can be specified multiple times or as comma-separated values. 5 directories can be added at maximum. Example: `--include-directories /path/to/project1,/path/to/project2` or `--include-directories /path/to/project1 --include-directories /path/to/project2` | +| `--screen-reader` | | Enables screen reader mode, which adjusts the TUI for better compatibility with screen readers. | | | +| `--version` | | Displays the version of the CLI. | | | +| `--openai-logging` | | Enables logging of OpenAI API calls for debugging and analysis. | | This flag overrides the `enableOpenAILogging` setting in `settings.json`. | +| `--openai-logging-dir` | | Sets a custom directory path for OpenAI API logs. | Directory path | This flag overrides the `openAILoggingDir` setting in `settings.json`. Supports absolute paths, relative paths, and `~` expansion. Example: `qwen --openai-logging-dir "~/qwen-logs" --openai-logging` | +| `--tavily-api-key` | | Sets the Tavily API key for web search functionality for this session. | API key | Example: `qwen --tavily-api-key tvly-your-api-key-here` | + +## Context Files (Hierarchical Instructional Context) + +While not strictly configuration for the CLI's _behavior_, context files (defaulting to `QWEN.md` but configurable via the `context.fileName` setting) are crucial for configuring the _instructional context_ (also referred to as "memory"). This powerful feature allows you to give project-specific instructions, coding style guides, or any relevant background information to the AI, making its responses more tailored and accurate to your needs. The CLI includes UI elements, such as an indicator in the footer showing the number of loaded context files, to keep you informed about the active context. + +- **Purpose:** These Markdown files contain instructions, guidelines, or context that you want the Qwen model to be aware of during your interactions. The system is designed to manage this instructional context hierarchically. + +### Example Context File Content (e.g. `QWEN.md`) + +Here's a conceptual example of what a context file at the root of a TypeScript project might contain: + +``` +# Project: My Awesome TypeScript Library + +## General Instructions: +- When generating new TypeScript code, please follow the existing coding style. +- Ensure all new functions and classes have JSDoc comments. +- Prefer functional programming paradigms where appropriate. +- All code should be compatible with TypeScript 5.0 and Node.js 20+. + +## Coding Style: +- Use 2 spaces for indentation. +- Interface names should be prefixed with `I` (e.g., `IUserService`). +- Private class members should be prefixed with an underscore (`_`). +- Always use strict equality (`===` and `!==`). + +## Specific Component: `src/api/client.ts` +- This file handles all outbound API requests. +- When adding new API call functions, ensure they include robust error handling and logging. +- Use the existing `fetchWithRetry` utility for all GET requests. + +## Regarding Dependencies: +- Avoid introducing new external dependencies unless absolutely necessary. +- If a new dependency is required, please state the reason. +``` + +This example demonstrates how you can provide general project context, specific coding conventions, and even notes about particular files or components. The more relevant and precise your context files are, the better the AI can assist you. Project-specific context files are highly encouraged to establish conventions and context. + +- **Hierarchical Loading and Precedence:** The CLI implements a sophisticated hierarchical memory system by loading context files (e.g., `QWEN.md`) from several locations. Content from files lower in this list (more specific) typically overrides or supplements content from files higher up (more general). The exact concatenation order and final context can be inspected using the `/memory show` command. The typical loading order is: + 1. **Global Context File:** + - Location: `~/.qwen/` (e.g., `~/.qwen/QWEN.md` in your user home directory). + - Scope: Provides default instructions for all your projects. + 2. **Project Root & Ancestors Context Files:** + - Location: The CLI searches for the configured context file in the current working directory and then in each parent directory up to either the project root (identified by a `.git` folder) or your home directory. + - Scope: Provides context relevant to the entire project or a significant portion of it. + 3. **Sub-directory Context Files (Contextual/Local):** + - Location: The CLI also scans for the configured context file in subdirectories _below_ the current working directory (respecting common ignore patterns like `node_modules`, `.git`, etc.). The breadth of this search is limited to 200 directories by default, but can be configured with the `context.discoveryMaxDirs` setting in your `settings.json` file. + - Scope: Allows for highly specific instructions relevant to a particular component, module, or subsection of your project. +- **Concatenation & UI Indication:** The contents of all found context files are concatenated (with separators indicating their origin and path) and provided as part of the system prompt. The CLI footer displays the count of loaded context files, giving you a quick visual cue about the active instructional context. +- **Importing Content:** You can modularize your context files by importing other Markdown files using the `@path/to/file.md` syntax. For more details, see the [Memory Import Processor documentation](../configuration/memory). +- **Commands for Memory Management:** + - Use `/memory refresh` to force a re-scan and reload of all context files from all configured locations. This updates the AI's instructional context. + - Use `/memory show` to display the combined instructional context currently loaded, allowing you to verify the hierarchy and content being used by the AI. + - See the [Commands documentation](../features/commands) for full details on the `/memory` command and its sub-commands (`show` and `refresh`). + +By understanding and utilizing these configuration layers and the hierarchical nature of context files, you can effectively manage the AI's memory and tailor Qwen Code's responses to your specific needs and projects. + +## Sandbox + +Qwen Code can execute potentially unsafe operations (like shell commands and file modifications) within a sandboxed environment to protect your system. + +[Sandbox](../features/sandbox) is disabled by default, but you can enable it in a few ways: + +- Using `--sandbox` or `-s` flag. +- Setting `GEMINI_SANDBOX` environment variable. +- Sandbox is enabled when using `--yolo` or `--approval-mode=yolo` by default. + +By default, it uses a pre-built `qwen-code-sandbox` Docker image. + +For project-specific sandboxing needs, you can create a custom Dockerfile at `.qwen/sandbox.Dockerfile` in your project's root directory. This Dockerfile can be based on the base sandbox image: + +``` +FROM qwen-code-sandbox +# Add your custom dependencies or configurations here +# For example: +# RUN apt-get update && apt-get install -y some-package +# COPY ./my-config /app/my-config +``` + +When `.qwen/sandbox.Dockerfile` exists, you can use `BUILD_SANDBOX` environment variable when running Qwen Code to automatically build the custom sandbox image: + +``` +BUILD_SANDBOX=1 qwen -s +``` + +## Usage Statistics + +To help us improve Qwen Code, we collect anonymized usage statistics. This data helps us understand how the CLI is used, identify common issues, and prioritize new features. + +**What we collect:** + +- **Tool Calls:** We log the names of the tools that are called, whether they succeed or fail, and how long they take to execute. We do not collect the arguments passed to the tools or any data returned by them. +- **API Requests:** We log the model used for each request, the duration of the request, and whether it was successful. We do not collect the content of the prompts or responses. +- **Session Information:** We collect information about the configuration of the CLI, such as the enabled tools and the approval mode. + +**What we DON'T collect:** + +- **Personally Identifiable Information (PII):** We do not collect any personal information, such as your name, email address, or API keys. +- **Prompt and Response Content:** We do not log the content of your prompts or the responses from the model. +- **File Content:** We do not log the content of any files that are read or written by the CLI. + +**How to opt out:** + +You can opt out of usage statistics collection at any time by setting the `usageStatisticsEnabled` property to `false` under the `privacy` category in your `settings.json` file: + +``` +{ + "privacy": { + "usageStatisticsEnabled": false + } +} +``` + +> [!note] +> +> When usage statistics are enabled, events are sent to an Alibaba Cloud RUM collection endpoint. diff --git a/docs/cli/themes.md b/docs/users/configuration/themes.md similarity index 57% rename from docs/cli/themes.md rename to docs/users/configuration/themes.md index 3b262bc9d..e74cfe027 100644 --- a/docs/cli/themes.md +++ b/docs/users/configuration/themes.md @@ -32,7 +32,7 @@ Qwen Code comes with a selection of pre-defined themes, which you can list using ### Theme Persistence -Selected themes are saved in Qwen Code's [configuration](./configuration.md) so your preference is remembered across sessions. +Selected themes are saved in Qwen Code's [configuration](../configuration/settings) so your preference is remembered across sessions. --- @@ -140,64 +140,21 @@ The theme file must be a valid JSON file that follows the same structure as a cu ### Example Custom Theme -Custom theme example + ### Using Your Custom Theme - Select your custom theme using the `/theme` command in Qwen Code. Your custom theme will appear in the theme selection dialog. - Or, set it as the default by adding `"theme": "MyCustomTheme"` to the `ui` object in your `settings.json`. -- Custom themes can be set at the user, project, or system level, and follow the same [configuration precedence](./configuration.md) as other settings. +- Custom themes can be set at the user, project, or system level, and follow the same [configuration precedence](../configuration/settings) as other settings. ---- +## Themes Preview -## Dark Themes - -### ANSI - -ANSI theme - -### Atom OneDark - -Atom One theme - -### Ayu - -Ayu theme - -### Default - -Default theme - -### Dracula - -Dracula theme - -### GitHub - -GitHub theme - -## Light Themes - -### ANSI Light - -ANSI Light theme - -### Ayu Light - -Ayu Light theme - -### Default Light - -Default Light theme - -### GitHub Light - -GitHub Light theme - -### Google Code - -Google Code theme - -### Xcode - -Xcode Light theme +| Dark Theme | Preview | Light Theme | Preview | +| :----------: | :-------------------------------------------------------------------------------------------------------------------------------------------------------------------: | :-----------: | :-------------------------------------------------------------------------------------------------------------------------------------------------------------------: | +| ANSI | | ANSI Light | | +| Atom OneDark | | Ayu Light |  | +| Ayu |  | Default Light |  | +| Default | | GitHub Light |  | +| Dracula | | Google Code |  | +| GitHub |  | Xcode |  | diff --git a/docs/cli/trusted-folders.md b/docs/users/configuration/trusted-folders.md similarity index 90% rename from docs/cli/trusted-folders.md rename to docs/users/configuration/trusted-folders.md index 6fe3486b6..7aa16d842 100644 --- a/docs/cli/trusted-folders.md +++ b/docs/users/configuration/trusted-folders.md @@ -22,8 +22,8 @@ Add the following to your user `settings.json` file: Once the feature is enabled, the first time you run the Qwen Code from a folder, a dialog will automatically appear, prompting you to make a choice: -- **Trust folder**: Grants full trust to the current folder (e.g., `my-project`). -- **Trust parent folder**: Grants trust to the parent directory (e.g., `safe-projects`), which automatically trusts all of its subdirectories as well. This is useful if you keep all your safe projects in one place. +- **Trust folder**: Grants full trust to the current folder (e.g. `my-project`). +- **Trust parent folder**: Grants trust to the parent directory (e.g. `safe-projects`), which automatically trusts all of its subdirectories as well. This is useful if you keep all your safe projects in one place. - **Don't trust**: Marks the folder as untrusted. The CLI will operate in a restricted "safe mode." Your choice is saved in a central file (`~/.qwen/trustedFolders.json`), so you will only be asked once per folder. @@ -56,6 +56,6 @@ If you need to change a decision or see all your settings, you have a couple of For advanced users, it's helpful to know the exact order of operations for how trust is determined: -1. **IDE Trust Signal**: If you are using the [IDE Integration](./ide-integration.md), the CLI first asks the IDE if the workspace is trusted. The IDE's response takes highest priority. +1. **IDE Trust Signal**: If you are using the [IDE Integration](../ide-integration/ide-integration), the CLI first asks the IDE if the workspace is trusted. The IDE's response takes highest priority. 2. **Local Trust File**: If the IDE is not connected, the CLI checks the central `~/.qwen/trustedFolders.json` file. diff --git a/docs/users/features/_meta.ts b/docs/users/features/_meta.ts new file mode 100644 index 000000000..bf182b6b7 --- /dev/null +++ b/docs/users/features/_meta.ts @@ -0,0 +1,13 @@ +export default { + commands: 'Commands', + 'sub-agents': 'SubAgents', + skills: 'Skills (Experimental)', + headless: 'Headless Mode', + checkpointing: { + display: 'hidden', + }, + 'approval-mode': 'Approval Mode', + mcp: 'MCP', + 'token-caching': 'Token Caching', + sandbox: 'Sandboxing', +}; diff --git a/docs/users/features/approval-mode.md b/docs/users/features/approval-mode.md new file mode 100644 index 000000000..e072f237c --- /dev/null +++ b/docs/users/features/approval-mode.md @@ -0,0 +1,263 @@ +# Approval Mode + +Qwen Code offers three distinct permission modes that allow you to flexibly control how AI interacts with your code and system based on task complexity and risk level. + +## Permission Modes Comparison + +| Mode | File Editing | Shell Commands | Best For | Risk Level | +| -------------- | --------------------------- | --------------------------- | ------------------------------------------------------------------------------------------------------ | ---------- | +| **Plan**​ | ❌ Read-only analysis only | ❌ Not executed | • Code exploration
• Planning complex changes
• Safe code review | Lowest | +| **Default**​ | ✅ Manual approval required | ✅ Manual approval required | • New/unfamiliar codebases
• Critical systems
• Team collaboration
• Learning and teaching | Low | +| **Auto-Edit**​ | ✅ Auto-approved | ❌ Manual approval required | • Daily development tasks
• Refactoring and code improvements
• Safe automation | Medium | +| **YOLO**​ | ✅ Auto-approved | ✅ Auto-approved | • Trusted personal projects
• Automated scripts/CI/CD
• Batch processing tasks | Highest | + +### Quick Reference Guide + +- **Start in Plan Mode**: Great for understanding before making changes +- **Work in Default Mode**: The balanced choice for most development work +- **Switch to Auto-Edit**: When you're making lots of safe code changes +- **Use YOLO sparingly**: Only for trusted automation in controlled environments + +> [!tip] +> +> You can quickly cycle through modes during a session using **Shift+Tab**. The terminal status bar shows your current mode, so you always know what permissions Qwen Code has. + +## 1. Use Plan Mode for safe code analysis + +Plan Mode instructs Qwen Code to create a plan by analyzing the codebase with **read-only** operations, perfect for exploring codebases, planning complex changes, or reviewing code safely. + +### When to use Plan Mode + +- **Multi-step implementation**: When your feature requires making edits to many files +- **Code exploration**: When you want to research the codebase thoroughly before changing anything +- **Interactive development**: When you want to iterate on the direction with Qwen Code + +### How to use Plan Mode + +**Turn on Plan Mode during a session** + +You can switch into Plan Mode during a session using **Shift+Tab** to cycle through permission modes. + +If you are in Normal Mode, **Shift+Tab** first switches into `auto-edits` Mode, indicated by `⏵⏵ accept edits on` at the bottom of the terminal. A subsequent **Shift+Tab** will switch into Plan Mode, indicated by `⏸ plan mode`. + +**Start a new session in Plan Mode** + +To start a new session in Plan Mode, use the `/approval-mode` then select `plan` + +```bash +/approval-mode +``` + +**Run "headless" queries in Plan Mode** + +You can also run a query in Plan Mode directly with `-p` or `prompt`: + +```bash +qwen --prompt "What is machine learning?" +``` + +### Example: Planning a complex refactor + +```bash +/approval-mode plan +``` + +``` +I need to refactor our authentication system to use OAuth2. Create a detailed migration plan. +``` + +Qwen Code analyzes the current implementation and create a comprehensive plan. Refine with follow-ups: + +``` +What about backward compatibility? +How should we handle database migration? +``` + +### Configure Plan Mode as default + +```json +// .qwen/settings.json +{ + "permissions": { + "defaultMode": "plan" + } +} +``` + +## 2. Use Default Mode for Controlled Interaction + +Default Mode is the standard way to work with Qwen Code. In this mode, you maintain full control over all potentially risky operations - Qwen Code will ask for your approval before making any file changes or executing shell commands. + +### When to use Default Mode + +- **New to a codebase**: When you're exploring an unfamiliar project and want to be extra cautious +- **Critical systems**: When working on production code, infrastructure, or sensitive data +- **Learning and teaching**: When you want to understand each step Qwen Code is taking +- **Team collaboration**: When multiple people are working on the same codebase +- **Complex operations**: When the changes involve multiple files or complex logic + +### How to use Default Mode + +**Turn on Default Mode during a session** + +You can switch into Default Mode during a session using **Shift+Tab**​ to cycle through permission modes. If you're in any other mode, pressing **Shift+Tab**​ will eventually cycle back to Default Mode, indicated by the absence of any mode indicator at the bottom of the terminal. + +**Start a new session in Default Mode** + +Default Mode is the initial mode when you start Qwen Code. If you've changed modes and want to return to Default Mode, use: + +``` +/approval-mode default +``` + +**Run "headless" queries in Default Mode** + +When running headless commands, Default Mode is the default behavior. You can explicitly specify it with: + +``` +qwen --prompt "Analyze this code for potential bugs" +``` + +### Example: Safely implementing a feature + +``` +/approval-mode default +``` + +``` +I need to add user profile pictures to our application. The pictures should be stored in an S3 bucket and the URLs saved in the database. +``` + +Qwen Code will analyze your codebase and propose a plan. It will then ask for approval before: + +1. Creating new files (controllers, models, migrations) +2. Modifying existing files (adding new columns, updating APIs) +3. Running any shell commands (database migrations, dependency installation) + +You can review each proposed change and approve or reject it individually. + +### Configure Default Mode as default + +```bash +// .qwen/settings.json +{ + "permissions": { +"defaultMode": "default" + } +} +``` + +## 3. Auto Edits Mode + +Auto-Edit Mode instructs Qwen Code to automatically approve file edits while requiring manual approval for shell commands, ideal for accelerating development workflows while maintaining system safety. + +### When to use Auto-Accept Edits Mode + +- **Daily development**: Ideal for most coding tasks +- **Safe automation**: Allows AI to modify code while preventing accidental execution of dangerous commands +- **Team collaboration**: Use in shared projects to avoid unintended impacts on others + +### How to switch to this mode + +``` +# Switch via command +/approval-mode auto-edit + +# Or use keyboard shortcut +Shift+Tab # Switch from other modes +``` + +### Workflow Example + +1. You ask Qwen Code to refactor a function +2. AI analyzes the code and proposes changes +3. **Automatically**​ applies all file changes without confirmation +4. If tests need to be run, it will **request approval**​ to execute `npm test` + +## 4. YOLO Mode - Full Automation + +YOLO Mode grants Qwen Code the highest permissions, automatically approving all tool calls including file editing and shell commands. + +### When to use YOLO Mode + +- **Automated scripts**: Running predefined automated tasks +- **CI/CD pipelines**: Automated execution in controlled environments +- **Personal projects**: Rapid iteration in fully trusted environments +- **Batch processing**: Tasks requiring multi-step command chains + +> [!warning] +> +> **Use YOLO Mode with caution**: AI can execute any command with your terminal permissions. Ensure: +> +> 1. You trust the current codebase +> 2. You understand all actions AI will perform +> 3. Important files are backed up or committed to version control + +### How to enable YOLO Mode + +``` +# Temporarily enable (current session only) +/approval-mode yolo + +# Set as project default +/approval-mode yolo --project + +# Set as user global default +/approval-mode yolo --user +``` + +### Configuration Example + +```bash +// .qwen/settings.json +{ + "permissions": { +"defaultMode": "yolo", +"confirmShellCommands": false, +"confirmFileEdits": false + } +} +``` + +### Automated Workflow Example + +```bash +# Fully automated refactoring task +qwen --prompt "Run the test suite, fix all failing tests, then commit changes" + +# Without human intervention, AI will: +# 1. Run test commands (auto-approved) +# 2. Fix failed test cases (auto-edit files) +# 3. Execute git commit (auto-approved) +``` + +## Mode Switching & Configuration + +### Keyboard Shortcut Switching + +During a Qwen Code session, use **Shift+Tab**​ to quickly cycle through the three modes: + +``` +Default Mode → Auto-Edit Mode → YOLO Mode → Plan Mode → Default Mode +``` + +### Persistent Configuration + +``` +// Project-level: ./.qwen/settings.json +// User-level: ~/.qwen/settings.json +{ + "permissions": { +"defaultMode": "auto-edit", // or "plan" or "yolo" +"confirmShellCommands": true, +"confirmFileEdits": true + } +} +``` + +### Mode Usage Recommendations + +1. **New to codebase**: Start with **Plan Mode**​ for safe exploration +2. **Daily development tasks**: Use **Auto-Accept Edits**​ (default mode), efficient and safe +3. **Automated scripts**: Use **YOLO Mode**​ in controlled environments for full automation +4. **Complex refactoring**: Use **Plan Mode**​ first for detailed planning, then switch to appropriate mode for execution diff --git a/docs/features/checkpointing.md b/docs/users/features/checkpointing.md similarity index 100% rename from docs/features/checkpointing.md rename to docs/users/features/checkpointing.md diff --git a/docs/users/features/commands.md b/docs/users/features/commands.md new file mode 100644 index 000000000..716fc3926 --- /dev/null +++ b/docs/users/features/commands.md @@ -0,0 +1,265 @@ +# Commands + +This document details all commands supported by Qwen Code, helping you efficiently manage sessions, customize the interface, and control its behavior. + +Qwen Code commands are triggered through specific prefixes and fall into three categories: + +| Prefix Type | Function Description | Typical Use Case | +| -------------------------- | --------------------------------------------------- | ---------------------------------------------------------------- | +| Slash Commands (`/`) | Meta-level control of Qwen Code itself | Managing sessions, modifying settings, getting help | +| At Commands (`@`) | Quickly inject local file content into conversation | Allowing AI to analyze specified files or code under directories | +| Exclamation Commands (`!`) | Direct interaction with system Shell | Executing system commands like `git status`, `ls`, etc. | + +## 1. Slash Commands (`/`) + +Slash commands are used to manage Qwen Code sessions, interface, and basic behavior. + +### 1.1 Session and Project Management + +These commands help you save, restore, and summarize work progress. + +| Command | Description | Usage Examples | +| ----------- | --------------------------------------------------------- | ------------------------------------ | +| `/init` | Analyze current directory and create initial context file | `/init` | +| `/summary` | Generate project summary based on conversation history | `/summary` | +| `/compress` | Replace chat history with summary to save Tokens | `/compress` | +| `/resume` | Resume a previous conversation session | `/resume` | +| `/restore` | Restore files to state before tool execution | `/restore` (list) or `/restore ` | + +### 1.2 Interface and Workspace Control + +Commands for adjusting interface appearance and work environment. + +| Command | Description | Usage Examples | +| ------------ | ---------------------------------------- | ----------------------------- | +| `/clear` | Clear terminal screen content | `/clear` (shortcut: `Ctrl+L`) | +| `/theme` | Change Qwen Code visual theme | `/theme` | +| `/vim` | Turn input area Vim editing mode on/off | `/vim` | +| `/directory` | Manage multi-directory support workspace | `/dir add ./src,./tests` | +| `/editor` | Open dialog to select supported editor | `/editor` | + +### 1.3 Language Settings + +Commands specifically for controlling interface and output language. + +| Command | Description | Usage Examples | +| --------------------- | -------------------------------- | -------------------------- | +| `/language` | View or change language settings | `/language` | +| → `ui [language]` | Set UI interface language | `/language ui zh-CN` | +| → `output [language]` | Set LLM output language | `/language output Chinese` | + +- Available UI languages: `zh-CN` (Simplified Chinese), `en-US` (English) +- Output language examples: `Chinese`, `English`, `Japanese`, etc. + +### 1.4 Tool and Model Management + +Commands for managing AI tools and models. + +| Command | Description | Usage Examples | +| ---------------- | --------------------------------------------- | --------------------------------------------- | +| `/mcp` | List configured MCP servers and tools | `/mcp`, `/mcp desc` | +| `/tools` | Display currently available tool list | `/tools`, `/tools desc` | +| `/approval-mode` | Change approval mode for tool usage | `/approval-mode --project` | +| →`plan` | Analysis only, no execution | Secure review | +| →`default` | Require approval for edits | Daily use | +| →`auto-edit` | Automatically approve edits | Trusted environment | +| →`yolo` | Automatically approve all | Quick prototyping | +| `/model` | Switch model used in current session | `/model` | +| `/extensions` | List all active extensions in current session | `/extensions` | +| `/memory` | Manage AI's instruction context | `/memory add Important Info` | + +### 1.5 Information, Settings, and Help + +Commands for obtaining information and performing system settings. + +| Command | Description | Usage Examples | +| --------------- | ----------------------------------------------- | ------------------------------------------------ | +| `/help` | Display help information for available commands | `/help` or `/?` | +| `/about` | Display version information | `/about` | +| `/stats` | Display detailed statistics for current session | `/stats` | +| `/settings` | Open settings editor | `/settings` | +| `/auth` | Change authentication method | `/auth` | +| `/bug` | Submit issue about Qwen Code | `/bug Button click unresponsive` | +| `/copy` | Copy last output content to clipboard | `/copy` | +| `/quit-confirm` | Show confirmation dialog before quitting | `/quit-confirm` (shortcut: press `Ctrl+C` twice) | +| `/quit` | Exit Qwen Code immediately | `/quit` or `/exit` | + +### 1.6 Common Shortcuts + +| Shortcut | Function | Note | +| ------------------ | ----------------------- | ---------------------- | +| `Ctrl/cmd+L` | Clear screen | Equivalent to `/clear` | +| `Ctrl/cmd+T` | Toggle tool description | MCP tool management | +| `Ctrl/cmd+C`×2 | Exit confirmation | Secure exit mechanism | +| `Ctrl/cmd+Z` | Undo input | Text editing | +| `Ctrl/cmd+Shift+Z` | Redo input | Text editing | + +## 2. @ Commands (Introducing Files) + +@ commands are used to quickly add local file or directory content to the conversation. + +| Command Format | Description | Examples | +| ------------------- | -------------------------------------------- | ------------------------------------------------ | +| `@` | Inject content of specified file | `@src/main.py Please explain this code` | +| `@` | Recursively read all text files in directory | `@docs/ Summarize content of this document` | +| Standalone `@` | Used when discussing `@` symbol itself | `@ What is this symbol used for in programming?` | + +Note: Spaces in paths need to be escaped with backslash (e.g., `@My\ Documents/file.txt`) + +## 3. Exclamation Commands (`!`) - Shell Command Execution + +Exclamation commands allow you to execute system commands directly within Qwen Code. + +| Command Format | Description | Examples | +| ------------------ | ------------------------------------------------------------------ | -------------------------------------- | +| `!` | Execute command in sub-Shell | `!ls -la`, `!git status` | +| Standalone `!` | Switch Shell mode, any input is executed directly as Shell command | `!`(enter) → Input command → `!`(exit) | + +Environment Variables: Commands executed via `!` will set the `QWEN_CODE=1` environment variable. + +## 4. Custom Commands + +Save frequently used prompts as shortcut commands to improve work efficiency and ensure consistency. + +### Quick Overview + +| Function | Description | Advantages | Priority | Applicable Scenarios | +| ---------------- | ------------------------------------------ | -------------------------------------- | -------- | ---------------------------------------------------- | +| Namespace | Subdirectory creates colon-named commands | Better command organization | | | +| Global Commands | `~/.qwen/commands/` | Available in all projects | Low | Personal frequently used commands, cross-project use | +| Project Commands | `/.qwen/commands/` | Project-specific, version-controllable | High | Team sharing, project-specific commands | + +Priority Rules: Project commands > User commands (project command used when names are same) + +### Command Naming Rules + +#### File Path to Command Name Mapping Table + +| File Location | Generated Command | Example Call | +| ---------------------------- | ----------------- | --------------------- | +| `~/.qwen/commands/test.toml` | `/test` | `/test Parameter` | +| `/git/commit.toml` | `/git:commit` | `/git:commit Message` | + +Naming Rules: Path separator (`/` or `\`) converted to colon (`:`) + +### TOML File Format Specification + +| Field | Required | Description | Example | +| ------------- | -------- | ---------------------------------------- | ------------------------------------------ | +| `prompt` | Required | Prompt content sent to model | `prompt = "Please analyze code: {{args}}"` | +| `description` | Optional | Command description (displayed in /help) | `description = "Code analysis tool"` | + +### Parameter Processing Mechanism + +| Processing Method | Syntax | Applicable Scenarios | Security Features | +| ---------------------------- | ------------------ | ------------------------------------ | -------------------------------------- | +| Context-aware Injection | `{{args}}` | Need precise parameter control | Automatic Shell escaping | +| Default Parameter Processing | No special marking | Simple commands, parameter appending | Append as-is | +| Shell Command Injection | `!{command}` | Need dynamic content | Execution confirmation required before | + +#### 1. Context-aware Injection (`{{args}}`) + +| Scenario | TOML Configuration | Call Method | Actual Effect | +| ---------------- | --------------------------------------- | --------------------- | ------------------------ | +| Raw Injection | `prompt = "Fix: {{args}}"` | `/fix "Button issue"` | `Fix: "Button issue"` | +| In Shell Command | `prompt = "Search: !{grep {{args}} .}"` | `/search "hello"` | Execute `grep "hello" .` | + +#### 2. Default Parameter Processing + +| Input Situation | Processing Method | Example | +| --------------- | ------------------------------------------------------ | ---------------------------------------------- | +| Has parameters | Append to end of prompt (separated by two line breaks) | `/cmd parameter` → Original prompt + parameter | +| No parameters | Send prompt as is | `/cmd` → Original prompt | + +🚀 Dynamic Content Injection + +| Injection Type | Syntax | Processing Order | Purpose | +| --------------------- | -------------- | ------------------- | -------------------------------- | +| File Content | `@{file path}` | Processed first | Inject static reference files | +| Shell Commands | `!{command}` | Processed in middle | Inject dynamic execution results | +| Parameter Replacement | `{{args}}` | Processed last | Inject user parameters | + +#### 3. Shell Command Execution (`!{...}`) + +| Operation | User Interaction | +| ------------------------------- | -------------------- | +| 1. Parse command and parameters | - | +| 2. Automatic Shell escaping | - | +| 3. Show confirmation dialog | ✅ User confirmation | +| 4. Execute command | - | +| 5. Inject output to prompt | - | + +Example: Git Commit Message Generation + +``` +# git/commit.toml +description = "Generate Commit message based on staged changes" +prompt = """ +Please generate a Commit message based on the following diff: +diff +!{git diff --staged} +""" +``` + +#### 4. File Content Injection (`@{...}`) + +| File Type | Support Status | Processing Method | +| ------------ | ---------------------- | --------------------------- | +| Text Files | ✅ Full Support | Directly inject content | +| Images/PDF | ✅ Multi-modal Support | Encode and inject | +| Binary Files | ⚠️ Limited Support | May be skipped or truncated | +| Directory | ✅ Recursive Injection | Follow .gitignore rules | + +Example: Code Review Command + +``` +# review.toml +description = "Code review based on best practices" +prompt = """ +Review {{args}}, reference standards: + +@{docs/code-standards.md} +""" +``` + +### Practical Creation Example + +#### "Pure Function Refactoring" Command Creation Steps Table + +| Operation | Command/Code | +| ----------------------------- | ------------------------------------------- | +| 1. Create directory structure | `mkdir -p ~/.qwen/commands/refactor` | +| 2. Create command file | `touch ~/.qwen/commands/refactor/pure.toml` | +| 3. Edit command content | Refer to the complete code below. | +| 4. Test command | `@file.js` → `/refactor:pure` | + +```# ~/.qwen/commands/refactor/pure.toml +description = "Refactor code to pure function" +prompt = """ + Please analyze code in current context, refactor to pure function. + Requirements: + 1. Provide refactored code + 2. Explain key changes and pure function characteristic implementation + 3. Maintain function unchanged + """ +``` + +### Custom Command Best Practices Summary + +#### Command Design Recommendations Table + +| Practice Points | Recommended Approach | Avoid | +| -------------------- | ----------------------------------- | ------------------------------------------- | +| Command Naming | Use namespaces for organization | Avoid overly generic names | +| Parameter Processing | Clearly use `{{args}}` | Rely on default appending (easy to confuse) | +| Error Handling | Utilize Shell error output | Ignore execution failure | +| File Organization | Organize by function in directories | All commands in root directory | +| Description Field | Always provide clear description | Rely on auto-generated description | + +#### Security Features Reminder Table + +| Security Mechanism | Protection Effect | User Operation | +| ---------------------- | -------------------------- | ---------------------- | +| Shell Escaping | Prevent command injection | Automatic processing | +| Execution Confirmation | Avoid accidental execution | Dialog confirmation | +| Error Reporting | Help diagnose issues | View error information | diff --git a/docs/features/headless.md b/docs/users/features/headless.md similarity index 74% rename from docs/features/headless.md rename to docs/users/features/headless.md index 67d9decce..139b3bbba 100644 --- a/docs/features/headless.md +++ b/docs/users/features/headless.md @@ -4,31 +4,6 @@ Headless mode allows you to run Qwen Code programmatically from command line scripts and automation tools without any interactive UI. This is ideal for scripting, automation, CI/CD pipelines, and building AI-powered tools. -- [Headless Mode](#headless-mode) - - [Overview](#overview) - - [Basic Usage](#basic-usage) - - [Direct Prompts](#direct-prompts) - - [Stdin Input](#stdin-input) - - [Combining with File Input](#combining-with-file-input) - - [Output Formats](#output-formats) - - [Text Output (Default)](#text-output-default) - - [JSON Output](#json-output) - - [Example Usage](#example-usage) - - [Stream-JSON Output](#stream-json-output) - - [Input Format](#input-format) - - [File Redirection](#file-redirection) - - [Configuration Options](#configuration-options) - - [Examples](#examples) - - [Code review](#code-review) - - [Generate commit messages](#generate-commit-messages) - - [API documentation](#api-documentation) - - [Batch code analysis](#batch-code-analysis) - - [PR code review](#pr-code-review) - - [Log analysis](#log-analysis) - - [Release notes generation](#release-notes-generation) - - [Model and tool usage tracking](#model-and-tool-usage-tracking) - - [Resources](#resources) - ## Overview The headless mode provides a headless interface to Qwen Code that: @@ -78,10 +53,10 @@ qwen --continue -p "Run the tests again and summarize failures" qwen --resume 123e4567-e89b-12d3-a456-426614174000 -p "Apply the follow-up refactor" ``` -Notes: - -- Session data is project-scoped JSONL under `~/.qwen/projects//chats`. -- Restores conversation history, tool outputs, and chat-compression checkpoints before sending the new prompt. +> [!note] +> +> - Session data is project-scoped JSONL under `~/.qwen/projects//chats`. +> - Restores conversation history, tool outputs, and chat-compression checkpoints before sending the new prompt. ## Output Formats @@ -214,21 +189,22 @@ qwen -p "Write code" --output-format stream-json --include-partial-messages | jq Key command-line options for headless usage: -| Option | Description | Example | -| ---------------------------- | --------------------------------------------------- | ------------------------------------------------------------------------ | -| `--prompt`, `-p` | Run in headless mode | `qwen -p "query"` | -| `--output-format`, `-o` | Specify output format (text, json, stream-json) | `qwen -p "query" --output-format json` | -| `--input-format` | Specify input format (text, stream-json) | `qwen --input-format text --output-format stream-json` | -| `--include-partial-messages` | Include partial messages in stream-json output | `qwen -p "query" --output-format stream-json --include-partial-messages` | -| `--debug`, `-d` | Enable debug mode | `qwen -p "query" --debug` | -| `--all-files`, `-a` | Include all files in context | `qwen -p "query" --all-files` | -| `--include-directories` | Include additional directories | `qwen -p "query" --include-directories src,docs` | -| `--yolo`, `-y` | Auto-approve all actions | `qwen -p "query" --yolo` | -| `--approval-mode` | Set approval mode | `qwen -p "query" --approval-mode auto_edit` | -| `--continue` | Resume the most recent session for this project | `qwen --continue -p "Pick up where we left off"` | -| `--resume [sessionId]` | Resume a specific session (or choose interactively) | `qwen --resume 123e... -p "Finish the refactor"` | +| Option | Description | Example | +| ---------------------------- | ------------------------------------------------------- | ------------------------------------------------------------------------ | +| `--prompt`, `-p` | Run in headless mode | `qwen -p "query"` | +| `--output-format`, `-o` | Specify output format (text, json, stream-json) | `qwen -p "query" --output-format json` | +| `--input-format` | Specify input format (text, stream-json) | `qwen --input-format text --output-format stream-json` | +| `--include-partial-messages` | Include partial messages in stream-json output | `qwen -p "query" --output-format stream-json --include-partial-messages` | +| `--debug`, `-d` | Enable debug mode | `qwen -p "query" --debug` | +| `--all-files`, `-a` | Include all files in context | `qwen -p "query" --all-files` | +| `--include-directories` | Include additional directories | `qwen -p "query" --include-directories src,docs` | +| `--yolo`, `-y` | Auto-approve all actions | `qwen -p "query" --yolo` | +| `--approval-mode` | Set approval mode | `qwen -p "query" --approval-mode auto_edit` | +| `--continue` | Resume the most recent session for this project | `qwen --continue -p "Pick up where we left off"` | +| `--resume [sessionId]` | Resume a specific session (or choose interactively) | `qwen --resume 123e... -p "Finish the refactor"` | +| `--experimental-skills` | Enable experimental Skills (registers the `skill` tool) | `qwen --experimental-skills -p "What Skills are available?"` | -For complete details on all available configuration options, settings files, and environment variables, see the [Configuration Guide](./cli/configuration.md). +For complete details on all available configuration options, settings files, and environment variables, see the [Configuration Guide](../configuration/settings). ## Examples @@ -301,7 +277,7 @@ tail -5 usage.log ## Resources -- [CLI Configuration](./cli/configuration.md) - Complete configuration guide -- [Authentication](./cli/authentication.md) - Setup authentication -- [Commands](./cli/commands.md) - Interactive commands reference -- [Tutorials](./cli/tutorials.md) - Step-by-step automation guides +- [CLI Configuration](../configuration/settings#command-line-arguments) - Complete configuration guide +- [Authentication](../configuration/settings#environment-variables-for-api-access) - Setup authentication +- [Commands](../features/commands) - Interactive commands reference +- [Tutorials](../quickstart) - Step-by-step automation guides diff --git a/docs/users/features/mcp.md b/docs/users/features/mcp.md new file mode 100644 index 000000000..2b123c12c --- /dev/null +++ b/docs/users/features/mcp.md @@ -0,0 +1,287 @@ +# Connect Qwen Code to tools via MCP + +Qwen Code can connect to external tools and data sources through the [Model Context Protocol (MCP)](https://modelcontextprotocol.io/introduction). MCP servers give Qwen Code access to your tools, databases, and APIs. + +## What you can do with MCP + +With MCP servers connected, you can ask Qwen Code to: + +- Work with files and repos (read/search/write, depending on the tools you enable) +- Query databases (schema inspection, queries, reporting) +- Integrate internal services (wrap your APIs as MCP tools) +- Automate workflows (repeatable tasks exposed as tools/prompts) + +> [!tip] +> +> If you’re looking for the “one command to get started”, jump to [Quick start](#quick-start). + +## Quick start + +Qwen Code loads MCP servers from `mcpServers` in your `settings.json`. You can configure servers either: + +- By editing `settings.json` directly +- By using `qwen mcp` commands (see [CLI reference](#qwen-mcp-cli)) + +### Add your first server + +1. Add a server (example: remote HTTP MCP server): + +```bash +qwen mcp add --transport http my-server http://localhost:3000/mcp +``` + +2. Verify it shows up: + +```bash +qwen mcp list +``` + +3. Restart Qwen Code in the same project (or start it if it wasn’t running yet), then ask the model to use tools from that server. + +## Where configuration is stored (scopes) + +Most users only need these two scopes: + +- **Project scope (default)**: `.qwen/settings.json` in your project root +- **User scope**: `~/.qwen/settings.json` across all projects on your machine + +Write to user scope: + +```bash +qwen mcp add --scope user --transport http my-server http://localhost:3000/mcp +``` + +> [!tip] +> +> For advanced configuration layers (system defaults/system settings and precedence rules), see [Settings](../configuration/settings). + +## Configure servers + +### Choose a transport + +| Transport | When to use | JSON field(s) | +| --------- | ----------------------------------------------------------------- | ------------------------------------------- | +| `http` | Recommended for remote services; works well for cloud MCP servers | `httpUrl` (+ optional `headers`) | +| `sse` | Legacy/deprecated servers that only support Server-Sent Events | `url` (+ optional `headers`) | +| `stdio` | Local process (scripts, CLIs, Docker) on your machine | `command`, `args` (+ optional `cwd`, `env`) | + +> [!note] +> +> If a server supports both, prefer **HTTP** over **SSE**. + +### Configure via `settings.json` vs `qwen mcp add` + +Both approaches produce the same `mcpServers` entries in your `settings.json`—use whichever you prefer. + +#### Stdio server (local process) + +JSON (`.qwen/settings.json`): + +```json +{ + "mcpServers": { + "pythonTools": { + "command": "python", + "args": ["-m", "my_mcp_server", "--port", "8080"], + "cwd": "./mcp-servers/python", + "env": { + "DATABASE_URL": "$DB_CONNECTION_STRING", + "API_KEY": "${EXTERNAL_API_KEY}" + }, + "timeout": 15000 + } + } +} +``` + +CLI (writes to project scope by default): + +```bash +qwen mcp add pythonTools -e DATABASE_URL=$DB_CONNECTION_STRING -e API_KEY=$EXTERNAL_API_KEY \ + --timeout 15000 python -m my_mcp_server --port 8080 +``` + +#### HTTP server (remote streamable HTTP) + +JSON: + +```json +{ + "mcpServers": { + "httpServerWithAuth": { + "httpUrl": "http://localhost:3000/mcp", + "headers": { + "Authorization": "Bearer your-api-token" + }, + "timeout": 5000 + } + } +} +``` + +CLI: + +```bash +qwen mcp add --transport http httpServerWithAuth http://localhost:3000/mcp \ + --header "Authorization: Bearer your-api-token" --timeout 5000 +``` + +#### SSE server (remote Server-Sent Events) + +JSON: + +```json +{ + "mcpServers": { + "sseServer": { + "url": "http://localhost:8080/sse", + "timeout": 30000 + } + } +} +``` + +CLI: + +```bash +qwen mcp add --transport sse sseServer http://localhost:8080/sse --timeout 30000 +``` + +## Safety and control + +### Trust (skip confirmations) + +- **Server trust** (`trust: true`): bypasses confirmation prompts for that server (use sparingly). + +### Tool filtering (allow/deny tools per server) + +Use `includeTools` / `excludeTools` to restrict tools exposed by a server (from Qwen Code’s perspective). + +Example: include only a few tools: + +```json +{ + "mcpServers": { + "filteredServer": { + "command": "python", + "args": ["-m", "my_mcp_server"], + "includeTools": ["safe_tool", "file_reader", "data_processor"], + "timeout": 30000 + } + } +} +``` + +### Global allow/deny lists + +The `mcp` object in your `settings.json` defines global rules for all MCP servers: + +- `mcp.allowed`: allow-list of MCP server names (keys in `mcpServers`) +- `mcp.excluded`: deny-list of MCP server names + +Example: + +```json +{ + "mcp": { + "allowed": ["my-trusted-server"], + "excluded": ["experimental-server"] + } +} +``` + +## Troubleshooting + +- **Server shows “Disconnected” in `qwen mcp list`**: verify the URL/command is correct, then increase `timeout`. +- **Stdio server fails to start**: use an absolute `command` path, and double-check `cwd`/`env`. +- **Environment variables in JSON don’t resolve**: ensure they exist in the environment where Qwen Code runs (shell vs GUI app environments can differ). + +## Reference + +### `settings.json` structure + +#### Server-specific configuration (`mcpServers`) + +Add an `mcpServers` object to your `settings.json` file: + +```json +// ... file contains other config objects +{ + "mcpServers": { + "serverName": { + "command": "path/to/server", + "args": ["--arg1", "value1"], + "env": { + "API_KEY": "$MY_API_TOKEN" + }, + "cwd": "./server-directory", + "timeout": 30000, + "trust": false + } + } +} +``` + +Configuration properties: + +Required (one of the following): + +| Property | Description | +| --------- | ------------------------------------------------------ | +| `command` | Path to the executable for Stdio transport | +| `url` | SSE endpoint URL (e.g., `"http://localhost:8080/sse"`) | +| `httpUrl` | HTTP streaming endpoint URL | + +Optional: + +| Property | Type/Default | Description | +| ---------------------- | ---------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `args` | array | Command-line arguments for Stdio transport | +| `headers` | object | Custom HTTP headers when using `url` or `httpUrl` | +| `env` | object | Environment variables for the server process. Values can reference environment variables using `$VAR_NAME` or `${VAR_NAME}` syntax | +| `cwd` | string | Working directory for Stdio transport | +| `timeout` | number
(default: 600,000) | Request timeout in milliseconds (default: 600,000ms = 10 minutes) | +| `trust` | boolean
(default: false) | When `true`, bypasses all tool call confirmations for this server (default: `false`) | +| `includeTools` | array | List of tool names to include from this MCP server. When specified, only the tools listed here will be available from this server (allowlist behavior). If not specified, all tools from the server are enabled by default. | +| `excludeTools` | array | List of tool names to exclude from this MCP server. Tools listed here will not be available to the model, even if they are exposed by the server.
Note: `excludeTools` takes precedence over `includeTools` - if a tool is in both lists, it will be excluded. | +| `targetAudience` | string | The OAuth Client ID allowlisted on the IAP-protected application you are trying to access. Used with `authProviderType: 'service_account_impersonation'`. | +| `targetServiceAccount` | string | The email address of the Google Cloud Service Account to impersonate. Used with `authProviderType: 'service_account_impersonation'`. | + + + +### Manage MCP servers with `qwen mcp` + +You can always configure MCP servers by manually editing `settings.json`, but the CLI is usually faster. + +#### Adding a server (`qwen mcp add`) + +```bash +qwen mcp add [options] [args...] +``` + +| Argument/Option | Description | Default | Example | +| ------------------- | ------------------------------------------------------------------- | ------------------ | ----------------------------------------- | +| `` | A unique name for the server. | — | `example-server` | +| `` | The command to execute (for `stdio`) or the URL (for `http`/`sse`). | — | `/usr/bin/python` or `http://localhost:8` | +| `[args...]` | Optional arguments for a `stdio` command. | — | `--port 5000` | +| `-s`, `--scope` | Configuration scope (user or project). | `project` | `-s user` | +| `-t`, `--transport` | Transport type (`stdio`, `sse`, `http`). | `stdio` | `-t sse` | +| `-e`, `--env` | Set environment variables. | — | `-e KEY=value` | +| `-H`, `--header` | Set HTTP headers for SSE and HTTP transports. | — | `-H "X-Api-Key: abc123"` | +| `--timeout` | Set connection timeout in milliseconds. | — | `--timeout 30000` | +| `--trust` | Trust the server (bypass all tool call confirmation prompts). | — (`false`) | `--trust` | +| `--description` | Set the description for the server. | — | `--description "Local tools"` | +| `--include-tools` | A comma-separated list of tools to include. | all tools included | `--include-tools mytool,othertool` | +| `--exclude-tools` | A comma-separated list of tools to exclude. | none | `--exclude-tools mytool` | + +#### Listing servers (`qwen mcp list`) + +```bash +qwen mcp list +``` + +#### Removing a server (`qwen mcp remove`) + +```bash +qwen mcp remove +``` diff --git a/docs/users/features/sandbox.md b/docs/users/features/sandbox.md new file mode 100644 index 000000000..dbe598bc2 --- /dev/null +++ b/docs/users/features/sandbox.md @@ -0,0 +1,225 @@ +# Sandbox + +This document explains how to run Qwen Code inside a sandbox to reduce risk when tools execute shell commands or modify files. + +## Prerequisites + +Before using sandboxing, you need to install and set up Qwen Code: + +```bash +npm install -g @qwen-code/qwen-code +``` + +To verify the installation + +```bash +qwen --version +``` + +## Overview of sandboxing + +Sandboxing isolates potentially dangerous operations (such as shell commands or file modifications) from your host system, providing a security barrier between the CLI and your environment. + +The benefits of sandboxing include: + +- **Security**: Prevent accidental system damage or data loss. +- **Isolation**: Limit file system access to project directory. +- **Consistency**: Ensure reproducible environments across different systems. +- **Safety**: Reduce risk when working with untrusted code or experimental commands. + +> [!note] +> +> **Naming note:** Some sandbox-related environment variables still use the `GEMINI_*` prefix for backwards compatibility. + +## Sandboxing methods + +Your ideal method of sandboxing may differ depending on your platform and your preferred container solution. + +### 1. macOS Seatbelt (macOS only) + +Lightweight, built-in sandboxing using `sandbox-exec`. + +**Default profile**: `permissive-open` - restricts writes outside the project directory, but allows most other operations and outbound network access. + +**Best for**: Fast, no Docker required, strong guardrails for file writes. + +### 2. Container-based (Docker/Podman) + +Cross-platform sandboxing with complete process isolation. + +By default, Qwen Code uses a published sandbox image (configured in the CLI package) and will pull it as needed. + +**Best for**: Strong isolation on any OS, consistent tooling inside a known image. + +### Choosing a method + +- **On macOS**: + - Use Seatbelt when you want lightweight sandboxing (recommended for most users). + - Use Docker/Podman when you need a full Linux userland (e.g., tools that require Linux binaries). +- **On Linux/Windows**: + - Use Docker or Podman. + +## Quickstart + +```bash +# Enable sandboxing with command flag +qwen -s -p "analyze the code structure" + +# Or enable sandboxing for your shell session (recommended for CI / scripts) +export GEMINI_SANDBOX=true # true auto-picks a provider (see notes below) +qwen -p "run the test suite" + +# Configure in settings.json +{ + "tools": { + "sandbox": true + } +} +``` + +> [!tip] +> +> **Provider selection notes:** +> +> - On **macOS**, `GEMINI_SANDBOX=true` typically selects `sandbox-exec` (Seatbelt) if available. +> - On **Linux/Windows**, `GEMINI_SANDBOX=true` requires `docker` or `podman` to be installed. +> - To force a provider, set `GEMINI_SANDBOX=docker|podman|sandbox-exec`. + +## Configuration + +### Enable sandboxing (in order of precedence) + +1. **Environment variable**: `GEMINI_SANDBOX=true|false|docker|podman|sandbox-exec` +2. **Command flag / argument**: `-s`, `--sandbox`, or `--sandbox=` +3. **Settings file**: `tools.sandbox` in your `settings.json` (e.g., `{"tools": {"sandbox": true}}`). + +> [!important] +> +> If `GEMINI_SANDBOX` is set, it **overrides** the CLI flag and `settings.json`. + +### Configure the sandbox image (Docker/Podman) + +- **CLI flag**: `--sandbox-image ` +- **Environment variable**: `GEMINI_SANDBOX_IMAGE=` + +If you don’t set either, Qwen Code uses the default image configured in the CLI package (for example `ghcr.io/qwenlm/qwen-code:`). + +### macOS Seatbelt profiles + +Built-in profiles (set via `SEATBELT_PROFILE` env var): + +- `permissive-open` (default): Write restrictions, network allowed +- `permissive-closed`: Write restrictions, no network +- `permissive-proxied`: Write restrictions, network via proxy +- `restrictive-open`: Strict restrictions, network allowed +- `restrictive-closed`: Maximum restrictions +- `restrictive-proxied`: Strict restrictions, network via proxy + +> [!tip] +> +> Start with `permissive-open`, then tighten to `restrictive-closed` if your workflow still works. + +### Custom Seatbelt profiles (macOS) + +To use a custom Seatbelt profile: + +1. Create a file named `.qwen/sandbox-macos-.sb` in your project. +2. Set `SEATBELT_PROFILE=`. + +### Custom Sandbox Flags + +For container-based sandboxing, you can inject custom flags into the `docker` or `podman` command using the `SANDBOX_FLAGS` environment variable. This is useful for advanced configurations, such as disabling security features for specific use cases. + +**Example (Podman)**: + +To disable SELinux labeling for volume mounts, you can set the following: + +```bash +export SANDBOX_FLAGS="--security-opt label=disable" +``` + +Multiple flags can be provided as a space-separated string: + +```bash +export SANDBOX_FLAGS="--flag1 --flag2=value" +``` + +### Network proxying (all sandbox methods) + +If you want to restrict outbound network access to an allowlist, you can run a local proxy alongside the sandbox: + +- Set `GEMINI_SANDBOX_PROXY_COMMAND=` +- The command must start a proxy server that listens on `:::8877` + +This is especially useful with `*-proxied` Seatbelt profiles. + +For a working allowlist-style proxy example, see: [Example Proxy Script](/developers/examples/proxy-script). + +## Linux UID/GID handling + +The sandbox automatically handles user permissions on Linux. Override these permissions with: + +```bash +export SANDBOX_SET_UID_GID=true # Force host UID/GID +export SANDBOX_SET_UID_GID=false # Disable UID/GID mapping +``` + +## Customizing the sandbox environment (Docker/Podman) + +If you need extra tools inside the container (e.g., `git`, `python`, `rg`), create a custom Dockerfile: + +- Path: `.qwen/sandbox.Dockerfile` +- Then run with: `BUILD_SANDBOX=1 qwen -s ...` + +This builds a project-specific image based on the default sandbox image. + +## Troubleshooting + +### Common issues + +**"Operation not permitted"** + +- Operation requires access outside sandbox. +- On macOS Seatbelt: try a more permissive `SEATBELT_PROFILE`. +- On Docker/Podman: verify the workspace is mounted and your command doesn’t require access outside the project directory. + +**Missing commands** + +- Container sandbox: add them via `.qwen/sandbox.Dockerfile` or `.qwen/sandbox.bashrc`. +- Seatbelt: your host binaries are used, but the sandbox may restrict access to some paths. + +**Network issues** + +- Check sandbox profile allows network. +- Verify proxy configuration. + +### Debug mode + +```bash +DEBUG=1 qwen -s -p "debug command" +``` + +**Note:** If you have `DEBUG=true` in a project's `.env` file, it won't affect the CLI due to automatic exclusion. Use `.qwen/.env` files for Qwen Code-specific debug settings. + +### Inspect sandbox + +```bash +# Check environment +qwen -s -p "run shell command: env | grep SANDBOX" + +# List mounts +qwen -s -p "run shell command: mount | grep workspace" +``` + +## Security notes + +- Sandboxing reduces but doesn't eliminate all risks. +- Use the most restrictive profile that allows your work. +- Container overhead is minimal after the first pull/build. +- GUI applications may not work in sandboxes. + +## Related documentation + +- [Configuration](../configuration/settings): Full configuration options. +- [Commands](../features/commands): Available commands. +- [Troubleshooting](../support/troubleshooting): General troubleshooting. diff --git a/docs/users/features/skills.md b/docs/users/features/skills.md new file mode 100644 index 000000000..a0cabcf1a --- /dev/null +++ b/docs/users/features/skills.md @@ -0,0 +1,282 @@ +# Agent Skills (Experimental) + +> Create, manage, and share Skills to extend Qwen Code’s capabilities. + +This guide shows you how to create, use, and manage Agent Skills in **Qwen Code**. Skills are modular capabilities that extend the model’s effectiveness through organized folders containing instructions (and optionally scripts/resources). + +> [!note] +> +> Skills are currently **experimental** and must be enabled with `--experimental-skills`. + +## Prerequisites + +- Qwen Code (recent version) +- Run with the experimental flag enabled: + +```bash +qwen --experimental-skills +``` + +- Basic familiarity with Qwen Code ([Quickstart](../quickstart.md)) + +## What are Agent Skills? + +Agent Skills package expertise into discoverable capabilities. Each Skill consists of a `SKILL.md` file with instructions that the model can load when relevant, plus optional supporting files like scripts and templates. + +### How Skills are invoked + +Skills are **model-invoked** — the model autonomously decides when to use them based on your request and the Skill’s description. This is different from slash commands, which are **user-invoked** (you explicitly type `/command`). + +### Benefits + +- Extend Qwen Code for your workflows +- Share expertise across your team via git +- Reduce repetitive prompting +- Compose multiple Skills for complex tasks + +## Create a Skill + +Skills are stored as directories containing a `SKILL.md` file. + +### Personal Skills + +Personal Skills are available across all your projects. Store them in `~/.qwen/skills/`: + +```bash +mkdir -p ~/.qwen/skills/my-skill-name +``` + +Use personal Skills for: + +- Your individual workflows and preferences +- Experimental Skills you’re developing +- Personal productivity helpers + +### Project Skills + +Project Skills are shared with your team. Store them in `.qwen/skills/` within your project: + +```bash +mkdir -p .qwen/skills/my-skill-name +``` + +Use project Skills for: + +- Team workflows and conventions +- Project-specific expertise +- Shared utilities and scripts + +Project Skills can be checked into git and automatically become available to teammates. + +## Write `SKILL.md` + +Create a `SKILL.md` file with YAML frontmatter and Markdown content: + +```yaml +--- +name: your-skill-name +description: Brief description of what this Skill does and when to use it +--- + +# Your Skill Name + +## Instructions +Provide clear, step-by-step guidance for Qwen Code. + +## Examples +Show concrete examples of using this Skill. +``` + +### Field requirements + +Qwen Code currently validates that: + +- `name` is a non-empty string +- `description` is a non-empty string + +Recommended conventions (not strictly enforced yet): + +- Use lowercase letters, numbers, and hyphens in `name` +- Make `description` specific: include both **what** the Skill does and **when** to use it (key words users will naturally mention) + +## Add supporting files + +Create additional files alongside `SKILL.md`: + +```text +my-skill/ +├── SKILL.md (required) +├── reference.md (optional documentation) +├── examples.md (optional examples) +├── scripts/ +│ └── helper.py (optional utility) +└── templates/ + └── template.txt (optional template) +``` + +Reference these files from `SKILL.md`: + +````markdown +For advanced usage, see [reference.md](reference.md). + +Run the helper script: + +```bash +python scripts/helper.py input.txt +``` +```` + +## View available Skills + +When `--experimental-skills` is enabled, Qwen Code discovers Skills from: + +- Personal Skills: `~/.qwen/skills/` +- Project Skills: `.qwen/skills/` + +To view available Skills, ask Qwen Code directly: + +```text +What Skills are available? +``` + +Or inspect the filesystem: + +```bash +# List personal Skills +ls ~/.qwen/skills/ + +# List project Skills (if in a project directory) +ls .qwen/skills/ + +# View a specific Skill’s content +cat ~/.qwen/skills/my-skill/SKILL.md +``` + +## Test a Skill + +After creating a Skill, test it by asking questions that match your description. + +Example: if your description mentions “PDF files”: + +```text +Can you help me extract text from this PDF? +``` + +The model autonomously decides to use your Skill if it matches the request — you don’t need to explicitly invoke it. + +## Debug a Skill + +If Qwen Code doesn’t use your Skill, check these common issues: + +### Make the description specific + +Too vague: + +```yaml +description: Helps with documents +``` + +Specific: + +```yaml +description: Extract text and tables from PDF files, fill forms, merge documents. Use when working with PDFs, forms, or document extraction. +``` + +### Verify file path + +- Personal Skills: `~/.qwen/skills//SKILL.md` +- Project Skills: `.qwen/skills//SKILL.md` + +```bash +# Personal +ls ~/.qwen/skills/my-skill/SKILL.md + +# Project +ls .qwen/skills/my-skill/SKILL.md +``` + +### Check YAML syntax + +Invalid YAML prevents the Skill metadata from loading correctly. + +```bash +cat SKILL.md | head -n 15 +``` + +Ensure: + +- Opening `---` on line 1 +- Closing `---` before Markdown content +- Valid YAML syntax (no tabs, correct indentation) + +### View errors + +Run Qwen Code with debug mode to see Skill loading errors: + +```bash +qwen --experimental-skills --debug +``` + +## Share Skills with your team + +You can share Skills through project repositories: + +1. Add the Skill under `.qwen/skills/` +2. Commit and push +3. Teammates pull the changes and run with `--experimental-skills` + +```bash +git add .qwen/skills/ +git commit -m "Add team Skill for PDF processing" +git push +``` + +## Update a Skill + +Edit `SKILL.md` directly: + +```bash +# Personal Skill +code ~/.qwen/skills/my-skill/SKILL.md + +# Project Skill +code .qwen/skills/my-skill/SKILL.md +``` + +Changes take effect the next time you start Qwen Code. If Qwen Code is already running, restart it to load the updates. + +## Remove a Skill + +Delete the Skill directory: + +```bash +# Personal +rm -rf ~/.qwen/skills/my-skill + +# Project +rm -rf .qwen/skills/my-skill +git commit -m "Remove unused Skill" +``` + +## Best practices + +### Keep Skills focused + +One Skill should address one capability: + +- Focused: “PDF form filling”, “Excel analysis”, “Git commit messages” +- Too broad: “Document processing” (split into smaller Skills) + +### Write clear descriptions + +Help the model discover when to use Skills by including specific triggers: + +```yaml +description: Analyze Excel spreadsheets, create pivot tables, and generate charts. Use when working with Excel files, spreadsheets, or .xlsx data. +``` + +### Test with your team + +- Does the Skill activate when expected? +- Are the instructions clear? +- Are there missing examples or edge cases? diff --git a/docs/features/subagents.md b/docs/users/features/sub-agents.md similarity index 75% rename from docs/features/subagents.md rename to docs/users/features/sub-agents.md index 506d856f6..3497df09f 100644 --- a/docs/features/subagents.md +++ b/docs/users/features/sub-agents.md @@ -6,11 +6,11 @@ Subagents are specialized AI assistants that handle specific types of tasks with Subagents are independent AI assistants that: -- **Specialize in specific tasks** - Each subagent is configured with a focused system prompt for particular types of work -- **Have separate context** - They maintain their own conversation history, separate from your main chat -- **Use controlled tools** - You can configure which tools each subagent has access to -- **Work autonomously** - Once given a task, they work independently until completion or failure -- **Provide detailed feedback** - You can see their progress, tool usage, and execution statistics in real-time +- **Specialize in specific tasks** - Each Subagent is configured with a focused system prompt for particular types of work +- **Have separate context** - They maintain their own conversation history, separate from your main chat +- **Use controlled tools** - You can configure which tools each Subagent has access to +- **Work autonomously** - Once given a task, they work independently until completion or failure +- **Provide detailed feedback** - You can see their progress, tool usage, and execution statistics in real-time ## Key Benefits @@ -22,8 +22,8 @@ Subagents are independent AI assistants that: ## How Subagents Work -1. **Configuration**: You create subagent configurations that define their behavior, tools, and system prompts -2. **Delegation**: The main AI can automatically delegate tasks to appropriate subagents +1. **Configuration**: You create Subagents configurations that define their behavior, tools, and system prompts +2. **Delegation**: The main AI can automatically delegate tasks to appropriate Subagents 3. **Execution**: Subagents work independently, using their configured tools to complete tasks 4. **Results**: They return results and execution summaries back to the main conversation @@ -31,68 +31,46 @@ Subagents are independent AI assistants that: ### Quick Start -1. **Create your first subagent**: +1. **Create your first Subagent**: - ``` - /agents create - ``` + `/agents create` Follow the guided wizard to create a specialized agent. 2. **Manage existing agents**: - ``` - /agents manage - ``` + `/agents manage` - View and manage your configured subagents. + View and manage your configured Subagents. -3. **Use subagents automatically**: - Simply ask the main AI to perform tasks that match your subagents' specializations. The AI will automatically delegate appropriate work. +3. **Use Subagents automatically**: Simply ask the main AI to perform tasks that match your Subagents' specializations. The AI will automatically delegate appropriate work. ### Example Usage ``` User: "Please write comprehensive tests for the authentication module" - -AI: I'll delegate this to your testing specialist subagent. -[Delegates to "testing-expert" subagent] +AI: I'll delegate this to your testing specialist Subagents. +[Delegates to "testing-expert" Subagents] [Shows real-time progress of test creation] -[Returns with completed test files and execution summary] +[Returns with completed test files and execution summary]` ``` ## Management ### CLI Commands -Subagents are managed through the `/agents` slash command and its subcommands: +Subagents are managed through the `/agents` slash command and its subcommands: -#### `/agents create` +**Usage:**:`/agents create`。Creates a new Subagent through a guided step wizard. -Creates a new subagent through a guided step wizard. - -**Usage:** - -``` -/agents create -``` - -#### `/agents manage` - -Opens an interactive management dialog for viewing and managing existing subagents. - -**Usage:** - -``` -/agents manage -``` +**Usage:**:`/agents manage`。Opens an interactive management dialog for viewing and managing existing Subagents. ### Storage Locations Subagents are stored as Markdown files in two locations: -- **Project-level**: `.qwen/agents/` (takes precedence) -- **User-level**: `~/.qwen/agents/` (fallback) +- **Project-level**: `.qwen/agents/` (takes precedence) +- **User-level**: `~/.qwen/agents/` (fallback) This allows you to have both project-specific agents and personal agents that work across all projects. @@ -102,14 +80,14 @@ Subagents are configured using Markdown files with YAML frontmatter. This format #### Basic Structure -```markdown +``` --- name: agent-name description: Brief description of when and how to use this agent tools: - - tool1 - - tool2 - - tool3 # Optional + - tool1 + - tool2 + - tool3 # Optional --- System prompt content goes here. @@ -119,7 +97,7 @@ You can use ${variable} templating for dynamic content. #### Example Usage -```markdown +``` --- name: project-documenter description: Creates project documentation and README files @@ -143,19 +121,19 @@ new contributors and end users understand the project. Qwen Code proactively delegates tasks based on: - The task description in your request -- The description field in subagent configurations +- The description field in Subagents configurations - Current context and available tools -To encourage more proactive subagent use, include phrases like "use PROACTIVELY" or "MUST BE USED" in your description field. +To encourage more proactive Subagents use, include phrases like "use PROACTIVELY" or "MUST BE USED" in your description field. ### Explicit Invocation -Request a specific subagent by mentioning it in your command: +Request a specific Subagent by mentioning it in your command: ``` -> Let the testing-expert subagent create unit tests for the payment module -> Have the documentation-writer subagent update the API reference -> Get the react-specialist subagent to optimize this component's performance +Let the testing-expert Subagents create unit tests for the payment module +Have the documentation-writer Subagents update the API reference +Get the react-specialist Subagents to optimize this component's performance ``` ## Examples @@ -166,7 +144,7 @@ Request a specific subagent by mentioning it in your command: Perfect for comprehensive test creation and test-driven development. -```markdown +``` --- name: testing-expert description: Writes comprehensive unit tests, integration tests, and handles test automation with best practices @@ -202,15 +180,15 @@ Focus on both positive and negative test cases. **Use Cases:** -- "Write unit tests for the authentication service" -- "Create integration tests for the payment processing workflow" -- "Add test coverage for edge cases in the data validation module" +- “Write unit tests for the authentication service” +- “Create integration tests for the payment processing workflow” +- “Add test coverage for edge cases in the data validation module” #### Documentation Writer Specialized in creating clear, comprehensive documentation. -```markdown +``` --- name: documentation-writer description: Creates comprehensive documentation, README files, API docs, and user guides @@ -255,15 +233,15 @@ the actual implementation. Use clear headings, bullet points, and examples. **Use Cases:** -- "Create API documentation for the user management endpoints" -- "Write a comprehensive README for this project" -- "Document the deployment process with troubleshooting steps" +- “Create API documentation for the user management endpoints” +- “Write a comprehensive README for this project” +- “Document the deployment process with troubleshooting steps” #### Code Reviewer Focused on code quality, security, and best practices. -```markdown +``` --- name: code-reviewer description: Reviews code for best practices, security issues, performance, and maintainability @@ -297,9 +275,9 @@ Prioritize issues by impact and provide rationale for recommendations. **Use Cases:** -- "Review this authentication implementation for security issues" -- "Check the performance implications of this database query logic" -- "Evaluate the code structure and suggest improvements" +- “Review this authentication implementation for security issues” +- “Check the performance implications of this database query logic” +- “Evaluate the code structure and suggest improvements” ### Technology-Specific Agents @@ -307,7 +285,7 @@ Prioritize issues by impact and provide rationale for recommendations. Optimized for React development, hooks, and component patterns. -```markdown +``` --- name: react-specialist description: Expert in React development, hooks, component patterns, and modern React best practices @@ -344,15 +322,15 @@ Focus on accessibility and user experience considerations. **Use Cases:** -- "Create a reusable data table component with sorting and filtering" -- "Implement a custom hook for API data fetching with caching" -- "Refactor this class component to use modern React patterns" +- “Create a reusable data table component with sorting and filtering” +- “Implement a custom hook for API data fetching with caching” +- “Refactor this class component to use modern React patterns” #### Python Expert Specialized in Python development, frameworks, and best practices. -```markdown +``` --- name: python-expert description: Expert in Python development, frameworks, testing, and Python-specific best practices @@ -390,9 +368,9 @@ Focus on writing clean, maintainable Python code that follows community standard **Use Cases:** -- "Create a FastAPI service for user authentication with JWT tokens" -- "Implement a data processing pipeline with pandas and error handling" -- "Write a CLI tool using argparse with comprehensive help documentation" +- “Create a FastAPI service for user authentication with JWT tokens” +- “Implement a data processing pipeline with pandas and error handling” +- “Write a CLI tool using argparse with comprehensive help documentation” ## Best Practices @@ -400,11 +378,11 @@ Focus on writing clean, maintainable Python code that follows community standard #### Single Responsibility Principle -Each subagent should have a clear, focused purpose. +Each Subagent should have a clear, focused purpose. **✅ Good:** -```markdown +``` --- name: testing-expert description: Writes comprehensive unit tests and integration tests @@ -413,14 +391,14 @@ description: Writes comprehensive unit tests and integration tests **❌ Avoid:** -```markdown +``` --- name: general-helper description: Helps with testing, documentation, code review, and deployment --- ``` -**Why:** Focused agents produce better results and are easier to maintain. +**Why:** Focused agents produce better results and are easier to maintain. #### Clear Specialization @@ -428,7 +406,7 @@ Define specific expertise areas rather than broad capabilities. **✅ Good:** -```markdown +``` --- name: react-performance-optimizer description: Optimizes React applications for performance using profiling and best practices @@ -437,14 +415,14 @@ description: Optimizes React applications for performance using profiling and be **❌ Avoid:** -```markdown +``` --- name: frontend-developer description: Works on frontend development tasks --- ``` -**Why:** Specific expertise leads to more targeted and effective assistance. +**Why:** Specific expertise leads to more targeted and effective assistance. #### Actionable Descriptions @@ -452,17 +430,17 @@ Write descriptions that clearly indicate when to use the agent. **✅ Good:** -```markdown +``` description: Reviews code for security vulnerabilities, performance issues, and maintainability concerns ``` **❌ Avoid:** -```markdown +``` description: A helpful code reviewer ``` -**Why:** Clear descriptions help the main AI choose the right agent for each task. +**Why:** Clear descriptions help the main AI choose the right agent for each task. ### Configuration Best Practices @@ -470,7 +448,7 @@ description: A helpful code reviewer **Be Specific About Expertise:** -```markdown +``` You are a Python testing specialist with expertise in: - pytest framework and fixtures @@ -481,7 +459,7 @@ You are a Python testing specialist with expertise in: **Include Step-by-Step Approaches:** -```markdown +``` For each testing task: 1. Analyze the code structure and dependencies @@ -493,7 +471,7 @@ For each testing task: **Specify Output Standards:** -```markdown +``` Always follow these standards: - Use descriptive test names that explain the scenario @@ -506,7 +484,7 @@ Always follow these standards: - **Tool Restrictions**: Subagents only have access to their configured tools - **Sandboxing**: All tool execution follows the same security model as direct tool use -- **Audit Trail**: All subagent actions are logged and visible in real-time +- **Audit Trail**: All Subagents actions are logged and visible in real-time - **Access Control**: Project and user-level separation provides appropriate boundaries - **Sensitive Information**: Avoid including secrets or credentials in agent configurations - **Production Environments**: Consider separate agents for production vs development environments diff --git a/docs/users/features/token-caching.md b/docs/users/features/token-caching.md new file mode 100644 index 000000000..51381c94a --- /dev/null +++ b/docs/users/features/token-caching.md @@ -0,0 +1,29 @@ +# Token Caching and Cost Optimization + +Qwen Code automatically optimizes API costs through token caching when using API key authentication. This feature stores frequently used content like system instructions and conversation history to reduce the number of tokens processed in subsequent requests. + +## How It Benefits You + +- **Cost reduction**: Less tokens mean lower API costs +- **Faster responses**: Cached content is retrieved more quickly +- **Automatic optimization**: No configuration needed - it works behind the scenes + +## Token caching is available for + +- API key users (Qwen API key, OpenAI-compatible providers) + +## Monitoring Your Savings + +Use the `/stats` command to see your cached token savings: + +- When active, the stats display shows how many tokens were served from cache +- You'll see both the absolute number and percentage of cached tokens +- Example: "10,500 (90.4%) of input tokens were served from the cache, reducing costs." + +This information is only displayed when cached tokens are being used, which occurs with API key authentication but not with OAuth authentication. + +## Example Stats Display + +![Qwen Code Stats Display](https://img.alicdn.com/imgextra/i3/O1CN01F1yzRs1juyZu63jdS_!!6000000004609-2-tps-1038-738.png) + +The above image shows an example of the `/stats` command output, highlighting the cached token savings information. diff --git a/docs/ide-integration/_meta.ts b/docs/users/ide-integration/_meta.ts similarity index 100% rename from docs/ide-integration/_meta.ts rename to docs/users/ide-integration/_meta.ts diff --git a/docs/ide-integration/ide-companion-spec.md b/docs/users/ide-integration/ide-companion-spec.md similarity index 84% rename from docs/ide-integration/ide-companion-spec.md rename to docs/users/ide-integration/ide-companion-spec.md index 3cf35d759..37b0b8335 100644 --- a/docs/ide-integration/ide-companion-spec.md +++ b/docs/users/ide-integration/ide-companion-spec.md @@ -16,16 +16,15 @@ The plugin **MUST** run a local HTTP server that implements the **Model Context - **Endpoint:** The server should expose a single endpoint (e.g., `/mcp`) for all MCP communication. - **Port:** The server **MUST** listen on a dynamically assigned port (i.e., listen on port `0`). -### 2. Discovery Mechanism: The Port File +### 2. Discovery Mechanism: The Lock File -For Qwen Code to connect, it needs to discover which IDE instance it's running in and what port your server is using. The plugin **MUST** facilitate this by creating a "discovery file." +For Qwen Code to connect, it needs to discover what port your server is using. The plugin **MUST** facilitate this by creating a "lock file" and setting the port environment variable. -- **How the CLI Finds the File:** The CLI determines the Process ID (PID) of the IDE it's running in by traversing the process tree. It then looks for a discovery file that contains this PID in its name. -- **File Location:** The file must be created in a specific directory: `os.tmpdir()/qwen/ide/`. Your plugin must create this directory if it doesn't exist. +- **How the CLI Finds the File:** The CLI reads the port from `QWEN_CODE_IDE_SERVER_PORT`, then reads `~/.qwen/ide/.lock`. (Legacy fallbacks exist for older extensions; see note below.) +- **File Location:** The file must be created in a specific directory: `~/.qwen/ide/`. Your plugin must create this directory if it doesn't exist. - **File Naming Convention:** The filename is critical and **MUST** follow the pattern: - `qwen-code-ide-server-${PID}-${PORT}.json` - - `${PID}`: The process ID of the parent IDE process. Your plugin must determine this PID and include it in the filename. - - `${PORT}`: The port your MCP server is listening on. + `.lock` + - ``: The port your MCP server is listening on. - **File Content & Workspace Validation:** The file **MUST** contain a JSON object with the following structure: ```json @@ -33,21 +32,20 @@ For Qwen Code to connect, it needs to discover which IDE instance it's running i "port": 12345, "workspacePath": "/path/to/project1:/path/to/project2", "authToken": "a-very-secret-token", - "ideInfo": { - "name": "vscode", - "displayName": "VS Code" - } + "ppid": 1234, + "ideName": "VS Code" } ``` - `port` (number, required): The port of the MCP server. - `workspacePath` (string, required): A list of all open workspace root paths, delimited by the OS-specific path separator (`:` for Linux/macOS, `;` for Windows). The CLI uses this path to ensure it's running in the same project folder that's open in the IDE. If the CLI's current working directory is not a sub-directory of `workspacePath`, the connection will be rejected. Your plugin **MUST** provide the correct, absolute path(s) to the root of the open workspace(s). - `authToken` (string, required): A secret token for securing the connection. The CLI will include this token in an `Authorization: Bearer ` header on all requests. - - `ideInfo` (object, required): Information about the IDE. - - `name` (string, required): A short, lowercase identifier for the IDE (e.g., `vscode`, `jetbrains`). - - `displayName` (string, required): A user-friendly name for the IDE (e.g., `VS Code`, `JetBrains IDE`). + - `ppid` (number, required): The parent process ID of the IDE process. + - `ideName` (string, required): A user-friendly name for the IDE (e.g., `VS Code`, `JetBrains IDE`). - **Authentication:** To secure the connection, the plugin **MUST** generate a unique, secret token and include it in the discovery file. The CLI will then include this token in the `Authorization` header for all requests to the MCP server (e.g., `Authorization: Bearer a-very-secret-token`). Your server **MUST** validate this token on every request and reject any that are unauthorized. -- **Tie-Breaking with Environment Variables (Recommended):** For the most reliable experience, your plugin **SHOULD** both create the discovery file and set the `QWEN_CODE_IDE_SERVER_PORT` environment variable in the integrated terminal. The file serves as the primary discovery mechanism, but the environment variable is crucial for tie-breaking. If a user has multiple IDE windows open for the same workspace, the CLI uses the `QWEN_CODE_IDE_SERVER_PORT` variable to identify and connect to the correct window's server. +- **Environment Variables (Required):** Your plugin **MUST** set `QWEN_CODE_IDE_SERVER_PORT` in the integrated terminal so the CLI can locate the correct `.lock` file. + +**Legacy note:** For extensions older than v0.5.1, Qwen Code may fall back to reading JSON files in the system temp directory named `qwen-code-ide-server-.json` or `qwen-code-ide-server-.json`. New integrations should not rely on these legacy files. ## II. The Context Interface diff --git a/docs/ide-integration/ide-integration.md b/docs/users/ide-integration/ide-integration.md similarity index 99% rename from docs/ide-integration/ide-integration.md rename to docs/users/ide-integration/ide-integration.md index febcf4785..b0cdf9223 100644 --- a/docs/ide-integration/ide-integration.md +++ b/docs/users/ide-integration/ide-integration.md @@ -2,7 +2,7 @@ Qwen Code can integrate with your IDE to provide a more seamless and context-aware experience. This integration allows the CLI to understand your workspace better and enables powerful features like native in-editor diffing. -Currently, the only supported IDE is [Visual Studio Code](https://code.visualstudio.com/) and other editors that support VS Code extensions. To build support for other editors, see the [IDE Companion Extension Spec](./ide-companion-spec.md). +Currently, the only supported IDE is [Visual Studio Code](https://code.visualstudio.com/) and other editors that support VS Code extensions. To build support for other editors, see the [IDE Companion Extension Spec](../ide-integration/ide-companion-spec). ## Features diff --git a/docs/users/integration-github-action.md b/docs/users/integration-github-action.md new file mode 100644 index 000000000..281967dd8 --- /dev/null +++ b/docs/users/integration-github-action.md @@ -0,0 +1,241 @@ +# Github Actions:qwen-code-action + +## Overview + +`qwen-code-action` is a GitHub Action that integrates [Qwen Code] into your development workflow via the [Qwen Code CLI]. It acts both as an autonomous agent for critical routine coding tasks, and an on-demand collaborator you can quickly delegate work to. + +Use it to perform GitHub pull request reviews, triage issues, perform code analysis and modification, and more using [Qwen Code] conversationally (e.g., `@qwencoder fix this issue`) directly inside your GitHub repositories. + +## Features + +- **Automation**: Trigger workflows based on events (e.g. issue opening) or schedules (e.g. nightly). +- **On-demand Collaboration**: Trigger workflows in issue and pull request + comments by mentioning the [Qwen Code CLI](./features/commands) (e.g., `@qwencoder /review`). +- **Extensible with Tools**: Leverage [Qwen Code](../developers/tools/introduction.md) models' tool-calling capabilities to interact with other CLIs like the [GitHub CLI] (`gh`). +- **Customizable**: Use a `QWEN.md` file in your repository to provide + project-specific instructions and context to [Qwen Code CLI](./features/commands). + +## Quick Start + +Get started with Qwen Code CLI in your repository in just a few minutes: + +### 1. Get a Qwen API Key + +Obtain your API key from [DashScope](https://help.aliyun.com/zh/model-studio/qwen-code) (Alibaba Cloud's AI platform) + +### 2. Add it as a GitHub Secret + +Store your API key as a secret named `QWEN_API_KEY` in your repository: + +- Go to your repository's **Settings > Secrets and variables > Actions** +- Click **New repository secret** +- Name: `QWEN_API_KEY`, Value: your API key + +### 3. Update your .gitignore + +Add the following entries to your `.gitignore` file: + +```gitignore +# qwen-code-cli settings +.qwen/ + +# GitHub App credentials +gha-creds-*.json +``` + +### 4. Choose a Workflow + +You have two options to set up a workflow: + +**Option A: Use setup command (Recommended)** + +1. Start the Qwen Code CLI in your terminal: + + ```shell + qwen + ``` + +2. In Qwen Code CLI in your terminal, type: + + ``` + /setup-github + ``` + +**Option B: Manually copy workflows** + +1. Copy the pre-built workflows from the [`examples/workflows`](./common-workflow) directory to your repository's `.github/workflows` directory. Note: the `qwen-dispatch.yml` workflow must also be copied, which triggers the workflows to run. + +### 5. Try it out + +**Pull Request Review:** + +- Open a pull request in your repository and wait for automatic review +- Comment `@qwencoder /review` on an existing pull request to manually trigger a review + +**Issue Triage:** + +- Open an issue and wait for automatic triage +- Comment `@qwencoder /triage` on existing issues to manually trigger triaging + +**General AI Assistance:** + +- In any issue or pull request, mention `@qwencoder` followed by your request +- Examples: + - `@qwencoder explain this code change` + - `@qwencoder suggest improvements for this function` + - `@qwencoder help me debug this error` + - `@qwencoder write unit tests for this component` + +## Workflows + +This action provides several pre-built workflows for different use cases. Each workflow is designed to be copied into your repository's `.github/workflows` directory and customized as needed. + +### Qwen Code Dispatch + +This workflow acts as a central dispatcher for Qwen Code CLI, routing requests to the appropriate workflow based on the triggering event and the command provided in the comment. For a detailed guide on how to set up the dispatch workflow, go to the [Qwen Code Dispatch workflow documentation](./common-workflow). + +### Issue Triage + +This action can be used to triage GitHub Issues automatically or on a schedule. For a detailed guide on how to set up the issue triage system, go to the [GitHub Issue Triage workflow documentation](./examples/workflows/issue-triage). + +### Pull Request Review + +This action can be used to automatically review pull requests when they are opened. For a detailed guide on how to set up the pull request review system, go to the [GitHub PR Review workflow documentation](./common-workflow). + +### Qwen Code CLI Assistant + +This type of action can be used to invoke a general-purpose, conversational Qwen Code AI assistant within the pull requests and issues to perform a wide range of tasks. For a detailed guide on how to set up the general-purpose Qwen Code CLI workflow, go to the [Qwen Code Assistant workflow documentation](./common-workflow). + +## Configuration + +### Inputs + + + +- qwen*api_key: *(Optional)\_ The API key for the Qwen API. + +- qwen*cli_version: *(Optional, default: `latest`)\_ The version of the Qwen Code CLI to install. Can be "latest", "preview", "nightly", a specific version number, or a git branch, tag, or commit. For more information, see [Qwen Code CLI releases](https://github.com/QwenLM/qwen-code-action/blob/main/docs/releases.md). + +- qwen*debug: *(Optional)\_ Enable debug logging and output streaming. + +- qwen*model: *(Optional)\_ The model to use with Qwen Code. + +- prompt: _(Optional, default: `You are a helpful assistant.`)_ A string passed to the Qwen Code CLI's [`--prompt` argument](https://github.com/QwenLM/qwen-code-action/blob/main/docs/cli/configuration.md#command-line-arguments). + +- settings: _(Optional)_ A JSON string written to `.qwen/settings.json` to configure the CLI's _project_ settings. + For more details, see the documentation on [settings files](https://github.com/QwenLM/qwen-code-action/blob/main/docs/cli/configuration.md#settings-files). + +- use*qwen_code_assist: *(Optional, default: `false`)\_ Whether to use Code Assist for Qwen Code model access instead of the default Qwen Code API key. + For more information, see the [Qwen Code CLI documentation](https://github.com/QwenLM/qwen-code-action/blob/main/docs/cli/authentication.md). + +- use*vertex_ai: *(Optional, default: `false`)\_ Whether to use Vertex AI for Qwen Code model access instead of the default Qwen Code API key. + For more information, see the [Qwen Code CLI documentation](https://github.com/QwenLM/qwen-code-action/blob/main/docs/cli/authentication.md). + +- extensions: _(Optional)_ A list of Qwen Code CLI extensions to install. + +- upload*artifacts: *(Optional, default: `false`)\_ Whether to upload artifacts to the github action. + +- use*pnpm: *(Optional, default: `false`)\_ Whether or not to use pnpm instead of npm to install qwen-code-cli + +- workflow*name: *(Optional, default: `${{ github.workflow }}`)\_ The GitHub workflow name, used for telemetry purposes. + + + +### Outputs + + + +- summary: The summarized output from the Qwen Code CLI execution. + +- error: The error output from the Qwen Code CLI execution, if any. + + + +### Repository Variables + +We recommend setting the following values as repository variables so they can be reused across all workflows. Alternatively, you can set them inline as action inputs in individual workflows or to override repository-level values. + +| Name | Description | Type | Required | When Required | +| ------------------ | --------------------------------------------------------- | -------- | -------- | ------------------------- | +| `DEBUG` | Enables debug logging for the Qwen Code CLI. | Variable | No | Never | +| `QWEN_CLI_VERSION` | Controls which version of the Qwen Code CLI is installed. | Variable | No | Pinning the CLI version | +| `APP_ID` | GitHub App ID for custom authentication. | Variable | No | Using a custom GitHub App | + +To add a repository variable: + +1. Go to your repository's **Settings > Secrets and variables > Actions > New variable**. +2. Enter the variable name and value. +3. Save. + +For details about repository variables, refer to the [GitHub documentation on variables][variables]. + +### Secrets + +You can set the following secrets in your repository: + +| Name | Description | Required | When Required | +| ----------------- | --------------------------------------------- | -------- | ------------------------------------------ | +| `QWEN_API_KEY` | Your Qwen API key from DashScope. | Yes | Required for all workflows that call Qwen. | +| `APP_PRIVATE_KEY` | Private key for your GitHub App (PEM format). | No | Using a custom GitHub App. | + +To add a secret: + +1. Go to your repository's **Settings > Secrets and variables >Actions > New repository secret**. +2. Enter the secret name and value. +3. Save. + +For more information, refer to the [official GitHub documentation on creating and using encrypted secrets][secrets]. + +## Authentication + +This action requires authentication to the GitHub API and optionally to Qwen Code services. + +### GitHub Authentication + +You can authenticate with GitHub in two ways: + +1. **Default `GITHUB_TOKEN`:** For simpler use cases, the action can use the + default `GITHUB_TOKEN` provided by the workflow. +2. **Custom GitHub App (Recommended):** For the most secure and flexible + authentication, we recommend creating a custom GitHub App. + +For detailed setup instructions for both Qwen and GitHub authentication, go to the +[**Authentication documentation**](./configuration/auth). + +## Extensions + +The Qwen Code CLI can be extended with additional functionality through extensions. +These extensions are installed from source from their GitHub repositories. + +For detailed instructions on how to set up and configure extensions, go to the +[Extensions documentation](../developers/extensions/extension). + +## Best Practices + +To ensure the security, reliability, and efficiency of your automated workflows, we strongly recommend following our best practices. These guidelines cover key areas such as repository security, workflow configuration, and monitoring. + +Key recommendations include: + +- **Securing Your Repository:** Implementing branch and tag protection, and restricting pull request approvers. +- **Monitoring and Auditing:** Regularly reviewing action logs and enabling OpenTelemetry for deeper insights into performance and behavior. + +For a comprehensive guide on securing your repository and workflows, please refer to our [**Best Practices documentation**](./common-workflow). + +## Customization + +Create a QWEN.md file in the root of your repository to provide +project-specific context and instructions to [Qwen Code CLI](./common-workflow). This is useful for defining +coding conventions, architectural patterns, or other guidelines the model should +follow for a given repository. + +## Contributing + +Contributions are welcome! Check out the Qwen Code CLI **Contributing Guide** for more details on how to get started. + +[secrets]: https://docs.github.com/en/actions/security-guides/using-secrets-in-github-actions +[Qwen Code]: https://github.com/QwenLM/qwen-code +[DashScope]: https://dashscope.console.aliyun.com/apiKey +[Qwen Code CLI]: https://github.com/QwenLM/qwen-code-action/ +[variables]: https://docs.github.com/en/actions/how-tos/write-workflows/choose-what-workflows-do/use-variables#creating-configuration-variables-for-a-repository +[GitHub CLI]: https://docs.github.com/en/github-cli/github-cli +[QWEN.md]: https://github.com/QwenLM/qwen-code-action/blob/main/docs/cli/configuration.md#context-files-hierarchical-instructional-context diff --git a/docs/users/integration-vscode.md b/docs/users/integration-vscode.md new file mode 100644 index 000000000..b12de7858 --- /dev/null +++ b/docs/users/integration-vscode.md @@ -0,0 +1,45 @@ +# Visual Studio Code + +> The VS Code extension (Beta) lets you see Qwen's changes in real-time through a native graphical interface integrated directly into your IDE, making it easier to access and interact with Qwen Code. + +
+ + + +### Features + +- **Native IDE experience**: Dedicated Qwen Code sidebar panel accessed via the Qwen icon +- **Auto-accept edits mode**: Automatically apply Qwen's changes as they're made +- **File management**: @-mention files or attach files and images using the system file picker +- **Conversation history**: Access to past conversations +- **Multiple sessions**: Run multiple Qwen Code sessions simultaneously + +### Requirements + +- VS Code 1.98.0 or higher + +### Installation + +1. Install Qwen Code CLI: + + ```bash + npm install -g qwen-code + ``` + +2. Download and install the extension from the [Visual Studio Code Extension Marketplace](https://marketplace.visualstudio.com/items?itemName=qwenlm.qwen-code-vscode-ide-companion). + +## Troubleshooting + +### Extension not installing + +- Ensure you have VS Code 1.98.0 or higher +- Check that VS Code has permission to install extensions +- Try installing directly from the Marketplace website + +### Qwen Code not responding + +- Check your internet connection +- Start a new conversation to see if the issue persists +- [File an issue on GitHub](https://github.com/qwenlm/qwen-code/issues) if the problem continues diff --git a/docs/users/integration-zed.md b/docs/users/integration-zed.md new file mode 100644 index 000000000..cd4cb2ae4 --- /dev/null +++ b/docs/users/integration-zed.md @@ -0,0 +1,54 @@ +# Zed Editor + +> Zed Editor provides native support for AI coding assistants through the Agent Control Protocol (ACP). This integration allows you to use Qwen Code directly within Zed's interface with real-time code suggestions. + +![Zed Editor Overview](https://img.alicdn.com/imgextra/i1/O1CN01aAhU311GwEoNh27FP_!!6000000000686-2-tps-3024-1898.png) + +### Features + +- **Native agent experience**: Integrated AI assistant panel within Zed's interface +- **Agent Client Protocol**: Full support for ACP enabling advanced IDE interactions +- **File management**: @-mention files to add them to the conversation context +- **Conversation history**: Access to past conversations within Zed + +### Requirements + +- Zed Editor (latest version recommended) +- Qwen Code CLI installed + +### Installation + +1. Install Qwen Code CLI: + + ```bash + npm install -g qwen-code + ``` + +2. Download and install [Zed Editor](https://zed.dev/) + +3. In Zed, click the **settings button** in the top right corner, select **"Add agent"**, choose **"Create a custom agent"**, and add the following configuration: + +```json +"Qwen Code": { + "type": "custom", + "command": "qwen", + "args": ["--experimental-acp"], + "env": {} +} +``` + +![Qwen Code Integration](https://img.alicdn.com/imgextra/i1/O1CN013s61L91dSE1J7MTgO_!!6000000003734-2-tps-2592-1234.png) + +## Troubleshooting + +### Agent not appearing + +- Run `qwen --version` in terminal to verify installation +- Check that the JSON configuration is valid +- Restart Zed Editor + +### Qwen Code not responding + +- Check your internet connection +- Verify CLI works by running `qwen` in terminal +- [File an issue on GitHub](https://github.com/qwenlm/qwen-code/issues) if the problem persists diff --git a/docs/users/overview.md b/docs/users/overview.md new file mode 100644 index 000000000..31a16040c --- /dev/null +++ b/docs/users/overview.md @@ -0,0 +1,62 @@ +# Qwen Code overview + +> Learn about Qwen Code, Qwen's agentic coding tool that lives in your terminal and helps you turn ideas into code faster than ever before. + +## Get started in 30 seconds + +Prerequisites: + +- A [Qwen Code](https://chat.qwen.ai/auth?mode=register) account +- Requires [Node.js 20+](https://nodejs.org/zh-cn/download), you can use `node -v` to check the version. If it's not installed, use the following command to install it. + +### Install Qwen Code: + +**NPM**(recommended) + +```bash +npm install -g @qwen-code/qwen-code@latest +``` + +**Homebrew**(macOS, Linux) + +```bash +brew install qwen-code +``` + +### Start using Qwen Code: + +```bash +cd your-project +qwen +``` + +Select **Qwen OAuth (Free)** authentication and follow the prompts to log in. Then let's start with understanding your codebase. Try one of these commands: + +``` +what does this project do? +``` + +![](https://cloud.video.taobao.com/vod/j7-QtQScn8UEAaEdiv619fSkk5p-t17orpDbSqKVL5A.mp4) + +You'll be prompted to log in on first use. That's it! [Continue with Quickstart (5 mins) →](./quickstart) + +> [!tip] +> +> See [troubleshooting](./support/troubleshooting) if you hit issues. + +> [!note] +> +> **New VS Code Extension (Beta)**: Prefer a graphical interface? Our new **VS Code extension** provides an easy-to-use native IDE experience without requiring terminal familiarity. Simply install from the marketplace and start coding with Qwen Code directly in your sidebar. You can search for **Qwen Code** in the VS Code Marketplace and download it. + +## What Qwen Code does for you + +- **Build features from descriptions**: Tell Qwen Code what you want to build in plain language. It will make a plan, write the code, and ensure it works. +- **Debug and fix issues**: Describe a bug or paste an error message. Qwen Code will analyze your codebase, identify the problem, and implement a fix. +- **Navigate any codebase**: Ask anything about your team's codebase, and get a thoughtful answer back. Qwen Code maintains awareness of your entire project structure, can find up-to-date information from the web, and with [MCP](./features/mcp) can pull from external datasources like Google Drive, Figma, and Slack. +- **Automate tedious tasks**: Fix fiddly lint issues, resolve merge conflicts, and write release notes. Do all this in a single command from your developer machines, or automatically in CI. + +## Why developers love Qwen Code + +- **Works in your terminal**: Not another chat window. Not another IDE. Qwen Code meets you where you already work, with the tools you already love. +- **Takes action**: Qwen Code can directly edit files, run commands, and create commits. Need more? [MCP](./features/mcp) lets Qwen Code read your design docs in Google Drive, update your tickets in Jira, or use _your_ custom developer tooling. +- **Unix philosophy**: Qwen Code is composable and scriptable. `tail -f app.log | qwen -p "Slack me if you see any anomalies appear in this log stream"` _works_. Your CI can run `qwen -p "If there are new text strings, translate them into French and raise a PR for @lang-fr-team to review"`. diff --git a/docs/users/quickstart.md b/docs/users/quickstart.md new file mode 100644 index 000000000..1fa249abc --- /dev/null +++ b/docs/users/quickstart.md @@ -0,0 +1,251 @@ +# Quickstart + +> 👏 Welcome to Qwen Code! + +This quickstart guide will have you using AI-powered coding assistance in just a few minutes. By the end, you'll understand how to use Qwen Code for common development tasks. + +## Before you begin + +Make sure you have: + +- A **terminal** or command prompt open +- A code project to work with +- A [Qwen Code](https://chat.qwen.ai/auth?mode=register) account + +## Step 1: Install Qwen Code + +To install Qwen Code, use one of the following methods: + +### NPM (recommended) + +Requires [Node.js 20+](https://nodejs.org/download), you can use `node -v` check the version. If it's not installed, use the following command to install it. + +If you have [Node.js or newer installed](https://nodejs.org/en/download/): + +```sh +npm install -g @qwen-code/qwen-code@latest +``` + +### Homebrew (macOS, Linux) + +```sh +brew install qwen-code +``` + +## Step 2: Log in to your account + +Qwen Code requires an account to use. When you start an interactive session with the `qwen` command, you'll need to log in: + +```bash +# You'll be prompted to log in on first use +qwen +``` + +```bash +# Follow the prompts to log in with your account +/auth +``` + +Select `Qwen OAuth`, log in to your account and follow the prompts to confirm. Once logged in, your credentials are stored and you won't need to log in again. + +> [!note] +> +> When you first authenticate Qwen Code with your Qwen account, a workspace called ".qwen" is automatically created for you. This workspace provides centralized cost tracking and management for all Qwen Code usage in your organization. + +> [!tip] +> +> If you need to log in again or switch accounts, use the `/auth` command within Qwen Code. + +## Step 3: Start your first session + +Open your terminal in any project directory and start Qwen Code: + +```bash +# optiona +cd /path/to/your/project +# start qwen +qwen +``` + +You'll see the Qwen Code welcome screen with your session information, recent conversations, and latest updates. Type `/help` for available commands. + +## Chat with Qwen Code + +### Ask your first question + +Qwen Code will analyze your files and provide a summary. You can also ask more specific questions: + +``` +explain the folder structure +``` + +You can also ask Qwen Code about its own capabilities: + +``` +what can Qwen Code do? +``` + +> [!note] +> +> Qwen Code reads your files as needed - you don't have to manually add context. Qwen Code also has access to its own documentation and can answer questions about its features and capabilities. + +### Make your first code change + +Now let's make Qwen Code do some actual coding. Try a simple task: + +``` +add a hello world function to the main file +``` + +Qwen Code will: + +1. Find the appropriate file +2. Show you the proposed changes +3. Ask for your approval +4. Make the edit + +> [!note] +> +> Qwen Code always asks for permission before modifying files. You can approve individual changes or enable "Accept all" mode for a session. + +### Use Git with Qwen Code + +Qwen Code makes Git operations conversational: + +``` +what files have I changed? +``` + +``` +commit my changes with a descriptive message +``` + +You can also prompt for more complex Git operations: + +``` +create a new branch called feature/quickstart +``` + +``` +show me the last 5 commits +``` + +``` +help me resolve merge conflicts +``` + +### Fix a bug or add a feature + +Qwen Code is proficient at debugging and feature implementation. + +Describe what you want in natural language: + +``` +add input validation to the user registration form +``` + +Or fix existing issues: + +``` +there's a bug where users can submit empty forms - fix it +``` + +Qwen Code will: + +- Locate the relevant code +- Understand the context +- Implement a solution +- Run tests if available + +### Test out other common workflows + +There are a number of ways to work with Claude: + +**Refactor code** + +``` +refactor the authentication module to use async/await instead of callbacks +``` + +**Write tests** + +``` +write unit tests for the calculator functions +``` + +**Update documentation** + +``` +update the README with installation instructions +``` + +**Code review** + +``` +review my changes and suggest improvements +``` + +> [!tip] +> +> **Remember**: Qwen Code is your AI pair programmer. Talk to it like you would a helpful colleague - describe what you want to achieve, and it will help you get there. + +## Essential commands + +Here are the most important commands for daily use: + +| Command | What it does | Example | +| --------------------- | ------------------------------------------------ | ----------------------------- | +| `qwen` | start Qwen Code | `qwen` | +| `/auth` | Change authentication method | `/auth` | +| `/help` | Display help information for available commands | `/help` or `/?` | +| `/compress` | Replace chat history with summary to save Tokens | `/compress` | +| `/clear` | Clear terminal screen content | `/clear` (shortcut: `Ctrl+L`) | +| `/theme` | Change Qwen Code visual theme | `/theme` | +| `/language` | View or change language settings | `/language` | +| → `ui [language]` | Set UI interface language | `/language ui zh-CN` | +| → `output [language]` | Set LLM output language | `/language output Chinese` | +| `/quit` | Exit Qwen Code immediately | `/quit` or `/exit` | + +See the [CLI reference](./features/commands) for a complete list of commands. + +## Pro tips for beginners + +**Be specific with your requests** + +- Instead of: "fix the bug" +- Try: "fix the login bug where users see a blank screen after entering wrong credentials" + +**Use step-by-step instructions** + +- Break complex tasks into steps: + +``` +1. create a new database table for user profiles +2. create an API endpoint to get and update user profiles +3. build a webpage that allows users to see and edit their information +``` + +**Let Qwen Code explore first** + +- Before making changes, let Qwen Code understand your code: + +``` +analyze the database schema +``` + +``` +build a dashboard showing products that are most frequently returned by our UK customers +``` + +**Save time with shortcuts** + +- Press `?` to see all available keyboard shortcuts +- Use Tab for command completion +- Press ↑ for command history +- Type `/` to see all slash commands + +## Getting help + +- **In Qwen Code**: Type `/help` or ask "how do I..." +- **Documentation**: You're here! Browse other guides +- **Community**: Join our [GitHub Discussion](https://github.com/QwenLM/qwen-code/discussions) for tips and support diff --git a/docs/users/reference/_meta.ts b/docs/users/reference/_meta.ts new file mode 100644 index 000000000..a4c232e86 --- /dev/null +++ b/docs/users/reference/_meta.ts @@ -0,0 +1,3 @@ +export default { + 'keyboard-shortcuts': 'Keyboard Shortcuts', +}; diff --git a/docs/cli/keyboard-shortcuts.md b/docs/users/reference/keyboard-shortcuts.md similarity index 100% rename from docs/cli/keyboard-shortcuts.md rename to docs/users/reference/keyboard-shortcuts.md diff --git a/docs/cli/Uninstall.md b/docs/users/support/Uninstall.md similarity index 86% rename from docs/cli/Uninstall.md rename to docs/users/support/Uninstall.md index bdc7ae653..f8970c883 100644 --- a/docs/cli/Uninstall.md +++ b/docs/users/support/Uninstall.md @@ -1,4 +1,4 @@ -# Uninstalling the CLI +# Uninstall Your uninstall method depends on how you ran the CLI. Follow the instructions for either npx or a global npm installation. @@ -33,7 +33,7 @@ Remove-Item -Path (Join-Path $env:LocalAppData "npm-cache\_npx") -Recurse -Force ## Method 2: Using npm (Global Install) -If you installed the CLI globally (e.g., `npm install -g @qwen-code/qwen-code`), use the `npm uninstall` command with the `-g` flag to remove it. +If you installed the CLI globally (e.g. `npm install -g @qwen-code/qwen-code`), use the `npm uninstall` command with the `-g` flag to remove it. ```bash npm uninstall -g @qwen-code/qwen-code diff --git a/docs/support/_meta.ts b/docs/users/support/_meta.ts similarity index 77% rename from docs/support/_meta.ts rename to docs/users/support/_meta.ts index 9140d4fe7..1407565ae 100644 --- a/docs/support/_meta.ts +++ b/docs/users/support/_meta.ts @@ -1,4 +1,6 @@ export default { troubleshooting: 'Troubleshooting', 'tos-privacy': 'Terms of Service', + + Uninstall: 'Uninstall', }; diff --git a/docs/support/tos-privacy.md b/docs/users/support/tos-privacy.md similarity index 82% rename from docs/support/tos-privacy.md rename to docs/users/support/tos-privacy.md index 046b1d086..aa0d5c471 100644 --- a/docs/support/tos-privacy.md +++ b/docs/users/support/tos-privacy.md @@ -23,19 +23,21 @@ When you authenticate using your qwen.ai account, these Terms of Service and Pri - **Terms of Service:** Your use is governed by the [Qwen Terms of Service](https://qwen.ai/termsservice). - **Privacy Notice:** The collection and use of your data is described in the [Qwen Privacy Policy](https://qwen.ai/privacypolicy). -For details about authentication setup, quotas, and supported features, see [Authentication Setup](./cli/authentication.md). +For details about authentication setup, quotas, and supported features, see [Authentication Setup](../configuration/settings). ## 2. If you are using OpenAI-Compatible API Authentication When you authenticate using API keys from OpenAI-compatible providers, the applicable Terms of Service and Privacy Notice depend on your chosen provider. -**Important:** When using OpenAI-compatible API authentication, you are subject to the terms and privacy policies of your chosen API provider, not Qwen Code's terms. Please review your provider's documentation for specific details about data usage, retention, and privacy practices. +> [!important] +> +> When using OpenAI-compatible API authentication, you are subject to the terms and privacy policies of your chosen API provider, not Qwen Code's terms. Please review your provider's documentation for specific details about data usage, retention, and privacy practices. Qwen Code supports various OpenAI-compatible providers. Please refer to your specific provider's terms of service and privacy policy for detailed information. ## Usage Statistics and Telemetry -Qwen Code may collect anonymous usage statistics and telemetry data to improve the user experience and product quality. This data collection is optional and can be controlled through configuration settings. +Qwen Code may collect anonymous usage statistics and [telemetry](../../developers/development/telemetry) data to improve the user experience and product quality. This data collection is optional and can be controlled through configuration settings. ### What Data is Collected @@ -50,10 +52,6 @@ When enabled, Qwen Code may collect: - **Qwen OAuth:** Usage statistics are governed by Qwen's privacy policy. You can opt-out through Qwen Code's configuration settings. - **OpenAI-Compatible API:** No additional data is collected by Qwen Code beyond what your chosen API provider collects. -### Opt-Out Instructions - -You can disable usage statistics collection by following the instructions in the [Usage Statistics Configuration](./cli/configuration.md#usage-statistics) documentation. - ## Frequently Asked Questions (FAQ) ### 1. Is my code, including prompts and answers, used to train AI models? @@ -85,8 +83,6 @@ When enabled, Qwen Code may collect: The Usage Statistics setting only controls data collection by Qwen Code itself. It does not affect what data your chosen AI service provider (Qwen, OpenAI, etc.) may collect according to their own privacy policies. -You can disable Usage Statistics collection by following the instructions in the [Usage Statistics Configuration](./cli/configuration.md#usage-statistics) documentation. - ### 3. How do I switch between authentication methods? You can switch between Qwen OAuth and OpenAI-compatible API authentication at any time: @@ -95,4 +91,4 @@ You can switch between Qwen OAuth and OpenAI-compatible API authentication at an 2. **Within the CLI**: Use the `/auth` command to reconfigure your authentication method 3. **Environment variables**: Set up `.env` files for automatic OpenAI-compatible API authentication -For detailed instructions, see the [Authentication Setup](./cli/authentication.md) documentation. +For detailed instructions, see the [Authentication Setup](../configuration/settings#environment-variables-for-api-access) documentation. diff --git a/docs/support/troubleshooting.md b/docs/users/support/troubleshooting.md similarity index 90% rename from docs/support/troubleshooting.md rename to docs/users/support/troubleshooting.md index f654c1f64..5ea16b3cb 100644 --- a/docs/support/troubleshooting.md +++ b/docs/users/support/troubleshooting.md @@ -1,4 +1,4 @@ -# Troubleshooting guide +# Troubleshooting This guide provides solutions to common issues and debugging tips, including topics on: @@ -31,7 +31,7 @@ This guide provides solutions to common issues and debugging tips, including top 1. In your home directory: `~/.qwen/settings.json`. 2. In your project's root directory: `./.qwen/settings.json`. - Refer to [Qwen Code Configuration](./cli/configuration.md) for more details. + Refer to [Qwen Code Configuration](../configuration/settings) for more details. - **Q: Why don't I see cached token counts in my stats output?** - A: Cached token information is only displayed when cached tokens are being used. This feature is available for API key users (Qwen API key or Google Cloud Vertex AI) but not for OAuth users (such as Google Personal/Enterprise accounts like Google Gmail or Google Workspace, respectively). This is because the Qwen Code Assist API does not support cached content creation. You can still view your total token usage using the `/stats` command. @@ -48,7 +48,7 @@ This guide provides solutions to common issues and debugging tips, including top - **Solution:** The update depends on how you installed Qwen Code: - If you installed `qwen` globally, check that your `npm` global binary directory is in your `PATH`. You can update using the command `npm install -g @qwen-code/qwen-code@latest`. - - If you are running `qwen` from source, ensure you are using the correct command to invoke it (e.g., `node packages/cli/dist/index.js ...`). To update, pull the latest changes from the repository, and then rebuild using the command `npm run build`. + - If you are running `qwen` from source, ensure you are using the correct command to invoke it (e.g. `node packages/cli/dist/index.js ...`). To update, pull the latest changes from the repository, and then rebuild using the command `npm run build`. - **Error: `MODULE_NOT_FOUND` or import errors.** - **Cause:** Dependencies are not installed correctly, or the project hasn't been built. @@ -59,12 +59,12 @@ This guide provides solutions to common issues and debugging tips, including top - **Error: "Operation not permitted", "Permission denied", or similar.** - **Cause:** When sandboxing is enabled, Qwen Code may attempt operations that are restricted by your sandbox configuration, such as writing outside the project directory or system temp directory. - - **Solution:** Refer to the [Configuration: Sandboxing](./cli/configuration.md#sandboxing) documentation for more information, including how to customize your sandbox configuration. + - **Solution:** Refer to the [Configuration: Sandboxing](../features/sandbox) documentation for more information, including how to customize your sandbox configuration. - **Qwen Code is not running in interactive mode in "CI" environments** - - **Issue:** Qwen Code does not enter interactive mode (no prompt appears) if an environment variable starting with `CI_` (e.g., `CI_TOKEN`) is set. This is because the `is-in-ci` package, used by the underlying UI framework, detects these variables and assumes a non-interactive CI environment. + - **Issue:** Qwen Code does not enter interactive mode (no prompt appears) if an environment variable starting with `CI_` (e.g. `CI_TOKEN`) is set. This is because the `is-in-ci` package, used by the underlying UI framework, detects these variables and assumes a non-interactive CI environment. - **Cause:** The `is-in-ci` package checks for the presence of `CI`, `CONTINUOUS_INTEGRATION`, or any environment variable with a `CI_` prefix. When any of these are found, it signals that the environment is non-interactive, which prevents the CLI from starting in its interactive mode. - - **Solution:** If the `CI_` prefixed variable is not needed for the CLI to function, you can temporarily unset it for the command. e.g., `env -u CI_TOKEN qwen` + - **Solution:** If the `CI_` prefixed variable is not needed for the CLI to function, you can temporarily unset it for the command. e.g. `env -u CI_TOKEN qwen` - **DEBUG mode not working from project .env file** - **Issue:** Setting `DEBUG=true` in a project's `.env` file doesn't enable debug mode for the CLI. @@ -88,7 +88,7 @@ The Qwen Code uses specific exit codes to indicate the reason for termination. T | --------- | -------------------------- | --------------------------------------------------------------------------------------------------- | | 41 | `FatalAuthenticationError` | An error occurred during the authentication process. | | 42 | `FatalInputError` | Invalid or missing input was provided to the CLI. (non-interactive mode only) | -| 44 | `FatalSandboxError` | An error occurred with the sandboxing environment (e.g., Docker, Podman, or Seatbelt). | +| 44 | `FatalSandboxError` | An error occurred with the sandboxing environment (e.g. Docker, Podman, or Seatbelt). | | 52 | `FatalConfigError` | A configuration file (`settings.json`) is invalid or contains errors. | | 53 | `FatalTurnLimitedError` | The maximum number of conversational turns for the session was reached. (non-interactive mode only) | @@ -101,7 +101,7 @@ The Qwen Code uses specific exit codes to indicate the reason for termination. T - **Core debugging:** - Check the server console output for error messages or stack traces. - Increase log verbosity if configurable. - - Use Node.js debugging tools (e.g., `node --inspect`) if you need to step through server-side code. + - Use Node.js debugging tools (e.g. `node --inspect`) if you need to step through server-side code. - **Tool issues:** - If a specific tool is failing, try to isolate the issue by running the simplest possible version of the command or operation the tool performs. diff --git a/eslint.config.js b/eslint.config.js index 5b3b7f3d0..26ec8edf8 100644 --- a/eslint.config.js +++ b/eslint.config.js @@ -75,6 +75,8 @@ export default tseslint.config( }, }, rules: { + // We use TypeScript for React components; prop-types are unnecessary + 'react/prop-types': 'off', // General Best Practice Rules (subset adapted for flat config) '@typescript-eslint/array-type': ['error', { default: 'array-simple' }], 'arrow-body-style': ['error', 'as-needed'], @@ -111,10 +113,14 @@ export default tseslint.config( { allow: [ 'react-dom/test-utils', + 'react-dom/client', 'memfs/lib/volume.js', 'yargs/**', 'msw/node', - '**/generated/**' + '**/generated/**', + './styles/tailwind.css', + './styles/App.css', + './styles/style.css' ], }, ], @@ -257,4 +263,25 @@ export default tseslint.config( ], }, }, + // Settings for docs-site directory + { + files: ['docs-site/**/*.{js,jsx}'], + languageOptions: { + globals: { + ...globals.browser, + ...globals.node, + }, + parserOptions: { + ecmaFeatures: { + jsx: true, + }, + }, + }, + rules: { + // Allow relaxed rules for documentation site + '@typescript-eslint/no-unused-vars': 'off', + 'react/prop-types': 'off', + 'react/react-in-jsx-scope': 'off', + }, + }, ); diff --git a/integration-tests/acp-integration.test.ts b/integration-tests/acp-integration.test.ts index b098e0251..31e32da76 100644 --- a/integration-tests/acp-integration.test.ts +++ b/integration-tests/acp-integration.test.ts @@ -13,8 +13,6 @@ import { TestRig } from './test-helper.js'; const REQUEST_TIMEOUT_MS = 60_000; const INITIAL_PROMPT = 'Create a quick note (smoke test).'; -const RESUME_PROMPT = 'Continue the note after reload.'; -const LIST_SIZE = 5; const IS_SANDBOX = process.env['GEMINI_SANDBOX'] && process.env['GEMINI_SANDBOX']!.toLowerCase() !== 'false'; @@ -25,6 +23,14 @@ type PendingRequest = { timeout: NodeJS.Timeout; }; +type UsageMetadata = { + promptTokens?: number | null; + completionTokens?: number | null; + thoughtsTokens?: number | null; + totalTokens?: number | null; + cachedTokens?: number | null; +}; + type SessionUpdateNotification = { sessionId?: string; update?: { @@ -39,6 +45,9 @@ type SessionUpdateNotification = { text?: string; }; modeId?: string; + _meta?: { + usage?: UsageMetadata; + }; }; }; @@ -86,10 +95,14 @@ function setupAcpTest( const permissionHandler = options?.permissionHandler ?? (() => ({ optionId: 'proceed_once' })); - const agent = spawn('node', [rig.bundlePath, '--experimental-acp'], { - cwd: rig.testDir!, - stdio: ['pipe', 'pipe', 'pipe'], - }); + const agent = spawn( + 'node', + [rig.bundlePath, '--experimental-acp', '--no-chat-recording'], + { + cwd: rig.testDir!, + stdio: ['pipe', 'pipe', 'pipe'], + }, + ); agent.stderr?.on('data', (chunk) => { stderr.push(chunk.toString()); @@ -253,11 +266,11 @@ function setupAcpTest( } (IS_SANDBOX ? describe.skip : describe)('acp integration', () => { - it('creates, lists, loads, and resumes a session', async () => { + it('basic smoke test', async () => { const rig = new TestRig(); rig.setup('acp load session'); - const { sendRequest, cleanup, stderr, sessionUpdates } = setupAcpTest(rig); + const { sendRequest, cleanup, stderr } = setupAcpTest(rig); try { const initResult = await sendRequest('initialize', { @@ -283,34 +296,6 @@ function setupAcpTest( prompt: [{ type: 'text', text: INITIAL_PROMPT }], }); expect(promptResult).toBeDefined(); - - await delay(500); - - const listResult = (await sendRequest('session/list', { - cwd: rig.testDir!, - size: LIST_SIZE, - })) as { items?: Array<{ sessionId: string }> }; - - expect(Array.isArray(listResult.items)).toBe(true); - expect(listResult.items?.length ?? 0).toBeGreaterThan(0); - - const sessionToLoad = listResult.items![0].sessionId; - await sendRequest('session/load', { - cwd: rig.testDir!, - sessionId: sessionToLoad, - mcpServers: [], - }); - - const resumeResult = await sendRequest('session/prompt', { - sessionId: sessionToLoad, - prompt: [{ type: 'text', text: RESUME_PROMPT }], - }); - expect(resumeResult).toBeDefined(); - - const sessionsWithUpdates = sessionUpdates - .map((update) => update.sessionId) - .filter(Boolean); - expect(sessionsWithUpdates).toContain(sessionToLoad); } catch (e) { if (stderr.length) { console.error('Agent stderr:', stderr.join('')); @@ -587,4 +572,52 @@ function setupAcpTest( await cleanup(); } }); + + it('receives usage metadata in agent_message_chunk updates', async () => { + const rig = new TestRig(); + rig.setup('acp usage metadata'); + + const { sendRequest, cleanup, stderr, sessionUpdates } = setupAcpTest(rig); + + 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 }; + + await sendRequest('session/prompt', { + sessionId: newSession.sessionId, + prompt: [{ type: 'text', text: 'Say "hello".' }], + }); + + await delay(500); + + // Find updates with usage metadata + const updatesWithUsage = sessionUpdates.filter( + (u) => + u.update?.sessionUpdate === 'agent_message_chunk' && + u.update?._meta?.usage, + ); + + expect(updatesWithUsage.length).toBeGreaterThan(0); + + const usage = updatesWithUsage[0].update?._meta?.usage; + expect(usage).toBeDefined(); + expect( + typeof usage?.promptTokens === 'number' || + typeof usage?.totalTokens === 'number', + ).toBe(true); + } catch (e) { + if (stderr.length) console.error('Agent stderr:', stderr.join('')); + throw e; + } finally { + await cleanup(); + } + }); }); diff --git a/integration-tests/file-system.test.ts b/integration-tests/file-system.test.ts index bd15c76eb..9e8176290 100644 --- a/integration-tests/file-system.test.ts +++ b/integration-tests/file-system.test.ts @@ -202,8 +202,8 @@ describe('file-system', () => { const readAttempt = toolLogs.find( (log) => log.toolRequest.name === 'read_file', ); - const writeAttempt = toolLogs.find( - (log) => log.toolRequest.name === 'write_file', + const editAttempt = toolLogs.find( + (log) => log.toolRequest.name === 'edit_file', ); const successfulReplace = toolLogs.find( (log) => log.toolRequest.name === 'replace' && log.toolRequest.success, @@ -226,15 +226,15 @@ describe('file-system', () => { // CRITICAL: Verify that no matter what the model did, it never successfully // wrote or replaced anything. - if (writeAttempt) { + if (editAttempt) { console.error( - 'A write_file attempt was made when no file should be written.', + 'A edit_file attempt was made when no file should be written.', ); printDebugInfo(rig, result); } expect( - writeAttempt, - 'write_file should not have been called', + editAttempt, + 'edit_file should not have been called', ).toBeUndefined(); if (successfulReplace) { diff --git a/integration-tests/sdk-typescript/configuration-options.test.ts b/integration-tests/sdk-typescript/configuration-options.test.ts index bc59cd791..ca218248c 100644 --- a/integration-tests/sdk-typescript/configuration-options.test.ts +++ b/integration-tests/sdk-typescript/configuration-options.test.ts @@ -438,12 +438,8 @@ describe('Configuration Options (E2E)', () => { } }); - // Skip in containerized sandbox environments - qwen-oauth requires user interaction - // which is not possible in Docker/Podman CI environments - it.skipIf( - process.env['SANDBOX'] === 'sandbox:docker' || - process.env['SANDBOX'] === 'sandbox:podman', - )('should accept authType: qwen-oauth', async () => { + // Skip - qwen-oauth requires user interaction which is not possible in CI environments + it.skip('should accept authType: qwen-oauth', async () => { // Note: qwen-oauth requires credentials in ~/.qwen and user interaction // Without credentials, the auth process will timeout waiting for user // This test verifies the option is accepted and passed correctly to CLI diff --git a/integration-tests/sdk-typescript/permission-control.test.ts b/integration-tests/sdk-typescript/permission-control.test.ts index e8d201e6e..974f72b37 100644 --- a/integration-tests/sdk-typescript/permission-control.test.ts +++ b/integration-tests/sdk-typescript/permission-control.test.ts @@ -952,7 +952,8 @@ describe('Permission Control (E2E)', () => { TEST_TIMEOUT, ); - it( + // FIXME: This test is flaky and sometimes fails with no tool calls. + it.skip( 'should allow read-only tools without restrictions', async () => { // Create test files for the model to read diff --git a/integration-tests/sdk-typescript/test-helper.ts b/integration-tests/sdk-typescript/test-helper.ts index f3005655e..d7efc026c 100644 --- a/integration-tests/sdk-typescript/test-helper.ts +++ b/integration-tests/sdk-typescript/test-helper.ts @@ -73,15 +73,26 @@ export class SDKTestHelper { await mkdir(this.testDir, { recursive: true }); // Optionally create .qwen/settings.json for CLI configuration - if (options.createQwenConfig) { + if (options.createQwenConfig !== false) { const qwenDir = join(this.testDir, '.qwen'); await mkdir(qwenDir, { recursive: true }); + const optionsSettings = options.settings ?? {}; + const generalSettings = + typeof optionsSettings['general'] === 'object' && + optionsSettings['general'] !== null + ? (optionsSettings['general'] as Record) + : {}; + const settings = { + ...optionsSettings, telemetry: { enabled: false, // SDK tests don't need telemetry }, - ...options.settings, + general: { + ...generalSettings, + chatRecording: false, // SDK tests don't need chat recording + }, }; await writeFile( diff --git a/integration-tests/sdk-typescript/tool-control.test.ts b/integration-tests/sdk-typescript/tool-control.test.ts index b2b955a6a..549f820c0 100644 --- a/integration-tests/sdk-typescript/tool-control.test.ts +++ b/integration-tests/sdk-typescript/tool-control.test.ts @@ -31,9 +31,7 @@ describe('Tool Control Parameters (E2E)', () => { beforeEach(async () => { helper = new SDKTestHelper(); - testDir = await helper.setup('tool-control', { - createQwenConfig: false, - }); + testDir = await helper.setup('tool-control'); }); afterEach(async () => { diff --git a/integration-tests/test-helper.ts b/integration-tests/test-helper.ts index 0fe658c56..a08b3df50 100644 --- a/integration-tests/test-helper.ts +++ b/integration-tests/test-helper.ts @@ -218,8 +218,8 @@ export class TestRig { process.env.INTEGRATION_TEST_USE_INSTALLED_GEMINI === 'true'; const command = isNpmReleaseTest ? 'qwen' : 'node'; const initialArgs = isNpmReleaseTest - ? extraInitialArgs - : [this.bundlePath, ...extraInitialArgs]; + ? ['--no-chat-recording', ...extraInitialArgs] + : [this.bundlePath, '--no-chat-recording', ...extraInitialArgs]; return { command, initialArgs }; } diff --git a/integration-tests/utf-bom-encoding.test.ts b/integration-tests/utf-bom-encoding.test.ts index 4429b7007..bb682de1e 100644 --- a/integration-tests/utf-bom-encoding.test.ts +++ b/integration-tests/utf-bom-encoding.test.ts @@ -5,8 +5,8 @@ */ import { describe, it, expect, beforeAll, afterAll } from 'vitest'; -import { writeFileSync, readFileSync } from 'node:fs'; -import { join, resolve } from 'node:path'; +import { writeFileSync } from 'node:fs'; +import { join } from 'node:path'; import { TestRig } from './test-helper.js'; // Windows skip (Option A: avoid infra scope) @@ -121,21 +121,4 @@ d('BOM end-to-end integration', () => { 'BOM_OK UTF-32BE', ); }); - - it('Can describe a PNG file', async () => { - const imagePath = resolve( - process.cwd(), - 'docs/assets/gemini-screenshot.png', - ); - const imageContent = readFileSync(imagePath); - const filename = 'gemini-screenshot.png'; - writeFileSync(join(dir, filename), imageContent); - const prompt = `What is shown in the image ${filename}?`; - const output = await rig.run(prompt); - await rig.waitForToolCall('read_file'); - const lower = output.toLowerCase(); - // The response is non-deterministic, so we just check for some - // keywords that are very likely to be in the response. - expect(lower.includes('gemini')).toBeTruthy(); - }); }); diff --git a/package-lock.json b/package-lock.json index 4fba584c7..330b90e08 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "@qwen-code/qwen-code", - "version": "0.4.1", + "version": "0.6.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@qwen-code/qwen-code", - "version": "0.4.1", + "version": "0.6.0", "workspaces": [ "packages/*" ], @@ -35,9 +35,8 @@ "eslint-plugin-license-header": "^0.8.0", "eslint-plugin-react": "^7.37.5", "eslint-plugin-react-hooks": "^5.2.0", - "glob": "^10.4.5", + "glob": "^10.5.0", "globals": "^16.0.0", - "google-artifactregistry-auth": "^3.4.0", "husky": "^9.1.7", "json": "^11.0.0", "lint-staged": "^16.1.6", @@ -108,6 +107,19 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/@alloc/quick-lru": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/@alloc/quick-lru/-/quick-lru-5.2.0.tgz", + "integrity": "sha512-UrcABB+4bUrFABwbluTIBErXwvbsU/V7TZWfmbgJfbkwiBuziS9gxdODUyuiecfdGQ85jglMW6juS3+z5TsKLw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/@ampproject/remapping": { "version": "2.3.0", "resolved": "https://registry.npmjs.org/@ampproject/remapping/-/remapping-2.3.0.tgz", @@ -122,6 +134,36 @@ "node": ">=6.0.0" } }, + "node_modules/@anthropic-ai/sdk": { + "version": "0.36.3", + "resolved": "https://registry.npmjs.org/@anthropic-ai/sdk/-/sdk-0.36.3.tgz", + "integrity": "sha512-+c0mMLxL/17yFZ4P5+U6bTWiCSFZUKJddrv01ud2aFBWnTPLdRncYV76D3q1tqfnL7aCnhRtykFnoCFzvr4U3Q==", + "license": "MIT", + "dependencies": { + "@types/node": "^18.11.18", + "@types/node-fetch": "^2.6.4", + "abort-controller": "^3.0.0", + "agentkeepalive": "^4.2.1", + "form-data-encoder": "1.7.2", + "formdata-node": "^4.3.2", + "node-fetch": "^2.6.7" + } + }, + "node_modules/@anthropic-ai/sdk/node_modules/@types/node": { + "version": "18.19.130", + "resolved": "https://registry.npmjs.org/@types/node/-/node-18.19.130.tgz", + "integrity": "sha512-GRaXQx6jGfL8sKfaIDD6OupbIHBr9jv7Jnaml9tB7l4v068PAOXqfcujMMo5PhbIs6ggR1XODELqahT2R8v0fg==", + "license": "MIT", + "dependencies": { + "undici-types": "~5.26.4" + } + }, + "node_modules/@anthropic-ai/sdk/node_modules/undici-types": { + "version": "5.26.5", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-5.26.5.tgz", + "integrity": "sha512-JlCMO+ehdEIKqlFxk6IfVoAUVmgz7cU7zD/h9XZ0qzeosSHmUJVOzSQvvYSYWXkFXC+IfLKSIffhv0sVZup6pA==", + "license": "MIT" + }, "node_modules/@asamuzakjp/css-color": { "version": "3.2.0", "resolved": "https://registry.npmjs.org/@asamuzakjp/css-color/-/css-color-3.2.0.tgz", @@ -1195,27 +1237,6 @@ "resolved": "packages/test-utils", "link": true }, - "node_modules/@google/genai": { - "version": "1.16.0", - "resolved": "https://registry.npmjs.org/@google/genai/-/genai-1.16.0.tgz", - "integrity": "sha512-hdTYu39QgDFxv+FB6BK2zi4UIJGWhx2iPc0pHQ0C5Q/RCi+m+4gsryIzTGO+riqWcUA8/WGYp6hpqckdOBNysw==", - "license": "Apache-2.0", - "dependencies": { - "google-auth-library": "^9.14.2", - "ws": "^8.18.0" - }, - "engines": { - "node": ">=20.0.0" - }, - "peerDependencies": { - "@modelcontextprotocol/sdk": "^1.11.4" - }, - "peerDependenciesMeta": { - "@modelcontextprotocol/sdk": { - "optional": true - } - } - }, "node_modules/@grpc/grpc-js": { "version": "1.13.4", "resolved": "https://registry.npmjs.org/@grpc/grpc-js/-/grpc-js-1.13.4.tgz", @@ -1247,6 +1268,18 @@ "node": ">=6" } }, + "node_modules/@hono/node-server": { + "version": "1.19.7", + "resolved": "https://registry.npmjs.org/@hono/node-server/-/node-server-1.19.7.tgz", + "integrity": "sha512-vUcD0uauS7EU2caukW8z5lJKtoGMokxNbJtBiwHgpqxEXokaHCBkQUmCHhjFB1VUTWdqj25QoMkMKzgjq+uhrw==", + "license": "MIT", + "engines": { + "node": ">=18.14.1" + }, + "peerDependencies": { + "hono": "^4" + } + }, "node_modules/@humanfs/core": { "version": "0.19.1", "resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.1.tgz", @@ -1815,247 +1848,6 @@ "win32" ] }, - "node_modules/@modelcontextprotocol/sdk": { - "version": "1.15.1", - "resolved": "https://registry.npmjs.org/@modelcontextprotocol/sdk/-/sdk-1.15.1.tgz", - "integrity": "sha512-W/XlN9c528yYn+9MQkVjxiTPgPxoxt+oczfjHBDsJx0+59+O7B75Zhsp0B16Xbwbz8ANISDajh6+V7nIcPMc5w==", - "license": "MIT", - "dependencies": { - "ajv": "^6.12.6", - "content-type": "^1.0.5", - "cors": "^2.8.5", - "cross-spawn": "^7.0.5", - "eventsource": "^3.0.2", - "eventsource-parser": "^3.0.0", - "express": "^5.0.1", - "express-rate-limit": "^7.5.0", - "pkce-challenge": "^5.0.0", - "raw-body": "^3.0.0", - "zod": "^3.23.8", - "zod-to-json-schema": "^3.24.1" - }, - "engines": { - "node": ">=18" - } - }, - "node_modules/@modelcontextprotocol/sdk/node_modules/accepts": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/accepts/-/accepts-2.0.0.tgz", - "integrity": "sha512-5cvg6CtKwfgdmVqY1WIiXKc3Q1bkRqGLi+2W/6ao+6Y7gu/RCwRuAhGEzh5B4KlszSuTLgZYuqFqo5bImjNKng==", - "license": "MIT", - "dependencies": { - "mime-types": "^3.0.0", - "negotiator": "^1.0.0" - }, - "engines": { - "node": ">= 0.6" - } - }, - "node_modules/@modelcontextprotocol/sdk/node_modules/body-parser": { - "version": "2.2.0", - "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-2.2.0.tgz", - "integrity": "sha512-02qvAaxv8tp7fBa/mw1ga98OGm+eCbqzJOKoRt70sLmfEEi+jyBYVTDGfCL/k06/4EMk/z01gCe7HoCH/f2LTg==", - "license": "MIT", - "dependencies": { - "bytes": "^3.1.2", - "content-type": "^1.0.5", - "debug": "^4.4.0", - "http-errors": "^2.0.0", - "iconv-lite": "^0.6.3", - "on-finished": "^2.4.1", - "qs": "^6.14.0", - "raw-body": "^3.0.0", - "type-is": "^2.0.0" - }, - "engines": { - "node": ">=18" - } - }, - "node_modules/@modelcontextprotocol/sdk/node_modules/content-disposition": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-1.0.0.tgz", - "integrity": "sha512-Au9nRL8VNUut/XSzbQA38+M78dzP4D+eqg3gfJHMIHHYa3bg067xj1KxMUWj+VULbiZMowKngFFbKczUrNJ1mg==", - "license": "MIT", - "dependencies": { - "safe-buffer": "5.2.1" - }, - "engines": { - "node": ">= 0.6" - } - }, - "node_modules/@modelcontextprotocol/sdk/node_modules/cookie-signature": { - "version": "1.2.2", - "resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.2.2.tgz", - "integrity": "sha512-D76uU73ulSXrD1UXF4KE2TMxVVwhsnCgfAyTg9k8P6KGZjlXKrOLe4dJQKI3Bxi5wjesZoFXJWElNWBjPZMbhg==", - "license": "MIT", - "engines": { - "node": ">=6.6.0" - } - }, - "node_modules/@modelcontextprotocol/sdk/node_modules/express": { - "version": "5.1.0", - "resolved": "https://registry.npmjs.org/express/-/express-5.1.0.tgz", - "integrity": "sha512-DT9ck5YIRU+8GYzzU5kT3eHGA5iL+1Zd0EutOmTE9Dtk+Tvuzd23VBU+ec7HPNSTxXYO55gPV/hq4pSBJDjFpA==", - "license": "MIT", - "dependencies": { - "accepts": "^2.0.0", - "body-parser": "^2.2.0", - "content-disposition": "^1.0.0", - "content-type": "^1.0.5", - "cookie": "^0.7.1", - "cookie-signature": "^1.2.1", - "debug": "^4.4.0", - "encodeurl": "^2.0.0", - "escape-html": "^1.0.3", - "etag": "^1.8.1", - "finalhandler": "^2.1.0", - "fresh": "^2.0.0", - "http-errors": "^2.0.0", - "merge-descriptors": "^2.0.0", - "mime-types": "^3.0.0", - "on-finished": "^2.4.1", - "once": "^1.4.0", - "parseurl": "^1.3.3", - "proxy-addr": "^2.0.7", - "qs": "^6.14.0", - "range-parser": "^1.2.1", - "router": "^2.2.0", - "send": "^1.1.0", - "serve-static": "^2.2.0", - "statuses": "^2.0.1", - "type-is": "^2.0.1", - "vary": "^1.1.2" - }, - "engines": { - "node": ">= 18" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/express" - } - }, - "node_modules/@modelcontextprotocol/sdk/node_modules/finalhandler": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-2.1.0.tgz", - "integrity": "sha512-/t88Ty3d5JWQbWYgaOGCCYfXRwV1+be02WqYYlL6h0lEiUAMPM8o8qKGO01YIkOHzka2up08wvgYD0mDiI+q3Q==", - "license": "MIT", - "dependencies": { - "debug": "^4.4.0", - "encodeurl": "^2.0.0", - "escape-html": "^1.0.3", - "on-finished": "^2.4.1", - "parseurl": "^1.3.3", - "statuses": "^2.0.1" - }, - "engines": { - "node": ">= 0.8" - } - }, - "node_modules/@modelcontextprotocol/sdk/node_modules/fresh": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/fresh/-/fresh-2.0.0.tgz", - "integrity": "sha512-Rx/WycZ60HOaqLKAi6cHRKKI7zxWbJ31MhntmtwMoaTeF7XFH9hhBp8vITaMidfljRQ6eYWCKkaTK+ykVJHP2A==", - "license": "MIT", - "engines": { - "node": ">= 0.8" - } - }, - "node_modules/@modelcontextprotocol/sdk/node_modules/media-typer": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/media-typer/-/media-typer-1.1.0.tgz", - "integrity": "sha512-aisnrDP4GNe06UcKFnV5bfMNPBUw4jsLGaWwWfnH3v02GnBuXX2MCVn5RbrWo0j3pczUilYblq7fQ7Nw2t5XKw==", - "license": "MIT", - "engines": { - "node": ">= 0.8" - } - }, - "node_modules/@modelcontextprotocol/sdk/node_modules/merge-descriptors": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/merge-descriptors/-/merge-descriptors-2.0.0.tgz", - "integrity": "sha512-Snk314V5ayFLhp3fkUREub6WtjBfPdCPY1Ln8/8munuLuiYhsABgBVWsozAG+MWMbVEvcdcpbi9R7ww22l9Q3g==", - "license": "MIT", - "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/@modelcontextprotocol/sdk/node_modules/negotiator": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-1.0.0.tgz", - "integrity": "sha512-8Ofs/AUQh8MaEcrlq5xOX0CQ9ypTF5dl78mjlMNfOK08fzpgTHQRQPBxcPlEtIw0yRpws+Zo/3r+5WRby7u3Gg==", - "license": "MIT", - "engines": { - "node": ">= 0.6" - } - }, - "node_modules/@modelcontextprotocol/sdk/node_modules/qs": { - "version": "6.14.0", - "resolved": "https://registry.npmjs.org/qs/-/qs-6.14.0.tgz", - "integrity": "sha512-YWWTjgABSKcvs/nWBi9PycY/JiPJqOD4JA6o9Sej2AtvSGarXxKC3OQSk4pAarbdQlKAh5D4FCQkJNkW+GAn3w==", - "license": "BSD-3-Clause", - "dependencies": { - "side-channel": "^1.1.0" - }, - "engines": { - "node": ">=0.6" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/@modelcontextprotocol/sdk/node_modules/send": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/send/-/send-1.2.0.tgz", - "integrity": "sha512-uaW0WwXKpL9blXE2o0bRhoL2EGXIrZxQ2ZQ4mgcfoBxdFmQold+qWsD2jLrfZ0trjKL6vOw0j//eAwcALFjKSw==", - "license": "MIT", - "dependencies": { - "debug": "^4.3.5", - "encodeurl": "^2.0.0", - "escape-html": "^1.0.3", - "etag": "^1.8.1", - "fresh": "^2.0.0", - "http-errors": "^2.0.0", - "mime-types": "^3.0.1", - "ms": "^2.1.3", - "on-finished": "^2.4.1", - "range-parser": "^1.2.1", - "statuses": "^2.0.1" - }, - "engines": { - "node": ">= 18" - } - }, - "node_modules/@modelcontextprotocol/sdk/node_modules/serve-static": { - "version": "2.2.0", - "resolved": "https://registry.npmjs.org/serve-static/-/serve-static-2.2.0.tgz", - "integrity": "sha512-61g9pCh0Vnh7IutZjtLGGpTA355+OPn2TyDv/6ivP2h/AdAVX9azsoxmg2/M6nZeQZNYBEwIcsne1mJd9oQItQ==", - "license": "MIT", - "dependencies": { - "encodeurl": "^2.0.0", - "escape-html": "^1.0.3", - "parseurl": "^1.3.3", - "send": "^1.2.0" - }, - "engines": { - "node": ">= 18" - } - }, - "node_modules/@modelcontextprotocol/sdk/node_modules/type-is": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/type-is/-/type-is-2.0.1.tgz", - "integrity": "sha512-OZs6gsjF4vMp32qrCbiVSkrFmXtG/AZhY3t0iAMrMBiAZyV9oALtXO8hsrHbMXF9x6L3grlFuwW2oAz7cav+Gw==", - "license": "MIT", - "dependencies": { - "content-type": "^1.0.5", - "media-typer": "^1.1.0", - "mime-types": "^3.0.0" - }, - "engines": { - "node": ">= 0.6" - } - }, "node_modules/@mswjs/interceptors": { "version": "0.39.5", "resolved": "https://registry.npmjs.org/@mswjs/interceptors/-/interceptors-0.39.5.tgz", @@ -2520,24 +2312,6 @@ "@opentelemetry/api": ">=1.0.0 <1.10.0" } }, - "node_modules/@opentelemetry/resource-detector-gcp": { - "version": "0.40.0", - "resolved": "https://registry.npmjs.org/@opentelemetry/resource-detector-gcp/-/resource-detector-gcp-0.40.0.tgz", - "integrity": "sha512-uAsUV8K4R9OJ3cgPUGYDqQByxOMTz4StmzJyofIv7+W+c1dTSEc1WVjWpTS2PAmywik++JlSmd8O4rMRJZpO8Q==", - "license": "Apache-2.0", - "dependencies": { - "@opentelemetry/core": "^2.0.0", - "@opentelemetry/resources": "^2.0.0", - "@opentelemetry/semantic-conventions": "^1.27.0", - "gcp-metadata": "^6.0.0" - }, - "engines": { - "node": "^18.19.0 || >=20.6.0" - }, - "peerDependencies": { - "@opentelemetry/api": "^1.0.0" - } - }, "node_modules/@opentelemetry/resources": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/@opentelemetry/resources/-/resources-2.0.1.tgz", @@ -2666,6 +2440,354 @@ "node": ">=14" } }, + "node_modules/@parcel/watcher": { + "version": "2.5.1", + "resolved": "https://registry.npmjs.org/@parcel/watcher/-/watcher-2.5.1.tgz", + "integrity": "sha512-dfUnCxiN9H4ap84DvD2ubjw+3vUNpstxa0TneY/Paat8a3R4uQZDLSvWjmznAY/DoahqTHl9V46HF/Zs3F29pg==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "peer": true, + "dependencies": { + "detect-libc": "^1.0.3", + "is-glob": "^4.0.3", + "micromatch": "^4.0.5", + "node-addon-api": "^7.0.0" + }, + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + }, + "optionalDependencies": { + "@parcel/watcher-android-arm64": "2.5.1", + "@parcel/watcher-darwin-arm64": "2.5.1", + "@parcel/watcher-darwin-x64": "2.5.1", + "@parcel/watcher-freebsd-x64": "2.5.1", + "@parcel/watcher-linux-arm-glibc": "2.5.1", + "@parcel/watcher-linux-arm-musl": "2.5.1", + "@parcel/watcher-linux-arm64-glibc": "2.5.1", + "@parcel/watcher-linux-arm64-musl": "2.5.1", + "@parcel/watcher-linux-x64-glibc": "2.5.1", + "@parcel/watcher-linux-x64-musl": "2.5.1", + "@parcel/watcher-win32-arm64": "2.5.1", + "@parcel/watcher-win32-ia32": "2.5.1", + "@parcel/watcher-win32-x64": "2.5.1" + } + }, + "node_modules/@parcel/watcher-android-arm64": { + "version": "2.5.1", + "resolved": "https://registry.npmjs.org/@parcel/watcher-android-arm64/-/watcher-android-arm64-2.5.1.tgz", + "integrity": "sha512-KF8+j9nNbUN8vzOFDpRMsaKBHZ/mcjEjMToVMJOhTozkDonQFFrRcfdLWn6yWKCmJKmdVxSgHiYvTCef4/qcBA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "peer": true, + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher-darwin-arm64": { + "version": "2.5.1", + "resolved": "https://registry.npmjs.org/@parcel/watcher-darwin-arm64/-/watcher-darwin-arm64-2.5.1.tgz", + "integrity": "sha512-eAzPv5osDmZyBhou8PoF4i6RQXAfeKL9tjb3QzYuccXFMQU0ruIc/POh30ePnaOyD1UXdlKguHBmsTs53tVoPw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "peer": true, + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher-darwin-x64": { + "version": "2.5.1", + "resolved": "https://registry.npmjs.org/@parcel/watcher-darwin-x64/-/watcher-darwin-x64-2.5.1.tgz", + "integrity": "sha512-1ZXDthrnNmwv10A0/3AJNZ9JGlzrF82i3gNQcWOzd7nJ8aj+ILyW1MTxVk35Db0u91oD5Nlk9MBiujMlwmeXZg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "peer": true, + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher-freebsd-x64": { + "version": "2.5.1", + "resolved": "https://registry.npmjs.org/@parcel/watcher-freebsd-x64/-/watcher-freebsd-x64-2.5.1.tgz", + "integrity": "sha512-SI4eljM7Flp9yPuKi8W0ird8TI/JK6CSxju3NojVI6BjHsTyK7zxA9urjVjEKJ5MBYC+bLmMcbAWlZ+rFkLpJQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "peer": true, + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher-linux-arm-glibc": { + "version": "2.5.1", + "resolved": "https://registry.npmjs.org/@parcel/watcher-linux-arm-glibc/-/watcher-linux-arm-glibc-2.5.1.tgz", + "integrity": "sha512-RCdZlEyTs8geyBkkcnPWvtXLY44BCeZKmGYRtSgtwwnHR4dxfHRG3gR99XdMEdQ7KeiDdasJwwvNSF5jKtDwdA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "peer": true, + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher-linux-arm-musl": { + "version": "2.5.1", + "resolved": "https://registry.npmjs.org/@parcel/watcher-linux-arm-musl/-/watcher-linux-arm-musl-2.5.1.tgz", + "integrity": "sha512-6E+m/Mm1t1yhB8X412stiKFG3XykmgdIOqhjWj+VL8oHkKABfu/gjFj8DvLrYVHSBNC+/u5PeNrujiSQ1zwd1Q==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "peer": true, + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher-linux-arm64-glibc": { + "version": "2.5.1", + "resolved": "https://registry.npmjs.org/@parcel/watcher-linux-arm64-glibc/-/watcher-linux-arm64-glibc-2.5.1.tgz", + "integrity": "sha512-LrGp+f02yU3BN9A+DGuY3v3bmnFUggAITBGriZHUREfNEzZh/GO06FF5u2kx8x+GBEUYfyTGamol4j3m9ANe8w==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "peer": true, + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher-linux-arm64-musl": { + "version": "2.5.1", + "resolved": "https://registry.npmjs.org/@parcel/watcher-linux-arm64-musl/-/watcher-linux-arm64-musl-2.5.1.tgz", + "integrity": "sha512-cFOjABi92pMYRXS7AcQv9/M1YuKRw8SZniCDw0ssQb/noPkRzA+HBDkwmyOJYp5wXcsTrhxO0zq1U11cK9jsFg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "peer": true, + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher-linux-x64-glibc": { + "version": "2.5.1", + "resolved": "https://registry.npmjs.org/@parcel/watcher-linux-x64-glibc/-/watcher-linux-x64-glibc-2.5.1.tgz", + "integrity": "sha512-GcESn8NZySmfwlTsIur+49yDqSny2IhPeZfXunQi48DMugKeZ7uy1FX83pO0X22sHntJ4Ub+9k34XQCX+oHt2A==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "peer": true, + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher-linux-x64-musl": { + "version": "2.5.1", + "resolved": "https://registry.npmjs.org/@parcel/watcher-linux-x64-musl/-/watcher-linux-x64-musl-2.5.1.tgz", + "integrity": "sha512-n0E2EQbatQ3bXhcH2D1XIAANAcTZkQICBPVaxMeaCVBtOpBZpWJuf7LwyWPSBDITb7In8mqQgJ7gH8CILCURXg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "peer": true, + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher-win32-arm64": { + "version": "2.5.1", + "resolved": "https://registry.npmjs.org/@parcel/watcher-win32-arm64/-/watcher-win32-arm64-2.5.1.tgz", + "integrity": "sha512-RFzklRvmc3PkjKjry3hLF9wD7ppR4AKcWNzH7kXR7GUe0Igb3Nz8fyPwtZCSquGrhU5HhUNDr/mKBqj7tqA2Vw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "peer": true, + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher-win32-ia32": { + "version": "2.5.1", + "resolved": "https://registry.npmjs.org/@parcel/watcher-win32-ia32/-/watcher-win32-ia32-2.5.1.tgz", + "integrity": "sha512-c2KkcVN+NJmuA7CGlaGD1qJh1cLfDnQsHjE89E60vUEMlqduHGCdCLJCID5geFVM0dOtA3ZiIO8BoEQmzQVfpQ==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "peer": true, + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher-win32-x64": { + "version": "2.5.1", + "resolved": "https://registry.npmjs.org/@parcel/watcher-win32-x64/-/watcher-win32-x64-2.5.1.tgz", + "integrity": "sha512-9lHBdJITeNR++EvSQVUcaZoWupyHfXe1jZvGZ06O/5MflPcuPLtEphScIBL+AiCWBO46tDSHzWyD0uDmmZqsgA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "peer": true, + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher/node_modules/detect-libc": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-1.0.3.tgz", + "integrity": "sha512-pGjwhsmsp4kL2RTz08wcOlGN83otlqHeD/Z5T8GXZB+/YcpQ/dgo+lbU8ZsGxV0HIvqqxo9l7mqYwyYMD9bKDg==", + "dev": true, + "license": "Apache-2.0", + "optional": true, + "peer": true, + "bin": { + "detect-libc": "bin/detect-libc.js" + }, + "engines": { + "node": ">=0.10" + } + }, + "node_modules/@parcel/watcher/node_modules/node-addon-api": { + "version": "7.1.1", + "resolved": "https://registry.npmjs.org/node-addon-api/-/node-addon-api-7.1.1.tgz", + "integrity": "sha512-5m3bsyrjFWE1xf7nz7YXdN4udnVtXK6/Yfgn5qnahL6bCkf2yKt4k3nuTKAtT4r3IG8JNR2ncsIMdZuAzJjHQQ==", + "dev": true, + "license": "MIT", + "optional": true, + "peer": true + }, "node_modules/@pkgjs/parseargs": { "version": "0.11.0", "resolved": "https://registry.npmjs.org/@pkgjs/parseargs/-/parseargs-0.11.0.tgz", @@ -3401,9 +3523,9 @@ "license": "MIT" }, "node_modules/@textlint/linter-formatter/node_modules/js-yaml": { - "version": "3.14.1", - "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-3.14.1.tgz", - "integrity": "sha512-okMH7OXXJ7YrN9Ok3/SXrnu4iX9yOk+25nqX4imS2npuvTYDmo/QEZoqwZkYaIDk3jVvBOTOIEgEhaLOynBS9g==", + "version": "3.14.2", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-3.14.2.tgz", + "integrity": "sha512-PMSmkqxr106Xa156c2M265Z+FTrPl+oxd/rgOQy2tijQeK5TxQ43psO1ZCwhVOSdnn+RzkzlRz/eY4BgJBYVpg==", "dev": true, "license": "MIT", "dependencies": { @@ -3658,6 +3780,24 @@ "dev": true, "license": "MIT" }, + "node_modules/@types/linkify-it": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/@types/linkify-it/-/linkify-it-5.0.0.tgz", + "integrity": "sha512-sVDA58zAw4eWAffKOaQH5/5j3XeayukzDk+ewSsnv3p4yJEZHCCzMDiZM8e0OUrRvmpGZ85jf4yDHkHsgBNr9Q==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/markdown-it": { + "version": "14.1.2", + "resolved": "https://registry.npmjs.org/@types/markdown-it/-/markdown-it-14.1.2.tgz", + "integrity": "sha512-promo4eFwuiW+TfGxhi+0x3czqTYJkG8qB17ZUJiVF10Xm7NLVRSLUsfRTU/6h1e24VvRnXCx+hG7li58lkzog==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/linkify-it": "^5", + "@types/mdurl": "^2" + } + }, "node_modules/@types/marked": { "version": "5.0.2", "resolved": "https://registry.npmjs.org/@types/marked/-/marked-5.0.2.tgz", @@ -3665,6 +3805,13 @@ "dev": true, "license": "MIT" }, + "node_modules/@types/mdurl": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/@types/mdurl/-/mdurl-2.0.0.tgz", + "integrity": "sha512-RGdgjQUZba5p6QEFAVx2OGb8rQDL/cPRG7GiedRzMcJ1tYnUANBncjbSB1NRGwbvjcPeikRABz2nshyPk1bhWg==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/mime": { "version": "1.3.5", "resolved": "https://registry.npmjs.org/@types/mime/-/mime-1.3.5.tgz", @@ -3705,6 +3852,16 @@ "undici-types": "~6.21.0" } }, + "node_modules/@types/node-fetch": { + "version": "2.6.13", + "resolved": "https://registry.npmjs.org/@types/node-fetch/-/node-fetch-2.6.13.tgz", + "integrity": "sha512-QGpRVpzSaUs30JBSGPjOg4Uveu384erbHBoT1zeONvyCfwQxIkUshLAOqN/k9EjGviPRmWTTe6aH2qySWKTVSw==", + "license": "MIT", + "dependencies": { + "@types/node": "*", + "form-data": "^4.0.4" + } + }, "node_modules/@types/normalize-package-data": { "version": "2.4.4", "resolved": "https://registry.npmjs.org/@types/normalize-package-data/-/normalize-package-data-2.4.4.tgz", @@ -3718,6 +3875,13 @@ "dev": true, "license": "MIT" }, + "node_modules/@types/prop-types": { + "version": "15.7.15", + "resolved": "https://registry.npmjs.org/@types/prop-types/-/prop-types-15.7.15.tgz", + "integrity": "sha512-F6bEyamV9jKGAFBEmlQnesRPGOQqS2+Uwi0Em15xenOxHaf2hv6L8YCVn3rPdPJOiJfPiCnLIRyvwVaqMY3MIw==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/qrcode-terminal": { "version": "0.12.2", "resolved": "https://registry.npmjs.org/@types/qrcode-terminal/-/qrcode-terminal-0.12.2.tgz", @@ -4558,15 +4722,15 @@ ] }, "node_modules/@vscode/vsce/node_modules/glob": { - "version": "11.0.3", - "resolved": "https://registry.npmjs.org/glob/-/glob-11.0.3.tgz", - "integrity": "sha512-2Nim7dha1KVkaiF4q6Dj+ngPPMdfvLJEOpZk/jKiUAkqKebpGAWQXAq9z1xu9HKu5lWfqw/FASuccEjyznjPaA==", + "version": "11.1.0", + "resolved": "https://registry.npmjs.org/glob/-/glob-11.1.0.tgz", + "integrity": "sha512-vuNwKSaKiqm7g0THUBu2x7ckSs3XJLXE+2ssL7/MfTGPLLcrJQ/4Uq1CjPTtO5cCIiRxqvN6Twy1qOwhL0Xjcw==", "dev": true, - "license": "ISC", + "license": "BlueOak-1.0.0", "dependencies": { "foreground-child": "^3.3.1", "jackspeak": "^4.1.1", - "minimatch": "^10.0.3", + "minimatch": "^10.1.1", "minipass": "^7.1.2", "package-json-from-dist": "^1.0.0", "path-scurry": "^2.0.0" @@ -4582,11 +4746,11 @@ } }, "node_modules/@vscode/vsce/node_modules/glob/node_modules/minimatch": { - "version": "10.0.3", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.0.3.tgz", - "integrity": "sha512-IPZ167aShDZZUMdRk66cyQAW3qr0WzbHkPdMYa8bzZhlHhO3jALbKdxcaak7W9FfT2rZNpQuUu4Od7ILEpXSaw==", + "version": "10.1.1", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.1.1.tgz", + "integrity": "sha512-enIvLvRAFZYXJzkCYG5RKmPfrFArdLv+R+lbQ53BmIMLIry74bjKzX6iHAm8WYamJkhSSEabrWN5D97XnKObjQ==", "dev": true, - "license": "ISC", + "license": "BlueOak-1.0.0", "dependencies": { "@isaacs/brace-expansion": "^5.0.0" }, @@ -4696,7 +4860,6 @@ "version": "3.0.0", "resolved": "https://registry.npmjs.org/abort-controller/-/abort-controller-3.0.0.tgz", "integrity": "sha512-h8lQ8tacZYnR3vNQTgibj+tODHI5/+l06Au2Pcriv/Gmet0eaj4TwWH41sO9wnHDiQsEj19q0drzdWdeAHtweg==", - "dev": true, "license": "MIT", "dependencies": { "event-target-shim": "^5.0.0" @@ -4783,10 +4946,23 @@ "node": ">= 14" } }, + "node_modules/agentkeepalive": { + "version": "4.6.0", + "resolved": "https://registry.npmjs.org/agentkeepalive/-/agentkeepalive-4.6.0.tgz", + "integrity": "sha512-kja8j7PjmncONqaTsB8fQ+wE2mSU2DJ9D4XKoJ5PFWIdRMa6SLSN1ff4mOr4jCbfRSsxR4keIiySJU0N9T5hIQ==", + "license": "MIT", + "dependencies": { + "humanize-ms": "^1.2.1" + }, + "engines": { + "node": ">= 8.0.0" + } + }, "node_modules/ajv": { "version": "6.12.6", "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.12.6.tgz", "integrity": "sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==", + "dev": true, "license": "MIT", "dependencies": { "fast-deep-equal": "^3.1.1", @@ -4921,6 +5097,27 @@ "url": "https://github.com/chalk/ansi-styles?sponsor=1" } }, + "node_modules/any-promise": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/any-promise/-/any-promise-1.3.0.tgz", + "integrity": "sha512-7UvmKalWRt1wgjL1RrGxoSJW/0QZFIegpeGvZG9kjp8vrRu55XTHbwnqq2GpXm9uLbcuhxm3IqX9OB4MZR1b2A==", + "dev": true, + "license": "MIT" + }, + "node_modules/anymatch": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/anymatch/-/anymatch-3.1.3.tgz", + "integrity": "sha512-KMReFUr0B4t+D+OBkjR3KYqvocp2XaSzO55UcB6mgQMd3KbcE+mWTyvVV7D/zsdEbNnV6acZUutkiHQXvTr1Rw==", + "dev": true, + "license": "ISC", + "dependencies": { + "normalize-path": "^3.0.0", + "picomatch": "^2.0.4" + }, + "engines": { + "node": ">= 8" + } + }, "node_modules/archiver": { "version": "7.0.1", "resolved": "https://registry.npmjs.org/archiver/-/archiver-7.0.1.tgz", @@ -5065,11 +5262,17 @@ "streamx": "^2.15.0" } }, + "node_modules/arg": { + "version": "5.0.2", + "resolved": "https://registry.npmjs.org/arg/-/arg-5.0.2.tgz", + "integrity": "sha512-PYjyFOLKQ9y57JvQ6QLo8dAgNqswh8M1RMJYdQduT6xbWSgK36P/Z/v+p888pM69jMMfS8Xd8F6I1kQ/I9HUGg==", + "dev": true, + "license": "MIT" + }, "node_modules/argparse": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz", "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==", - "dev": true, "license": "Python-2.0" }, "node_modules/aria-query": { @@ -5326,7 +5529,6 @@ "version": "0.4.0", "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz", "integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==", - "dev": true, "license": "MIT" }, "node_modules/atomically": { @@ -5350,6 +5552,44 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/autoprefixer": { + "version": "10.4.22", + "resolved": "https://registry.npmjs.org/autoprefixer/-/autoprefixer-10.4.22.tgz", + "integrity": "sha512-ARe0v/t9gO28Bznv6GgqARmVqcWOV3mfgUPn9becPHMiD3o9BwlRgaeccZnwTpZ7Zwqrm+c1sUSsMxIzQzc8Xg==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/autoprefixer" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "browserslist": "^4.27.0", + "caniuse-lite": "^1.0.30001754", + "fraction.js": "^5.3.4", + "normalize-range": "^0.1.2", + "picocolors": "^1.1.1", + "postcss-value-parser": "^4.2.0" + }, + "bin": { + "autoprefixer": "bin/autoprefixer" + }, + "engines": { + "node": "^10 || ^12 || >=14" + }, + "peerDependencies": { + "postcss": "^8.1.0" + } + }, "node_modules/available-typed-arrays": { "version": "1.0.7", "resolved": "https://registry.npmjs.org/available-typed-arrays/-/available-typed-arrays-1.0.7.tgz", @@ -5425,6 +5665,16 @@ ], "license": "MIT" }, + "node_modules/baseline-browser-mapping": { + "version": "2.8.32", + "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.8.32.tgz", + "integrity": "sha512-OPz5aBThlyLFgxyhdwf/s2+8ab3OvT7AdTNvKHBwpXomIYeXqpUUuT8LrdtxZSsWJ4R4CU1un4XGh5Ez3nlTpw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "baseline-browser-mapping": "dist/cli.js" + } + }, "node_modules/bignumber.js": { "version": "9.3.0", "resolved": "https://registry.npmjs.org/bignumber.js/-/bignumber.js-9.3.0.tgz", @@ -5434,6 +5684,19 @@ "node": "*" } }, + "node_modules/binary-extensions": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/binary-extensions/-/binary-extensions-2.3.0.tgz", + "integrity": "sha512-Ceh+7ox5qe7LJuLHoY0feh3pHuUDHAcRUeyL2VYghZwfpkNIy/+8Ocg0a3UuSoYzavmylwuLWQOf3hl0jjMMIw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/binaryextensions": { "version": "6.11.0", "resolved": "https://registry.npmjs.org/binaryextensions/-/binaryextensions-6.11.0.tgz", @@ -5601,6 +5864,40 @@ "node": ">=8" } }, + "node_modules/browserslist": { + "version": "4.28.0", + "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.0.tgz", + "integrity": "sha512-tbydkR/CxfMwelN0vwdP/pLkDwyAASZ+VfWm4EOwlB6SWhx1sYnWLqo8N5j0rAzPfzfRaxt0mM/4wPU/Su84RQ==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "baseline-browser-mapping": "^2.8.25", + "caniuse-lite": "^1.0.30001754", + "electron-to-chromium": "^1.5.249", + "node-releases": "^2.0.27", + "update-browserslist-db": "^1.1.4" + }, + "bin": { + "browserslist": "cli.js" + }, + "engines": { + "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7" + } + }, "node_modules/buffer": { "version": "5.7.1", "resolved": "https://registry.npmjs.org/buffer/-/buffer-5.7.1.tgz", @@ -5746,6 +6043,37 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/camelcase-css": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/camelcase-css/-/camelcase-css-2.0.1.tgz", + "integrity": "sha512-QOSvevhslijgYwRx6Rv7zKdMF8lbRmx+uQGx2+vDc+KI/eBnsy9kit5aj23AgGu3pa4t9AgwbnXWqS+iOY+2aA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 6" + } + }, + "node_modules/caniuse-lite": { + "version": "1.0.30001757", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001757.tgz", + "integrity": "sha512-r0nnL/I28Zi/yjk1el6ilj27tKcdjLsNqAOZr0yVjWPrSQyHgKI2INaEWw21bAQSv2LXRt1XuCS/GomNpWOxsQ==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/caniuse-lite" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "CC-BY-4.0" + }, "node_modules/chai": { "version": "5.2.0", "resolved": "https://registry.npmjs.org/chai/-/chai-5.2.0.tgz", @@ -5884,6 +6212,24 @@ "entities": "^6.0.0" } }, + "node_modules/chokidar": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-4.0.3.tgz", + "integrity": "sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA==", + "dev": true, + "license": "MIT", + "optional": true, + "peer": true, + "dependencies": { + "readdirp": "^4.0.1" + }, + "engines": { + "node": ">= 14.16.0" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, "node_modules/chownr": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/chownr/-/chownr-3.0.0.tgz", @@ -6141,7 +6487,6 @@ "version": "1.0.8", "resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz", "integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==", - "dev": true, "license": "MIT", "dependencies": { "delayed-stream": "~1.0.0" @@ -6486,6 +6831,19 @@ "url": "https://github.com/sponsors/fb55" } }, + "node_modules/cssesc": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/cssesc/-/cssesc-3.0.0.tgz", + "integrity": "sha512-/Tb/JcjK111nNScGob5MNtsntNM1aCNUDipB/TkwZFhyDrrE47SOx/18wF2bbjgc3ZzCSKW1T5nt5EbFoAz/Vg==", + "dev": true, + "license": "MIT", + "bin": { + "cssesc": "bin/cssesc" + }, + "engines": { + "node": ">=4" + } + }, "node_modules/cssstyle": { "version": "4.6.0", "resolved": "https://registry.npmjs.org/cssstyle/-/cssstyle-4.6.0.tgz", @@ -6507,6 +6865,15 @@ "devOptional": true, "license": "MIT" }, + "node_modules/data-uri-to-buffer": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/data-uri-to-buffer/-/data-uri-to-buffer-4.0.1.tgz", + "integrity": "sha512-0R9ikRb668HB7QDxT1vkpuUBtqc53YyAwMwGeUFKRojY/NWKvdZ+9UYtRfGmhqNbRkTSVpMbmyhXipFFv2cb/A==", + "license": "MIT", + "engines": { + "node": ">= 12" + } + }, "node_modules/data-urls": { "version": "5.0.0", "resolved": "https://registry.npmjs.org/data-urls/-/data-urls-5.0.0.tgz", @@ -6745,7 +7112,6 @@ "version": "1.0.0", "resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz", "integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==", - "dev": true, "license": "MIT", "engines": { "node": ">=0.4.0" @@ -6803,6 +7169,13 @@ "url": "https://github.com/sponsors/wooorm" } }, + "node_modules/didyoumean": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/didyoumean/-/didyoumean-1.2.2.tgz", + "integrity": "sha512-gxtyfqMg7GKyhQmb056K7M3xszy/myH8w+B4RT+QXBQsvAOdc3XymqDDPHx1BgPgsdAA5SIifona89YtRATDzw==", + "dev": true, + "license": "Apache-2.0" + }, "node_modules/diff": { "version": "7.0.0", "resolved": "https://registry.npmjs.org/diff/-/diff-7.0.0.tgz", @@ -6845,6 +7218,13 @@ "node": ">=8" } }, + "node_modules/dlv": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/dlv/-/dlv-1.1.3.tgz", + "integrity": "sha512-+HlytyjlPKnIG8XuRG8WvmBP8xs8P71y+SKKS6ZXWoEgLuePxtDoUEiH7WkdePWrQ5JBpE6aoVqfZfJUQkjXwA==", + "dev": true, + "license": "MIT" + }, "node_modules/doctrine": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/doctrine/-/doctrine-2.1.0.tgz", @@ -7027,6 +7407,13 @@ "integrity": "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==", "license": "MIT" }, + "node_modules/electron-to-chromium": { + "version": "1.5.262", + "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.262.tgz", + "integrity": "sha512-NlAsMteRHek05jRUxUR0a5jpjYq9ykk6+kO0yRaMi5moe7u0fVIOeQ3Y30A8dIiWFBNUoQGi1ljb1i5VtS9WQQ==", + "dev": true, + "license": "ISC" + }, "node_modules/emoji-regex": { "version": "9.2.2", "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-9.2.2.tgz", @@ -7237,7 +7624,6 @@ "version": "2.1.0", "resolved": "https://registry.npmjs.org/es-set-tostringtag/-/es-set-tostringtag-2.1.0.tgz", "integrity": "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==", - "dev": true, "license": "MIT", "dependencies": { "es-errors": "^1.3.0", @@ -7767,7 +8153,6 @@ "version": "5.0.1", "resolved": "https://registry.npmjs.org/event-target-shim/-/event-target-shim-5.0.1.tgz", "integrity": "sha512-i/2XbnSz/uxRCU6+NdVJgKWDTM427+MqYbkQzD321DuCQJUqOuJKIA0IM2+W2xtYHdKOmZ4dR6fExsd4SXL+WQ==", - "dev": true, "license": "MIT", "engines": { "node": ">=6" @@ -8088,6 +8473,7 @@ "version": "2.1.0", "resolved": "https://registry.npmjs.org/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz", "integrity": "sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==", + "dev": true, "license": "MIT" }, "node_modules/fast-levenshtein": { @@ -8131,6 +8517,29 @@ "pend": "~1.2.0" } }, + "node_modules/fetch-blob": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/fetch-blob/-/fetch-blob-3.2.0.tgz", + "integrity": "sha512-7yAQpD2UMJzLi1Dqv7qFYnPbaPx7ZfFK6PiIxQ4PfkGPyNyl2Ugx+a/umUonmKqjhM4DnfbMvdX6otXq83soQQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/jimmywarting" + }, + { + "type": "paypal", + "url": "https://paypal.me/jimmywarting" + } + ], + "license": "MIT", + "dependencies": { + "node-domexception": "^1.0.0", + "web-streams-polyfill": "^3.0.3" + }, + "engines": { + "node": "^12.20 || >= 14.13" + } + }, "node_modules/file-entry-cache": { "version": "8.0.0", "resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-8.0.0.tgz", @@ -8289,7 +8698,6 @@ "version": "4.0.4", "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.4.tgz", "integrity": "sha512-KrGhL9Q4zjj0kiUt5OO4Mr/A/jlI2jDYs5eHBpYHPcBEVSiipAvn2Ko2HnPe20rmcuuvMHNdZFp+4IlGTMF0Ow==", - "dev": true, "license": "MIT", "dependencies": { "asynckit": "^0.4.0", @@ -8302,11 +8710,16 @@ "node": ">= 6" } }, + "node_modules/form-data-encoder": { + "version": "1.7.2", + "resolved": "https://registry.npmjs.org/form-data-encoder/-/form-data-encoder-1.7.2.tgz", + "integrity": "sha512-qfqtYan3rxrnCk1VYaA4H+Ms9xdpPqvLZa6xmMgFvhO32x7/3J/ExcTd6qpxM0vH2GdMI+poehyBZvqfMTto8A==", + "license": "MIT" + }, "node_modules/form-data/node_modules/mime-types": { "version": "2.1.35", "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz", "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", - "dev": true, "license": "MIT", "dependencies": { "mime-db": "1.52.0" @@ -8315,6 +8728,40 @@ "node": ">= 0.6" } }, + "node_modules/formdata-node": { + "version": "4.4.1", + "resolved": "https://registry.npmjs.org/formdata-node/-/formdata-node-4.4.1.tgz", + "integrity": "sha512-0iirZp3uVDjVGt9p49aTaqjk84TrglENEDuqfdlZQ1roC9CWlPk6Avf8EEnZNcAqPonwkG35x4n3ww/1THYAeQ==", + "license": "MIT", + "dependencies": { + "node-domexception": "1.0.0", + "web-streams-polyfill": "4.0.0-beta.3" + }, + "engines": { + "node": ">= 12.20" + } + }, + "node_modules/formdata-node/node_modules/web-streams-polyfill": { + "version": "4.0.0-beta.3", + "resolved": "https://registry.npmjs.org/web-streams-polyfill/-/web-streams-polyfill-4.0.0-beta.3.tgz", + "integrity": "sha512-QW95TCTaHmsYfHDybGMwO5IJIM93I/6vTRk+daHTWFPhwh+C8Cg7j7XyKrwrj8Ib6vYXe0ocYNrmzY4xAAN6ug==", + "license": "MIT", + "engines": { + "node": ">= 14" + } + }, + "node_modules/formdata-polyfill": { + "version": "4.0.10", + "resolved": "https://registry.npmjs.org/formdata-polyfill/-/formdata-polyfill-4.0.10.tgz", + "integrity": "sha512-buewHzMvYL29jdeQTVILecSaZKnt/RJWjoZCF5OW60Z67/GmSLBkOFM7qh1PI3zFNtJbaZL5eQu1vLfazOwj4g==", + "license": "MIT", + "dependencies": { + "fetch-blob": "^3.1.2" + }, + "engines": { + "node": ">=12.20.0" + } + }, "node_modules/forwarded": { "version": "0.2.0", "resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz", @@ -8330,6 +8777,20 @@ "integrity": "sha512-alTFZZQDKMporBH77856pXgzhEzaUVmLCDk+egLgIgHst3Tpndzz8MnKe+GzRJRfvVdn69HhpW7cmXzvtLvJAw==", "license": "MIT" }, + "node_modules/fraction.js": { + "version": "5.3.4", + "resolved": "https://registry.npmjs.org/fraction.js/-/fraction.js-5.3.4.tgz", + "integrity": "sha512-1X1NTtiJphryn/uLQz3whtY6jK3fTqoE3ohKs0tT+Ujr1W59oopxmoEh7Lu5p6vBaPbgoM0bzveAW4Qi5RyWDQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": "*" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/rawify" + } + }, "node_modules/fresh": { "version": "0.5.2", "resolved": "https://registry.npmjs.org/fresh/-/fresh-0.5.2.tgz", @@ -8440,36 +8901,6 @@ "integrity": "sha512-Tt4kuxLXFKHy8KT40zwsUPUkg1CrsgY25FxA2U/j/0WgEDCk3ddc/zLTCCcbSHX9FcKtLuVaDGtGE/STWC+j3Q==", "license": "BSD-3-Clause" }, - "node_modules/gaxios": { - "version": "6.7.1", - "resolved": "https://registry.npmjs.org/gaxios/-/gaxios-6.7.1.tgz", - "integrity": "sha512-LDODD4TMYx7XXdpwxAVRAIAuB0bzv0s+ywFonY46k126qzQHT9ygyoa9tncmOiQmmDrik65UYsEkv3lbfqQ3yQ==", - "license": "Apache-2.0", - "dependencies": { - "extend": "^3.0.2", - "https-proxy-agent": "^7.0.1", - "is-stream": "^2.0.0", - "node-fetch": "^2.6.9", - "uuid": "^9.0.1" - }, - "engines": { - "node": ">=14" - } - }, - "node_modules/gcp-metadata": { - "version": "6.1.1", - "resolved": "https://registry.npmjs.org/gcp-metadata/-/gcp-metadata-6.1.1.tgz", - "integrity": "sha512-a4tiq7E0/5fTjxPAaH4jpjkSv/uCaU2p5KC6HVGrvl0cDjA8iBZv4vv1gyzlmK0ZUKqwpOyQMKzZQe3lTit77A==", - "license": "Apache-2.0", - "dependencies": { - "gaxios": "^6.1.1", - "google-logging-utils": "^0.0.2", - "json-bigint": "^1.0.0" - }, - "engines": { - "node": ">=14" - } - }, "node_modules/get-caller-file": { "version": "2.0.5", "resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz", @@ -8591,9 +9022,9 @@ "optional": true }, "node_modules/glob": { - "version": "10.4.5", - "resolved": "https://registry.npmjs.org/glob/-/glob-10.4.5.tgz", - "integrity": "sha512-7Bv8RF0k6xjo7d4A/PxYLbUCfb6c+Vpd2/mB2yRDlew7Jb5hEXiCD9ibfO7wpk8i4sevK6DFny9h7EYbM3/sHg==", + "version": "10.5.0", + "resolved": "https://registry.npmjs.org/glob/-/glob-10.5.0.tgz", + "integrity": "sha512-DfXN8DfhJ7NH3Oe7cFmu3NCu1wKbkReJ8TorzSAFbSKrlNaQSKfIzqYqVY8zlbs2NLBbWpRiU52GX2PbaBVNkg==", "license": "ISC", "dependencies": { "foreground-child": "^3.1.0", @@ -8779,46 +9210,6 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/google-artifactregistry-auth": { - "version": "3.4.0", - "resolved": "https://registry.npmjs.org/google-artifactregistry-auth/-/google-artifactregistry-auth-3.4.0.tgz", - "integrity": "sha512-Z2EmP7gbKtTmK5k846tfF7dQqeU2vREIcfCI79FKRTAdkbUZ/BFGJwwTvC2ss0vYSySvK7h6I1JsqBFqIXABBg==", - "dev": true, - "license": "Apache-2.0", - "dependencies": { - "google-auth-library": "^9.14.0", - "yargs": "^17.1.1" - }, - "bin": { - "artifactregistry-auth": "src/main.js" - } - }, - "node_modules/google-auth-library": { - "version": "9.15.1", - "resolved": "https://registry.npmjs.org/google-auth-library/-/google-auth-library-9.15.1.tgz", - "integrity": "sha512-Jb6Z0+nvECVz+2lzSMt9u98UsoakXxA2HGHMCxh+so3n90XgYWkq5dur19JAJV7ONiJY22yBTyJB1TSkvPq9Ng==", - "license": "Apache-2.0", - "dependencies": { - "base64-js": "^1.3.0", - "ecdsa-sig-formatter": "^1.0.11", - "gaxios": "^6.1.1", - "gcp-metadata": "^6.1.0", - "gtoken": "^7.0.0", - "jws": "^4.0.0" - }, - "engines": { - "node": ">=14" - } - }, - "node_modules/google-logging-utils": { - "version": "0.0.2", - "resolved": "https://registry.npmjs.org/google-logging-utils/-/google-logging-utils-0.0.2.tgz", - "integrity": "sha512-NEgUnEcBiP5HrPzufUkBzJOD/Sxsco3rLNo1F1TNf7ieU8ryUzBhqba8r756CjLX7rn3fHl6iLEwPYuqpoKgQQ==", - "license": "Apache-2.0", - "engines": { - "node": ">=14" - } - }, "node_modules/gopd": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", @@ -8867,19 +9258,6 @@ "node": "^12.22.0 || ^14.16.0 || ^16.0.0 || >=17.0.0" } }, - "node_modules/gtoken": { - "version": "7.1.0", - "resolved": "https://registry.npmjs.org/gtoken/-/gtoken-7.1.0.tgz", - "integrity": "sha512-pCcEwRi+TKpMlxAQObHDQ56KawURgyAf6jtIY046fJ5tIv3zDe/LEIubckAO8fj6JnAxLdmWkUfNyulQ2iKdEw==", - "license": "MIT", - "dependencies": { - "gaxios": "^6.0.0", - "jws": "^4.0.0" - }, - "engines": { - "node": ">=14.0.0" - } - }, "node_modules/has-bigints": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/has-bigints/-/has-bigints-1.1.0.tgz", @@ -8956,7 +9334,6 @@ "version": "1.0.2", "resolved": "https://registry.npmjs.org/has-tostringtag/-/has-tostringtag-1.0.2.tgz", "integrity": "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==", - "dev": true, "license": "MIT", "dependencies": { "has-symbols": "^1.0.3" @@ -8996,6 +9373,16 @@ "node": ">=12.0.0" } }, + "node_modules/hono": { + "version": "4.11.1", + "resolved": "https://registry.npmjs.org/hono/-/hono-4.11.1.tgz", + "integrity": "sha512-KsFcH0xxHes0J4zaQgWbYwmz3UPOOskdqZmItstUG93+Wk1ePBLkLGwbP9zlmh1BFUiL8Qp+Xfu9P7feJWpGNg==", + "license": "MIT", + "peer": true, + "engines": { + "node": ">=16.9.0" + } + }, "node_modules/hosted-git-info": { "version": "7.0.2", "resolved": "https://registry.npmjs.org/hosted-git-info/-/hosted-git-info-7.0.2.tgz", @@ -9125,6 +9512,15 @@ "node": ">=16.17.0" } }, + "node_modules/humanize-ms": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/humanize-ms/-/humanize-ms-1.2.1.tgz", + "integrity": "sha512-Fl70vYtsAFb/C06PTS9dZBo7ihau+Tu/DNCk/OyHhea07S+aeMWpFFkUaXRa8fI+ScZbEI8dfSxwY7gxZ9SAVQ==", + "license": "MIT", + "dependencies": { + "ms": "^2.0.0" + } + }, "node_modules/husky": { "version": "9.1.7", "resolved": "https://registry.npmjs.org/husky/-/husky-9.1.7.tgz", @@ -9194,6 +9590,15 @@ "node": ">= 4" } }, + "node_modules/immutable": { + "version": "5.1.4", + "resolved": "https://registry.npmjs.org/immutable/-/immutable-5.1.4.tgz", + "integrity": "sha512-p6u1bG3YSnINT5RQmx/yRZBpenIl30kVxkTLDyHLIMk0gict704Q9n+thfDI7lTRm9vXdDYutVzXhzcThxTnXA==", + "dev": true, + "license": "MIT", + "optional": true, + "peer": true + }, "node_modules/import-fresh": { "version": "3.3.1", "resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.1.tgz", @@ -9653,6 +10058,19 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/is-binary-path": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/is-binary-path/-/is-binary-path-2.1.0.tgz", + "integrity": "sha512-ZMERYes6pDydyuGidse7OsHxtbI7WVeUEozgR/g7rd0xUimYNlvZRE/K2MgZTjWy725IfelLeVcEM97mmtRGXw==", + "dev": true, + "license": "MIT", + "dependencies": { + "binary-extensions": "^2.0.0" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/is-boolean-object": { "version": "1.2.2", "resolved": "https://registry.npmjs.org/is-boolean-object/-/is-boolean-object-1.2.2.tgz", @@ -10013,6 +10431,7 @@ "version": "2.0.1", "resolved": "https://registry.npmjs.org/is-stream/-/is-stream-2.0.1.tgz", "integrity": "sha512-hFoiJiTl63nn+kstHGBtewWSKnQLpyb155KHheA1l39uvtO9nWIop1p3udqPcUd/xbF1VLMO4n7OI6p7RbngDg==", + "dev": true, "license": "MIT", "engines": { "node": ">=8" @@ -10264,6 +10683,25 @@ "@pkgjs/parseargs": "^0.11.0" } }, + "node_modules/jiti": { + "version": "1.21.7", + "resolved": "https://registry.npmjs.org/jiti/-/jiti-1.21.7.tgz", + "integrity": "sha512-/imKNG4EbWNrVjoNC/1H5/9GFy+tqjGBHCaSsN+P2RnPqjsLmv6UD3Ej+Kj8nBWaRAwyk7kK5ZUc+OEatnTR3A==", + "dev": true, + "license": "MIT", + "bin": { + "jiti": "bin/jiti.js" + } + }, + "node_modules/jose": { + "version": "6.1.3", + "resolved": "https://registry.npmjs.org/jose/-/jose-6.1.3.tgz", + "integrity": "sha512-0TpaTfihd4QMNwrz/ob2Bp7X04yuxJkjRGi4aKmOqwhov54i6u79oCv7T+C7lo70MKH6BesI3vscD1yb/yzKXQ==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/panva" + } + }, "node_modules/js-tokens": { "version": "9.0.1", "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-9.0.1.tgz", @@ -10272,9 +10710,9 @@ "license": "MIT" }, "node_modules/js-yaml": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.0.tgz", - "integrity": "sha512-wpxZs9NoxZaJESJGIZTyDEaYpl0FKSA+FB9aJiyemKhMwkxQg63h4T1KJgUGHpTqPDNRcmmYLugrRjJlBtWvRA==", + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.1.tgz", + "integrity": "sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA==", "dev": true, "license": "MIT", "dependencies": { @@ -10375,6 +10813,12 @@ "integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==", "license": "MIT" }, + "node_modules/json-schema-typed": { + "version": "8.0.2", + "resolved": "https://registry.npmjs.org/json-schema-typed/-/json-schema-typed-8.0.2.tgz", + "integrity": "sha512-fQhoXdcvc3V28x7C7BMs4P5+kNlgUURe2jmUT1T//oBRMDrqy1QPelJimwZGo7Hg9VPV3EQV5Bnq4hbFy2vetA==", + "license": "BSD-2-Clause" + }, "node_modules/json-stable-stringify-without-jsonify": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/json-stable-stringify-without-jsonify/-/json-stable-stringify-without-jsonify-1.0.1.tgz", @@ -10470,13 +10914,13 @@ } }, "node_modules/jsonwebtoken/node_modules/jws": { - "version": "3.2.2", - "resolved": "https://registry.npmjs.org/jws/-/jws-3.2.2.tgz", - "integrity": "sha512-YHlZCB6lMTllWDtSPHz/ZXTsi8S00usEV6v1tjq8tOUZzw7DpSDWVXjXDre6ed1w/pd495ODpHZYSdkRTsa0HA==", + "version": "3.2.3", + "resolved": "https://registry.npmjs.org/jws/-/jws-3.2.3.tgz", + "integrity": "sha512-byiJ0FLRdLdSVSReO/U4E7RoEyOCKnEnEPMjq3HxWtvzLsV08/i5RQKsFVNkCldrCaPr2vDNAOMsfs8T/Hze7g==", "dev": true, "license": "MIT", "dependencies": { - "jwa": "^1.4.1", + "jwa": "^1.4.2", "safe-buffer": "^5.0.1" } }, @@ -10508,12 +10952,12 @@ } }, "node_modules/jws": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/jws/-/jws-4.0.0.tgz", - "integrity": "sha512-KDncfTmOZoOMTFG4mBlG0qUIOlc03fmzH+ru6RgYVZhPkyiy/92Owlt/8UEN+a4TXR1FQetfIpJE8ApdvdVxTg==", + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/jws/-/jws-4.0.1.tgz", + "integrity": "sha512-EKI/M/yqPncGUUh44xz0PxSidXFr/+r0pA70+gIYhjv+et7yxM+s29Y+VGDkovRofQem0fs7Uvf4+YmAdyRduA==", "license": "MIT", "dependencies": { - "jwa": "^2.0.0", + "jwa": "^2.0.1", "safe-buffer": "^5.0.1" } }, @@ -10666,11 +11110,17 @@ "url": "https://github.com/sponsors/antonk52" } }, + "node_modules/lines-and-columns": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/lines-and-columns/-/lines-and-columns-1.2.4.tgz", + "integrity": "sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg==", + "dev": true, + "license": "MIT" + }, "node_modules/linkify-it": { "version": "5.0.0", "resolved": "https://registry.npmjs.org/linkify-it/-/linkify-it-5.0.0.tgz", "integrity": "sha512-5aHCbzQRADcdP+ATqnDuhhJ/MRIqDkZX5pyjFHRRysS8vZ5AbqGEoFIb6pYHPZ+L/OC2Lc+xT8uHVVR5CAK/wQ==", - "dev": true, "license": "MIT", "dependencies": { "uc.micro": "^2.0.0" @@ -11103,7 +11553,6 @@ "version": "14.1.0", "resolved": "https://registry.npmjs.org/markdown-it/-/markdown-it-14.1.0.tgz", "integrity": "sha512-a54IwgWPaeBCAAsv13YgmALOF1elABB08FxO9i+r4VFk5Vl4pKokRPeX8u5TCgSsPi6ec1otfLjdOpVcgbpshg==", - "dev": true, "license": "MIT", "dependencies": { "argparse": "^2.0.1", @@ -11142,7 +11591,6 @@ "version": "2.0.0", "resolved": "https://registry.npmjs.org/mdurl/-/mdurl-2.0.0.tgz", "integrity": "sha512-Lf+9+2r+Tdp5wXDXC4PcIBjTDtq4UKjCPMQhKIuzpJNW0b96kVqSwW0bT7FhRSfmAiFYgP+SCRvdrDozfh0U5w==", - "dev": true, "license": "MIT" }, "node_modules/media-typer": { @@ -11448,6 +11896,18 @@ "node": "^18.17.0 || >=20.5.0" } }, + "node_modules/mz": { + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/mz/-/mz-2.7.0.tgz", + "integrity": "sha512-z81GNO7nnYMEhrGh9LeymoE4+Yr0Wn5McHIZMK5cfQCl+NDX08sCZgUc9/6MHni9IWuFLm1Z3HTCXu2z9fN62Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "any-promise": "^1.0.0", + "object-assign": "^4.0.1", + "thenify-all": "^1.0.0" + } + }, "node_modules/nan": { "version": "2.23.0", "resolved": "https://registry.npmjs.org/nan/-/nan-2.23.0.tgz", @@ -11540,6 +12000,26 @@ "license": "MIT", "optional": true }, + "node_modules/node-domexception": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/node-domexception/-/node-domexception-1.0.0.tgz", + "integrity": "sha512-/jKZoMpw0F8GRwl4/eLROPA3cfcXtLApP0QzLmUT/HuPCZWyB7IY9ZrMeKw2O/nFIqPQB3PVM9aYm0F312AXDQ==", + "deprecated": "Use your platform's native DOMException instead", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/jimmywarting" + }, + { + "type": "github", + "url": "https://paypal.me/jimmywarting" + } + ], + "license": "MIT", + "engines": { + "node": ">=10.5.0" + } + }, "node_modules/node-fetch": { "version": "2.7.0", "resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-2.7.0.tgz", @@ -11593,6 +12073,13 @@ "nan": "^2.17.0" } }, + "node_modules/node-releases": { + "version": "2.0.27", + "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.27.tgz", + "integrity": "sha512-nmh3lCkYZ3grZvqcCH+fjmQ7X+H0OeZgP40OierEaAptX4XofMh5kwNbWh7lBduUzCcV/8kZ+NDLCwm2iorIlA==", + "dev": true, + "license": "MIT" + }, "node_modules/node-sarif-builder": { "version": "3.2.0", "resolved": "https://registry.npmjs.org/node-sarif-builder/-/node-sarif-builder-3.2.0.tgz", @@ -11631,6 +12118,16 @@ "node": ">=0.10.0" } }, + "node_modules/normalize-range": { + "version": "0.1.2", + "resolved": "https://registry.npmjs.org/normalize-range/-/normalize-range-0.1.2.tgz", + "integrity": "sha512-bdok/XvKII3nUpklnV6P2hxtMNrCboOjAcyBuQnWEhO665FwrSNRxU+AqpsyvO6LgGYPspN+lu5CLtw4jPRKNA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/npm-normalize-package-bin": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/npm-normalize-package-bin/-/npm-normalize-package-bin-4.0.0.tgz", @@ -11970,6 +12467,16 @@ "node": ">=0.10.0" } }, + "node_modules/object-hash": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/object-hash/-/object-hash-3.0.0.tgz", + "integrity": "sha512-RSn9F68PjH9HqtltsSnqYC1XXoWe9Bju5+213R98cNGttag9q9yAOTzdbsqvIa7aNm5WffBZFpWYr2aWrklWAw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 6" + } + }, "node_modules/object-inspect": { "version": "1.13.4", "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz", @@ -12566,6 +13073,16 @@ "node": ">=4" } }, + "node_modules/pirates": { + "version": "4.0.7", + "resolved": "https://registry.npmjs.org/pirates/-/pirates-4.0.7.tgz", + "integrity": "sha512-TfySrs/5nm8fQJDcBDuUng3VOUKsd7S+zqvbOTiGXHfxX4wK31ard+hoNuvkicM/2YFzlpDgABOevKSsB4G/FA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 6" + } + }, "node_modules/pkce-challenge": { "version": "5.0.0", "resolved": "https://registry.npmjs.org/pkce-challenge/-/pkce-challenge-5.0.0.tgz", @@ -12636,6 +13153,140 @@ "node": "^10 || ^12 || >=14" } }, + "node_modules/postcss-import": { + "version": "15.1.0", + "resolved": "https://registry.npmjs.org/postcss-import/-/postcss-import-15.1.0.tgz", + "integrity": "sha512-hpr+J05B2FVYUAXHeK1YyI267J/dDDhMU6B6civm8hSY1jYJnBXxzKDKDswzJmtLHryrjhnDjqqp/49t8FALew==", + "dev": true, + "license": "MIT", + "dependencies": { + "postcss-value-parser": "^4.0.0", + "read-cache": "^1.0.0", + "resolve": "^1.1.7" + }, + "engines": { + "node": ">=14.0.0" + }, + "peerDependencies": { + "postcss": "^8.0.0" + } + }, + "node_modules/postcss-js": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/postcss-js/-/postcss-js-4.1.0.tgz", + "integrity": "sha512-oIAOTqgIo7q2EOwbhb8UalYePMvYoIeRY2YKntdpFQXNosSu3vLrniGgmH9OKs/qAkfoj5oB3le/7mINW1LCfw==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "camelcase-css": "^2.0.1" + }, + "engines": { + "node": "^12 || ^14 || >= 16" + }, + "peerDependencies": { + "postcss": "^8.4.21" + } + }, + "node_modules/postcss-load-config": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/postcss-load-config/-/postcss-load-config-6.0.1.tgz", + "integrity": "sha512-oPtTM4oerL+UXmx+93ytZVN82RrlY/wPUV8IeDxFrzIjXOLF1pN+EmKPLbubvKHT2HC20xXsCAH2Z+CKV6Oz/g==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "lilconfig": "^3.1.1" + }, + "engines": { + "node": ">= 18" + }, + "peerDependencies": { + "jiti": ">=1.21.0", + "postcss": ">=8.0.9", + "tsx": "^4.8.1", + "yaml": "^2.4.2" + }, + "peerDependenciesMeta": { + "jiti": { + "optional": true + }, + "postcss": { + "optional": true + }, + "tsx": { + "optional": true + }, + "yaml": { + "optional": true + } + } + }, + "node_modules/postcss-nested": { + "version": "6.2.0", + "resolved": "https://registry.npmjs.org/postcss-nested/-/postcss-nested-6.2.0.tgz", + "integrity": "sha512-HQbt28KulC5AJzG+cZtj9kvKB93CFCdLvog1WFLf1D+xmMvPGlBstkpTEZfK5+AN9hfJocyBFCNiqyS48bpgzQ==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "postcss-selector-parser": "^6.1.1" + }, + "engines": { + "node": ">=12.0" + }, + "peerDependencies": { + "postcss": "^8.2.14" + } + }, + "node_modules/postcss-selector-parser": { + "version": "6.1.2", + "resolved": "https://registry.npmjs.org/postcss-selector-parser/-/postcss-selector-parser-6.1.2.tgz", + "integrity": "sha512-Q8qQfPiZ+THO/3ZrOrO0cJJKfpYCagtMUkXbnEfmgUjwXg6z/WBeOyS9APBBPCTSiDV+s4SwQGu8yFsiMRIudg==", + "dev": true, + "license": "MIT", + "dependencies": { + "cssesc": "^3.0.0", + "util-deprecate": "^1.0.2" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/postcss-value-parser": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/postcss-value-parser/-/postcss-value-parser-4.2.0.tgz", + "integrity": "sha512-1NNCs6uurfkVbeXG4S8JFT9t19m45ICnif8zWLd5oPSZ50QnwMfK+H3jv408d4jw/7Bttv5axS5IiHoLaVNHeQ==", + "dev": true, + "license": "MIT" + }, "node_modules/prebuild-install": { "version": "7.1.3", "resolved": "https://registry.npmjs.org/prebuild-install/-/prebuild-install-7.1.3.tgz", @@ -12823,6 +13474,7 @@ "version": "2.3.1", "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", "integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==", + "dev": true, "license": "MIT", "engines": { "node": ">=6" @@ -12832,7 +13484,6 @@ "version": "2.3.1", "resolved": "https://registry.npmjs.org/punycode.js/-/punycode.js-2.3.1.tgz", "integrity": "sha512-uxFIHU0YlHYhDQtV4R9J6a52SLx28BCjT+4ieh7IGbgwVJWO+km431c4yRlREUAsAmt/uMjQUyQHNEPf0M39CA==", - "dev": true, "license": "MIT", "engines": { "node": ">=6" @@ -13077,6 +13728,26 @@ "node": ">=0.8" } }, + "node_modules/read-cache": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/read-cache/-/read-cache-1.0.0.tgz", + "integrity": "sha512-Owdv/Ft7IjOgm/i0xvNDZ1LrRANRfew4b2prF3OWMQLxLfu3bS8FVhCsrSCMK4lR56Y9ya+AThoTpDCTxCmpRA==", + "dev": true, + "license": "MIT", + "dependencies": { + "pify": "^2.3.0" + } + }, + "node_modules/read-cache/node_modules/pify": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/pify/-/pify-2.3.0.tgz", + "integrity": "sha512-udgsAY+fTnvv7kI7aaxbqwWNb0AHiB0qBO89PZKPkoTmGOgdbrHDKD+0B2X4uTfJ/FT1R09r9gTsjUjNJotuog==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/read-package-json-fast": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/read-package-json-fast/-/read-package-json-fast-4.0.0.tgz", @@ -13207,6 +13878,22 @@ "node": ">=10" } }, + "node_modules/readdirp": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-4.1.2.tgz", + "integrity": "sha512-GDhwkLfywWL2s6vEjyhri+eXmfH6j1L7JE27WhqLeYzoh/A3DBaYGEj2H/HFZCn/kMfim73FXxEJTw06WtxQwg==", + "dev": true, + "license": "MIT", + "optional": true, + "peer": true, + "engines": { + "node": ">= 14.18.0" + }, + "funding": { + "type": "individual", + "url": "https://paulmillr.com/funding/" + } + }, "node_modules/reflect.getprototypeof": { "version": "1.0.10", "resolved": "https://registry.npmjs.org/reflect.getprototypeof/-/reflect.getprototypeof-1.0.10.tgz", @@ -13644,6 +14331,29 @@ "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", "license": "MIT" }, + "node_modules/sass": { + "version": "1.94.2", + "resolved": "https://registry.npmjs.org/sass/-/sass-1.94.2.tgz", + "integrity": "sha512-N+7WK20/wOr7CzA2snJcUSSNTCzeCGUTFY3OgeQP3mZ1aj9NMQ0mSTXwlrnd89j33zzQJGqIN52GIOmYrfq46A==", + "dev": true, + "license": "MIT", + "optional": true, + "peer": true, + "dependencies": { + "chokidar": "^4.0.0", + "immutable": "^5.0.2", + "source-map-js": ">=0.6.2 <2.0.0" + }, + "bin": { + "sass": "sass.js" + }, + "engines": { + "node": ">=14.0.0" + }, + "optionalDependencies": { + "@parcel/watcher": "^2.4.1" + } + }, "node_modules/sax": { "version": "1.4.1", "resolved": "https://registry.npmjs.org/sax/-/sax-1.4.1.tgz", @@ -14503,6 +15213,39 @@ "resolved": "https://registry.npmjs.org/stubborn-fs/-/stubborn-fs-1.2.5.tgz", "integrity": "sha512-H2N9c26eXjzL/S/K+i/RHHcFanE74dptvvjM8iwzwbVcWY/zjBbgRqF3K0DY4+OD+uTTASTBvDoxPDaPN02D7g==" }, + "node_modules/sucrase": { + "version": "3.35.1", + "resolved": "https://registry.npmjs.org/sucrase/-/sucrase-3.35.1.tgz", + "integrity": "sha512-DhuTmvZWux4H1UOnWMB3sk0sbaCVOoQZjv8u1rDoTV0HTdGem9hkAZtl4JZy8P2z4Bg0nT+YMeOFyVr4zcG5Tw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/gen-mapping": "^0.3.2", + "commander": "^4.0.0", + "lines-and-columns": "^1.1.6", + "mz": "^2.7.0", + "pirates": "^4.0.1", + "tinyglobby": "^0.2.11", + "ts-interface-checker": "^0.1.9" + }, + "bin": { + "sucrase": "bin/sucrase", + "sucrase-node": "bin/sucrase-node" + }, + "engines": { + "node": ">=16 || 14 >=14.17" + } + }, + "node_modules/sucrase/node_modules/commander": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/commander/-/commander-4.1.1.tgz", + "integrity": "sha512-NOKm8xhkzAjzFx8B2v5OAHT+u5pRQc2UCa2Vq9jYL/31o2wi9mxBA7LIFs3sV5VSC49z6pEhfbMULvShKj26WA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 6" + } + }, "node_modules/supports-color": { "version": "5.5.0", "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-5.5.0.tgz", @@ -14669,11 +15412,100 @@ "node": ">=8" } }, - "node_modules/tar": { - "version": "7.5.1", - "resolved": "https://registry.npmjs.org/tar/-/tar-7.5.1.tgz", - "integrity": "sha512-nlGpxf+hv0v7GkWBK2V9spgactGOp0qvfWRxUMjqHyzrt3SgwE48DIv/FhqPHJYLHpgW1opq3nERbz5Anq7n1g==", + "node_modules/tailwindcss": { + "version": "3.4.18", + "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-3.4.18.tgz", + "integrity": "sha512-6A2rnmW5xZMdw11LYjhcI5846rt9pbLSabY5XPxo+XWdxwZaFEn47Go4NzFiHu9sNNmr/kXivP1vStfvMaK1GQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@alloc/quick-lru": "^5.2.0", + "arg": "^5.0.2", + "chokidar": "^3.6.0", + "didyoumean": "^1.2.2", + "dlv": "^1.1.3", + "fast-glob": "^3.3.2", + "glob-parent": "^6.0.2", + "is-glob": "^4.0.3", + "jiti": "^1.21.7", + "lilconfig": "^3.1.3", + "micromatch": "^4.0.8", + "normalize-path": "^3.0.0", + "object-hash": "^3.0.0", + "picocolors": "^1.1.1", + "postcss": "^8.4.47", + "postcss-import": "^15.1.0", + "postcss-js": "^4.0.1", + "postcss-load-config": "^4.0.2 || ^5.0 || ^6.0", + "postcss-nested": "^6.2.0", + "postcss-selector-parser": "^6.1.2", + "resolve": "^1.22.8", + "sucrase": "^3.35.0" + }, + "bin": { + "tailwind": "lib/cli.js", + "tailwindcss": "lib/cli.js" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/tailwindcss/node_modules/chokidar": { + "version": "3.6.0", + "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-3.6.0.tgz", + "integrity": "sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw==", + "dev": true, + "license": "MIT", + "dependencies": { + "anymatch": "~3.1.2", + "braces": "~3.0.2", + "glob-parent": "~5.1.2", + "is-binary-path": "~2.1.0", + "is-glob": "~4.0.1", + "normalize-path": "~3.0.0", + "readdirp": "~3.6.0" + }, + "engines": { + "node": ">= 8.10.0" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + }, + "optionalDependencies": { + "fsevents": "~2.3.2" + } + }, + "node_modules/tailwindcss/node_modules/chokidar/node_modules/glob-parent": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz", + "integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==", + "dev": true, "license": "ISC", + "dependencies": { + "is-glob": "^4.0.1" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/tailwindcss/node_modules/readdirp": { + "version": "3.6.0", + "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-3.6.0.tgz", + "integrity": "sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA==", + "dev": true, + "license": "MIT", + "dependencies": { + "picomatch": "^2.2.1" + }, + "engines": { + "node": ">=8.10.0" + } + }, + "node_modules/tar": { + "version": "7.5.2", + "resolved": "https://registry.npmjs.org/tar/-/tar-7.5.2.tgz", + "integrity": "sha512-7NyxrTE4Anh8km8iEy7o0QYPs+0JKBTj5ZaqHg6B39erLg0qYXN3BijtShwbsNSvQ+LN75+KV+C4QR/f6Gwnpg==", + "license": "BlueOak-1.0.0", "dependencies": { "@isaacs/fs-minipass": "^4.0.0", "chownr": "^3.0.0", @@ -14686,9 +15518,9 @@ } }, "node_modules/tar-fs": { - "version": "2.1.3", - "resolved": "https://registry.npmjs.org/tar-fs/-/tar-fs-2.1.3.tgz", - "integrity": "sha512-090nwYJDmlhwFwEW3QQl+vaNnxsO2yVsd45eTKRBzSzu+hlb1w2K9inVq5b0ngXuLVqQ4ApvsUHHnu/zQNkWAg==", + "version": "2.1.4", + "resolved": "https://registry.npmjs.org/tar-fs/-/tar-fs-2.1.4.tgz", + "integrity": "sha512-mDAjwmZdh7LTT6pNleZ05Yt65HC3E+NiQzl672vQG38jIrehtJk/J3mNwIg+vShQPcLF/LV7CMnDW6vjj6sfYQ==", "dev": true, "license": "MIT", "optional": true, @@ -14816,6 +15648,29 @@ "url": "https://bevry.me/fund" } }, + "node_modules/thenify": { + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/thenify/-/thenify-3.3.1.tgz", + "integrity": "sha512-RVZSIV5IG10Hk3enotrhvz0T9em6cyHBLkH/YAZuKqd8hRkKhSfCGIcP2KUY0EPxndzANBmNllzWPwak+bheSw==", + "dev": true, + "license": "MIT", + "dependencies": { + "any-promise": "^1.0.0" + } + }, + "node_modules/thenify-all": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/thenify-all/-/thenify-all-1.6.0.tgz", + "integrity": "sha512-RNxQH/qI8/t3thXJDwcstUO4zeqo64+Uy/+sNVRBx4Xn2OX+OZ9oP+iJnNFqplFra2ZUVeKCSa2oVWi3T4uVmA==", + "dev": true, + "license": "MIT", + "dependencies": { + "thenify": ">= 3.1.0 < 4" + }, + "engines": { + "node": ">=0.8" + } + }, "node_modules/thingies": { "version": "2.5.0", "resolved": "https://registry.npmjs.org/thingies/-/thingies-2.5.0.tgz", @@ -15052,6 +15907,13 @@ "typescript": ">=4.8.4" } }, + "node_modules/ts-interface-checker": { + "version": "0.1.13", + "resolved": "https://registry.npmjs.org/ts-interface-checker/-/ts-interface-checker-0.1.13.tgz", + "integrity": "sha512-Y/arvbn+rrz3JCKl9C4kVNfTfSm2/mEp5FSz5EsZSANGPSlQrpRI5M4PKF+mJnE52jOO90PnPSc3Ur3bTQw0gA==", + "dev": true, + "license": "Apache-2.0" + }, "node_modules/tsconfig-paths": { "version": "3.15.0", "resolved": "https://registry.npmjs.org/tsconfig-paths/-/tsconfig-paths-3.15.0.tgz", @@ -15306,7 +16168,6 @@ "version": "2.1.0", "resolved": "https://registry.npmjs.org/uc.micro/-/uc.micro-2.1.0.tgz", "integrity": "sha512-ARDJmphmdvUk6Glw7y9DQ2bFkKBHwQHLi2lsaH6PPmz/Ka9sFOBsBluozhDltWmnv9u/cF6Rt87znRTPV+yp/A==", - "dev": true, "license": "MIT" }, "node_modules/ufo": { @@ -15346,6 +16207,7 @@ "version": "7.15.0", "resolved": "https://registry.npmjs.org/undici/-/undici-7.15.0.tgz", "integrity": "sha512-7oZJCPvvMvTd0OlqWsIxTuItTpJBpU1tcbVl24FMn3xt3+VSunwUasmfPJRE57oNO1KsZ4PgA1xTdAX4hq8NyQ==", + "dev": true, "license": "MIT", "engines": { "node": ">=20.18.1" @@ -15388,6 +16250,37 @@ "node": ">= 0.8" } }, + "node_modules/update-browserslist-db": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.1.4.tgz", + "integrity": "sha512-q0SPT4xyU84saUX+tomz1WLkxUbuaJnR1xWt17M7fJtEJigJeWUNGUqrauFXsHnqev9y9JTRGwk13tFBuKby4A==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "escalade": "^3.2.0", + "picocolors": "^1.1.1" + }, + "bin": { + "update-browserslist-db": "cli.js" + }, + "peerDependencies": { + "browserslist": ">= 4.21.0" + } + }, "node_modules/update-notifier": { "version": "7.3.1", "resolved": "https://registry.npmjs.org/update-notifier/-/update-notifier-7.3.1.tgz", @@ -15512,6 +16405,7 @@ "version": "4.4.1", "resolved": "https://registry.npmjs.org/uri-js/-/uri-js-4.4.1.tgz", "integrity": "sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==", + "dev": true, "license": "BSD-2-Clause", "dependencies": { "punycode": "^2.1.0" @@ -15822,6 +16716,15 @@ "node": ">=18" } }, + "node_modules/web-streams-polyfill": { + "version": "3.3.3", + "resolved": "https://registry.npmjs.org/web-streams-polyfill/-/web-streams-polyfill-3.3.3.tgz", + "integrity": "sha512-d2JWLCivmZYTSIoge9MsgFCZrt571BikcWGYkjC1khllbTeDlGqZ2D8vD8E/lJa8WGWbb7Plm8/XJYV7IJHZZw==", + "license": "MIT", + "engines": { + "node": ">= 8" + } + }, "node_modules/webidl-conversions": { "version": "7.0.0", "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-7.0.0.tgz", @@ -16411,22 +17314,13 @@ "url": "https://github.com/sponsors/colinhacks" } }, - "node_modules/zod-to-json-schema": { - "version": "3.24.6", - "resolved": "https://registry.npmjs.org/zod-to-json-schema/-/zod-to-json-schema-3.24.6.tgz", - "integrity": "sha512-h/z3PKvcTcTetyjl1fkj79MHNEjm+HpD6NXheWjzOekY7kV+lwDYnHw+ivHkijnCSMz1yJaWBD9vu/Fcmk+vEg==", - "license": "ISC", - "peerDependencies": { - "zod": "^3.24.1" - } - }, "packages/cli": { "name": "@qwen-code/qwen-code", - "version": "0.4.1", + "version": "0.6.0", "dependencies": { - "@google/genai": "1.16.0", + "@google/genai": "1.30.0", "@iarna/toml": "^2.2.5", - "@modelcontextprotocol/sdk": "^1.15.1", + "@modelcontextprotocol/sdk": "^1.25.1", "@qwen-code/qwen-code-core": "file:../core", "@types/update-notifier": "^6.0.8", "ansi-regex": "^6.2.2", @@ -16436,7 +17330,7 @@ "dotenv": "^17.1.0", "extract-zip": "^2.0.1", "fzf": "^0.5.2", - "glob": "^10.4.5", + "glob": "^10.5.0", "highlight.js": "^11.11.1", "ink": "^6.2.3", "ink-gradient": "^3.0.0", @@ -16452,8 +17346,8 @@ "string-width": "^7.1.0", "strip-ansi": "^7.1.0", "strip-json-comments": "^3.1.1", - "tar": "^7.5.1", - "undici": "^7.10.0", + "tar": "^7.5.2", + "undici": "^6.22.0", "update-notifier": "^7.3.1", "wrap-ansi": "9.0.2", "yargs": "^17.7.2", @@ -16490,6 +17384,75 @@ "node": ">=20" } }, + "packages/cli/node_modules/@google/genai": { + "version": "1.30.0", + "resolved": "https://registry.npmjs.org/@google/genai/-/genai-1.30.0.tgz", + "integrity": "sha512-3MRcgczBFbUat1wIlZoLJ0vCCfXgm7Qxjh59cZi2X08RgWLtm9hKOspzp7TOg1TV2e26/MLxR2GR5yD5GmBV2w==", + "license": "Apache-2.0", + "dependencies": { + "google-auth-library": "^10.3.0", + "ws": "^8.18.0" + }, + "engines": { + "node": ">=20.0.0" + }, + "peerDependencies": { + "@modelcontextprotocol/sdk": "^1.20.1" + }, + "peerDependenciesMeta": { + "@modelcontextprotocol/sdk": { + "optional": true + } + } + }, + "packages/cli/node_modules/@modelcontextprotocol/sdk": { + "version": "1.25.1", + "resolved": "https://registry.npmjs.org/@modelcontextprotocol/sdk/-/sdk-1.25.1.tgz", + "integrity": "sha512-yO28oVFFC7EBoiKdAn+VqRm+plcfv4v0xp6osG/VsCB0NlPZWi87ajbCZZ8f/RvOFLEu7//rSRmuZZ7lMoe3gQ==", + "license": "MIT", + "dependencies": { + "@hono/node-server": "^1.19.7", + "ajv": "^8.17.1", + "ajv-formats": "^3.0.1", + "content-type": "^1.0.5", + "cors": "^2.8.5", + "cross-spawn": "^7.0.5", + "eventsource": "^3.0.2", + "eventsource-parser": "^3.0.0", + "express": "^5.0.1", + "express-rate-limit": "^7.5.0", + "jose": "^6.1.1", + "json-schema-typed": "^8.0.2", + "pkce-challenge": "^5.0.0", + "raw-body": "^3.0.0", + "zod": "^3.25 || ^4.0", + "zod-to-json-schema": "^3.25.0" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@cfworker/json-schema": "^4.1.1", + "zod": "^3.25 || ^4.0" + }, + "peerDependenciesMeta": { + "@cfworker/json-schema": { + "optional": true + }, + "zod": { + "optional": false + } + } + }, + "packages/cli/node_modules/@modelcontextprotocol/sdk/node_modules/zod-to-json-schema": { + "version": "3.25.0", + "resolved": "https://registry.npmjs.org/zod-to-json-schema/-/zod-to-json-schema-3.25.0.tgz", + "integrity": "sha512-HvWtU2UG41LALjajJrML6uQejQhNJx+JBO9IflpSja4R03iNWfKXrj6W2h7ljuLyc1nKS+9yDyL/9tD1U/yBnQ==", + "license": "ISC", + "peerDependencies": { + "zod": "^3.25 || ^4" + } + }, "packages/cli/node_modules/@testing-library/react": { "version": "16.3.0", "resolved": "https://registry.npmjs.org/@testing-library/react/-/react-16.3.0.tgz", @@ -16518,6 +17481,436 @@ } } }, + "packages/cli/node_modules/accepts": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/accepts/-/accepts-2.0.0.tgz", + "integrity": "sha512-5cvg6CtKwfgdmVqY1WIiXKc3Q1bkRqGLi+2W/6ao+6Y7gu/RCwRuAhGEzh5B4KlszSuTLgZYuqFqo5bImjNKng==", + "license": "MIT", + "dependencies": { + "mime-types": "^3.0.0", + "negotiator": "^1.0.0" + }, + "engines": { + "node": ">= 0.6" + } + }, + "packages/cli/node_modules/ajv": { + "version": "8.17.1", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.17.1.tgz", + "integrity": "sha512-B/gBuNg5SiMTrPkC+A2+cW0RszwxYmn6VYxB/inlBStS5nx6xHIt/ehKRhIMhqusl7a8LjQoZnjCs5vhwxOQ1g==", + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.3", + "fast-uri": "^3.0.1", + "json-schema-traverse": "^1.0.0", + "require-from-string": "^2.0.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, + "packages/cli/node_modules/body-parser": { + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-2.2.1.tgz", + "integrity": "sha512-nfDwkulwiZYQIGwxdy0RUmowMhKcFVcYXUU7m4QlKYim1rUtg83xm2yjZ40QjDuc291AJjjeSc9b++AWHSgSHw==", + "license": "MIT", + "dependencies": { + "bytes": "^3.1.2", + "content-type": "^1.0.5", + "debug": "^4.4.3", + "http-errors": "^2.0.0", + "iconv-lite": "^0.7.0", + "on-finished": "^2.4.1", + "qs": "^6.14.0", + "raw-body": "^3.0.1", + "type-is": "^2.0.1" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "packages/cli/node_modules/content-disposition": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-1.0.1.tgz", + "integrity": "sha512-oIXISMynqSqm241k6kcQ5UwttDILMK4BiurCfGEREw6+X9jkkpEe5T9FZaApyLGGOnFuyMWZpdolTXMtvEJ08Q==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "packages/cli/node_modules/cookie-signature": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.2.2.tgz", + "integrity": "sha512-D76uU73ulSXrD1UXF4KE2TMxVVwhsnCgfAyTg9k8P6KGZjlXKrOLe4dJQKI3Bxi5wjesZoFXJWElNWBjPZMbhg==", + "license": "MIT", + "engines": { + "node": ">=6.6.0" + } + }, + "packages/cli/node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "packages/cli/node_modules/express": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/express/-/express-5.2.1.tgz", + "integrity": "sha512-hIS4idWWai69NezIdRt2xFVofaF4j+6INOpJlVOLDO8zXGpUVEVzIYk12UUi2JzjEzWL3IOAxcTubgz9Po0yXw==", + "license": "MIT", + "dependencies": { + "accepts": "^2.0.0", + "body-parser": "^2.2.1", + "content-disposition": "^1.0.0", + "content-type": "^1.0.5", + "cookie": "^0.7.1", + "cookie-signature": "^1.2.1", + "debug": "^4.4.0", + "depd": "^2.0.0", + "encodeurl": "^2.0.0", + "escape-html": "^1.0.3", + "etag": "^1.8.1", + "finalhandler": "^2.1.0", + "fresh": "^2.0.0", + "http-errors": "^2.0.0", + "merge-descriptors": "^2.0.0", + "mime-types": "^3.0.0", + "on-finished": "^2.4.1", + "once": "^1.4.0", + "parseurl": "^1.3.3", + "proxy-addr": "^2.0.7", + "qs": "^6.14.0", + "range-parser": "^1.2.1", + "router": "^2.2.0", + "send": "^1.1.0", + "serve-static": "^2.2.0", + "statuses": "^2.0.1", + "type-is": "^2.0.1", + "vary": "^1.1.2" + }, + "engines": { + "node": ">= 18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "packages/cli/node_modules/finalhandler": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-2.1.1.tgz", + "integrity": "sha512-S8KoZgRZN+a5rNwqTxlZZePjT/4cnm0ROV70LedRHZ0p8u9fRID0hJUZQpkKLzro8LfmC8sx23bY6tVNxv8pQA==", + "license": "MIT", + "dependencies": { + "debug": "^4.4.0", + "encodeurl": "^2.0.0", + "escape-html": "^1.0.3", + "on-finished": "^2.4.1", + "parseurl": "^1.3.3", + "statuses": "^2.0.1" + }, + "engines": { + "node": ">= 18.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "packages/cli/node_modules/fresh": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/fresh/-/fresh-2.0.0.tgz", + "integrity": "sha512-Rx/WycZ60HOaqLKAi6cHRKKI7zxWbJ31MhntmtwMoaTeF7XFH9hhBp8vITaMidfljRQ6eYWCKkaTK+ykVJHP2A==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "packages/cli/node_modules/gaxios": { + "version": "7.1.3", + "resolved": "https://registry.npmjs.org/gaxios/-/gaxios-7.1.3.tgz", + "integrity": "sha512-YGGyuEdVIjqxkxVH1pUTMY/XtmmsApXrCVv5EU25iX6inEPbV+VakJfLealkBtJN69AQmh1eGOdCl9Sm1UP6XQ==", + "license": "Apache-2.0", + "dependencies": { + "extend": "^3.0.2", + "https-proxy-agent": "^7.0.1", + "node-fetch": "^3.3.2", + "rimraf": "^5.0.1" + }, + "engines": { + "node": ">=18" + } + }, + "packages/cli/node_modules/gcp-metadata": { + "version": "8.1.2", + "resolved": "https://registry.npmjs.org/gcp-metadata/-/gcp-metadata-8.1.2.tgz", + "integrity": "sha512-zV/5HKTfCeKWnxG0Dmrw51hEWFGfcF2xiXqcA3+J90WDuP0SvoiSO5ORvcBsifmx/FoIjgQN3oNOGaQ5PhLFkg==", + "license": "Apache-2.0", + "dependencies": { + "gaxios": "^7.0.0", + "google-logging-utils": "^1.0.0", + "json-bigint": "^1.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "packages/cli/node_modules/google-auth-library": { + "version": "10.5.0", + "resolved": "https://registry.npmjs.org/google-auth-library/-/google-auth-library-10.5.0.tgz", + "integrity": "sha512-7ABviyMOlX5hIVD60YOfHw4/CxOfBhyduaYB+wbFWCWoni4N7SLcV46hrVRktuBbZjFC9ONyqamZITN7q3n32w==", + "license": "Apache-2.0", + "dependencies": { + "base64-js": "^1.3.0", + "ecdsa-sig-formatter": "^1.0.11", + "gaxios": "^7.0.0", + "gcp-metadata": "^8.0.0", + "google-logging-utils": "^1.0.0", + "gtoken": "^8.0.0", + "jws": "^4.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "packages/cli/node_modules/google-logging-utils": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/google-logging-utils/-/google-logging-utils-1.1.3.tgz", + "integrity": "sha512-eAmLkjDjAFCVXg7A1unxHsLf961m6y17QFqXqAXGj/gVkKFrEICfStRfwUlGNfeCEjNRa32JEWOUTlYXPyyKvA==", + "license": "Apache-2.0", + "engines": { + "node": ">=14" + } + }, + "packages/cli/node_modules/gtoken": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/gtoken/-/gtoken-8.0.0.tgz", + "integrity": "sha512-+CqsMbHPiSTdtSO14O51eMNlrp9N79gmeqmXeouJOhfucAedHw9noVe/n5uJk3tbKE6a+6ZCQg3RPhVhHByAIw==", + "license": "MIT", + "dependencies": { + "gaxios": "^7.0.0", + "jws": "^4.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "packages/cli/node_modules/http-errors": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.1.tgz", + "integrity": "sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==", + "license": "MIT", + "dependencies": { + "depd": "~2.0.0", + "inherits": "~2.0.4", + "setprototypeof": "~1.2.0", + "statuses": "~2.0.2", + "toidentifier": "~1.0.1" + }, + "engines": { + "node": ">= 0.8" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "packages/cli/node_modules/iconv-lite": { + "version": "0.7.1", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.7.1.tgz", + "integrity": "sha512-2Tth85cXwGFHfvRgZWszZSvdo+0Xsqmw8k8ZwxScfcBneNUraK+dxRxRm24nszx80Y0TVio8kKLt5sLE7ZCLlw==", + "license": "MIT", + "dependencies": { + "safer-buffer": ">= 2.1.2 < 3.0.0" + }, + "engines": { + "node": ">=0.10.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "packages/cli/node_modules/json-schema-traverse": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz", + "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==", + "license": "MIT" + }, + "packages/cli/node_modules/media-typer": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/media-typer/-/media-typer-1.1.0.tgz", + "integrity": "sha512-aisnrDP4GNe06UcKFnV5bfMNPBUw4jsLGaWwWfnH3v02GnBuXX2MCVn5RbrWo0j3pczUilYblq7fQ7Nw2t5XKw==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "packages/cli/node_modules/merge-descriptors": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/merge-descriptors/-/merge-descriptors-2.0.0.tgz", + "integrity": "sha512-Snk314V5ayFLhp3fkUREub6WtjBfPdCPY1Ln8/8munuLuiYhsABgBVWsozAG+MWMbVEvcdcpbi9R7ww22l9Q3g==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "packages/cli/node_modules/mime-types": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-3.0.2.tgz", + "integrity": "sha512-Lbgzdk0h4juoQ9fCKXW4by0UJqj+nOOrI9MJ1sSj4nI8aI2eo1qmvQEie4VD1glsS250n15LsWsYtCugiStS5A==", + "license": "MIT", + "dependencies": { + "mime-db": "^1.54.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "packages/cli/node_modules/negotiator": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-1.0.0.tgz", + "integrity": "sha512-8Ofs/AUQh8MaEcrlq5xOX0CQ9ypTF5dl78mjlMNfOK08fzpgTHQRQPBxcPlEtIw0yRpws+Zo/3r+5WRby7u3Gg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "packages/cli/node_modules/node-fetch": { + "version": "3.3.2", + "resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-3.3.2.tgz", + "integrity": "sha512-dRB78srN/l6gqWulah9SrxeYnxeddIG30+GOqK/9OlLVyLg3HPnr6SqOWTWOXKRwC2eGYCkZ59NNuSgvSrpgOA==", + "license": "MIT", + "dependencies": { + "data-uri-to-buffer": "^4.0.0", + "fetch-blob": "^3.1.4", + "formdata-polyfill": "^4.0.10" + }, + "engines": { + "node": "^12.20.0 || ^14.13.1 || >=16.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/node-fetch" + } + }, + "packages/cli/node_modules/qs": { + "version": "6.14.0", + "resolved": "https://registry.npmjs.org/qs/-/qs-6.14.0.tgz", + "integrity": "sha512-YWWTjgABSKcvs/nWBi9PycY/JiPJqOD4JA6o9Sej2AtvSGarXxKC3OQSk4pAarbdQlKAh5D4FCQkJNkW+GAn3w==", + "license": "BSD-3-Clause", + "dependencies": { + "side-channel": "^1.1.0" + }, + "engines": { + "node": ">=0.6" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "packages/cli/node_modules/raw-body": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/raw-body/-/raw-body-3.0.2.tgz", + "integrity": "sha512-K5zQjDllxWkf7Z5xJdV0/B0WTNqx6vxG70zJE4N0kBs4LovmEYWJzQGxC9bS9RAKu3bgM40lrd5zoLJ12MQ5BA==", + "license": "MIT", + "dependencies": { + "bytes": "~3.1.2", + "http-errors": "~2.0.1", + "iconv-lite": "~0.7.0", + "unpipe": "~1.0.0" + }, + "engines": { + "node": ">= 0.10" + } + }, + "packages/cli/node_modules/rimraf": { + "version": "5.0.10", + "resolved": "https://registry.npmjs.org/rimraf/-/rimraf-5.0.10.tgz", + "integrity": "sha512-l0OE8wL34P4nJH/H2ffoaniAokM2qSmrtXHmlpvYr5AVVX8msAyW0l8NVJFDxlSK4u3Uh/f41cQheDVdnYijwQ==", + "license": "ISC", + "dependencies": { + "glob": "^10.3.7" + }, + "bin": { + "rimraf": "dist/esm/bin.mjs" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "packages/cli/node_modules/send": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/send/-/send-1.2.1.tgz", + "integrity": "sha512-1gnZf7DFcoIcajTjTwjwuDjzuz4PPcY2StKPlsGAQ1+YH20IRVrBaXSWmdjowTJ6u8Rc01PoYOGHXfP1mYcZNQ==", + "license": "MIT", + "dependencies": { + "debug": "^4.4.3", + "encodeurl": "^2.0.0", + "escape-html": "^1.0.3", + "etag": "^1.8.1", + "fresh": "^2.0.0", + "http-errors": "^2.0.1", + "mime-types": "^3.0.2", + "ms": "^2.1.3", + "on-finished": "^2.4.1", + "range-parser": "^1.2.1", + "statuses": "^2.0.2" + }, + "engines": { + "node": ">= 18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "packages/cli/node_modules/serve-static": { + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/serve-static/-/serve-static-2.2.1.tgz", + "integrity": "sha512-xRXBn0pPqQTVQiC8wyQrKs2MOlX24zQ0POGaj0kultvoOCstBQM5yvOhAVSUwOMjQtTvsPWoNCHfPGwaaQJhTw==", + "license": "MIT", + "dependencies": { + "encodeurl": "^2.0.0", + "escape-html": "^1.0.3", + "parseurl": "^1.3.3", + "send": "^1.2.0" + }, + "engines": { + "node": ">= 18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, "packages/cli/node_modules/string-width": { "version": "7.2.0", "resolved": "https://registry.npmjs.org/string-width/-/string-width-7.2.0.tgz", @@ -16535,13 +17928,37 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "packages/cli/node_modules/type-is": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/type-is/-/type-is-2.0.1.tgz", + "integrity": "sha512-OZs6gsjF4vMp32qrCbiVSkrFmXtG/AZhY3t0iAMrMBiAZyV9oALtXO8hsrHbMXF9x6L3grlFuwW2oAz7cav+Gw==", + "license": "MIT", + "dependencies": { + "content-type": "^1.0.5", + "media-typer": "^1.1.0", + "mime-types": "^3.0.0" + }, + "engines": { + "node": ">= 0.6" + } + }, + "packages/cli/node_modules/undici": { + "version": "6.22.0", + "resolved": "https://registry.npmjs.org/undici/-/undici-6.22.0.tgz", + "integrity": "sha512-hU/10obOIu62MGYjdskASR3CUAiYaFTtC9Pa6vHyf//mAipSvSQg6od2CnJswq7fvzNS3zJhxoRkgNVaHurWKw==", + "license": "MIT", + "engines": { + "node": ">=18.17" + } + }, "packages/core": { "name": "@qwen-code/qwen-code-core", - "version": "0.4.1", + "version": "0.6.0", "hasInstallScript": true, "dependencies": { - "@google/genai": "1.16.0", - "@modelcontextprotocol/sdk": "^1.11.0", + "@anthropic-ai/sdk": "^0.36.1", + "@google/genai": "1.30.0", + "@modelcontextprotocol/sdk": "^1.25.1", "@opentelemetry/api": "^1.9.0", "@opentelemetry/exporter-logs-otlp-grpc": "^0.203.0", "@opentelemetry/exporter-logs-otlp-http": "^0.203.0", @@ -16550,7 +17967,6 @@ "@opentelemetry/exporter-trace-otlp-grpc": "^0.203.0", "@opentelemetry/exporter-trace-otlp-http": "^0.203.0", "@opentelemetry/instrumentation-http": "^0.203.0", - "@opentelemetry/resource-detector-gcp": "^0.40.0", "@opentelemetry/sdk-node": "^0.203.0", "@types/html-to-text": "^9.0.4", "@xterm/headless": "5.5.0", @@ -16564,8 +17980,8 @@ "fast-uri": "^3.0.6", "fdir": "^6.4.6", "fzf": "^0.5.2", - "glob": "^10.4.5", - "google-auth-library": "^9.11.0", + "glob": "^10.5.0", + "google-auth-library": "^10.5.0", "html-to-text": "^9.0.5", "https-proxy-agent": "^7.0.6", "ignore": "^7.0.0", @@ -16580,7 +17996,7 @@ "simple-git": "^3.28.0", "strip-ansi": "^7.1.0", "tiktoken": "^1.0.21", - "undici": "^7.10.0", + "undici": "^6.22.0", "uuid": "^9.0.1", "ws": "^8.18.0" }, @@ -16609,6 +18025,79 @@ "node-pty": "^1.0.0" } }, + "packages/core/node_modules/@google/genai": { + "version": "1.30.0", + "resolved": "https://registry.npmjs.org/@google/genai/-/genai-1.30.0.tgz", + "integrity": "sha512-3MRcgczBFbUat1wIlZoLJ0vCCfXgm7Qxjh59cZi2X08RgWLtm9hKOspzp7TOg1TV2e26/MLxR2GR5yD5GmBV2w==", + "license": "Apache-2.0", + "dependencies": { + "google-auth-library": "^10.3.0", + "ws": "^8.18.0" + }, + "engines": { + "node": ">=20.0.0" + }, + "peerDependencies": { + "@modelcontextprotocol/sdk": "^1.20.1" + }, + "peerDependenciesMeta": { + "@modelcontextprotocol/sdk": { + "optional": true + } + } + }, + "packages/core/node_modules/@modelcontextprotocol/sdk": { + "version": "1.25.1", + "resolved": "https://registry.npmjs.org/@modelcontextprotocol/sdk/-/sdk-1.25.1.tgz", + "integrity": "sha512-yO28oVFFC7EBoiKdAn+VqRm+plcfv4v0xp6osG/VsCB0NlPZWi87ajbCZZ8f/RvOFLEu7//rSRmuZZ7lMoe3gQ==", + "license": "MIT", + "dependencies": { + "@hono/node-server": "^1.19.7", + "ajv": "^8.17.1", + "ajv-formats": "^3.0.1", + "content-type": "^1.0.5", + "cors": "^2.8.5", + "cross-spawn": "^7.0.5", + "eventsource": "^3.0.2", + "eventsource-parser": "^3.0.0", + "express": "^5.0.1", + "express-rate-limit": "^7.5.0", + "jose": "^6.1.1", + "json-schema-typed": "^8.0.2", + "pkce-challenge": "^5.0.0", + "raw-body": "^3.0.0", + "zod": "^3.25 || ^4.0", + "zod-to-json-schema": "^3.25.0" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@cfworker/json-schema": "^4.1.1", + "zod": "^3.25 || ^4.0" + }, + "peerDependenciesMeta": { + "@cfworker/json-schema": { + "optional": true + }, + "zod": { + "optional": false + } + } + }, + "packages/core/node_modules/accepts": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/accepts/-/accepts-2.0.0.tgz", + "integrity": "sha512-5cvg6CtKwfgdmVqY1WIiXKc3Q1bkRqGLi+2W/6ao+6Y7gu/RCwRuAhGEzh5B4KlszSuTLgZYuqFqo5bImjNKng==", + "license": "MIT", + "dependencies": { + "mime-types": "^3.0.0", + "negotiator": "^1.0.0" + }, + "engines": { + "node": ">= 0.6" + } + }, "packages/core/node_modules/ajv": { "version": "8.17.1", "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.17.1.tgz", @@ -16625,6 +18114,112 @@ "url": "https://github.com/sponsors/epoberezkin" } }, + "packages/core/node_modules/body-parser": { + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-2.2.1.tgz", + "integrity": "sha512-nfDwkulwiZYQIGwxdy0RUmowMhKcFVcYXUU7m4QlKYim1rUtg83xm2yjZ40QjDuc291AJjjeSc9b++AWHSgSHw==", + "license": "MIT", + "dependencies": { + "bytes": "^3.1.2", + "content-type": "^1.0.5", + "debug": "^4.4.3", + "http-errors": "^2.0.0", + "iconv-lite": "^0.7.0", + "on-finished": "^2.4.1", + "qs": "^6.14.0", + "raw-body": "^3.0.1", + "type-is": "^2.0.1" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "packages/core/node_modules/content-disposition": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-1.0.1.tgz", + "integrity": "sha512-oIXISMynqSqm241k6kcQ5UwttDILMK4BiurCfGEREw6+X9jkkpEe5T9FZaApyLGGOnFuyMWZpdolTXMtvEJ08Q==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "packages/core/node_modules/cookie-signature": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.2.2.tgz", + "integrity": "sha512-D76uU73ulSXrD1UXF4KE2TMxVVwhsnCgfAyTg9k8P6KGZjlXKrOLe4dJQKI3Bxi5wjesZoFXJWElNWBjPZMbhg==", + "license": "MIT", + "engines": { + "node": ">=6.6.0" + } + }, + "packages/core/node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "packages/core/node_modules/express": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/express/-/express-5.2.1.tgz", + "integrity": "sha512-hIS4idWWai69NezIdRt2xFVofaF4j+6INOpJlVOLDO8zXGpUVEVzIYk12UUi2JzjEzWL3IOAxcTubgz9Po0yXw==", + "license": "MIT", + "dependencies": { + "accepts": "^2.0.0", + "body-parser": "^2.2.1", + "content-disposition": "^1.0.0", + "content-type": "^1.0.5", + "cookie": "^0.7.1", + "cookie-signature": "^1.2.1", + "debug": "^4.4.0", + "depd": "^2.0.0", + "encodeurl": "^2.0.0", + "escape-html": "^1.0.3", + "etag": "^1.8.1", + "finalhandler": "^2.1.0", + "fresh": "^2.0.0", + "http-errors": "^2.0.0", + "merge-descriptors": "^2.0.0", + "mime-types": "^3.0.0", + "on-finished": "^2.4.1", + "once": "^1.4.0", + "parseurl": "^1.3.3", + "proxy-addr": "^2.0.7", + "qs": "^6.14.0", + "range-parser": "^1.2.1", + "router": "^2.2.0", + "send": "^1.1.0", + "serve-static": "^2.2.0", + "statuses": "^2.0.1", + "type-is": "^2.0.1", + "vary": "^1.1.2" + }, + "engines": { + "node": ">= 18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, "packages/core/node_modules/fdir": { "version": "6.4.6", "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.4.6.tgz", @@ -16639,6 +18234,141 @@ } } }, + "packages/core/node_modules/finalhandler": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-2.1.1.tgz", + "integrity": "sha512-S8KoZgRZN+a5rNwqTxlZZePjT/4cnm0ROV70LedRHZ0p8u9fRID0hJUZQpkKLzro8LfmC8sx23bY6tVNxv8pQA==", + "license": "MIT", + "dependencies": { + "debug": "^4.4.0", + "encodeurl": "^2.0.0", + "escape-html": "^1.0.3", + "on-finished": "^2.4.1", + "parseurl": "^1.3.3", + "statuses": "^2.0.1" + }, + "engines": { + "node": ">= 18.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "packages/core/node_modules/fresh": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/fresh/-/fresh-2.0.0.tgz", + "integrity": "sha512-Rx/WycZ60HOaqLKAi6cHRKKI7zxWbJ31MhntmtwMoaTeF7XFH9hhBp8vITaMidfljRQ6eYWCKkaTK+ykVJHP2A==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "packages/core/node_modules/gaxios": { + "version": "7.1.3", + "resolved": "https://registry.npmjs.org/gaxios/-/gaxios-7.1.3.tgz", + "integrity": "sha512-YGGyuEdVIjqxkxVH1pUTMY/XtmmsApXrCVv5EU25iX6inEPbV+VakJfLealkBtJN69AQmh1eGOdCl9Sm1UP6XQ==", + "license": "Apache-2.0", + "dependencies": { + "extend": "^3.0.2", + "https-proxy-agent": "^7.0.1", + "node-fetch": "^3.3.2", + "rimraf": "^5.0.1" + }, + "engines": { + "node": ">=18" + } + }, + "packages/core/node_modules/gcp-metadata": { + "version": "8.1.2", + "resolved": "https://registry.npmjs.org/gcp-metadata/-/gcp-metadata-8.1.2.tgz", + "integrity": "sha512-zV/5HKTfCeKWnxG0Dmrw51hEWFGfcF2xiXqcA3+J90WDuP0SvoiSO5ORvcBsifmx/FoIjgQN3oNOGaQ5PhLFkg==", + "license": "Apache-2.0", + "dependencies": { + "gaxios": "^7.0.0", + "google-logging-utils": "^1.0.0", + "json-bigint": "^1.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "packages/core/node_modules/google-auth-library": { + "version": "10.5.0", + "resolved": "https://registry.npmjs.org/google-auth-library/-/google-auth-library-10.5.0.tgz", + "integrity": "sha512-7ABviyMOlX5hIVD60YOfHw4/CxOfBhyduaYB+wbFWCWoni4N7SLcV46hrVRktuBbZjFC9ONyqamZITN7q3n32w==", + "license": "Apache-2.0", + "dependencies": { + "base64-js": "^1.3.0", + "ecdsa-sig-formatter": "^1.0.11", + "gaxios": "^7.0.0", + "gcp-metadata": "^8.0.0", + "google-logging-utils": "^1.0.0", + "gtoken": "^8.0.0", + "jws": "^4.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "packages/core/node_modules/google-logging-utils": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/google-logging-utils/-/google-logging-utils-1.1.3.tgz", + "integrity": "sha512-eAmLkjDjAFCVXg7A1unxHsLf961m6y17QFqXqAXGj/gVkKFrEICfStRfwUlGNfeCEjNRa32JEWOUTlYXPyyKvA==", + "license": "Apache-2.0", + "engines": { + "node": ">=14" + } + }, + "packages/core/node_modules/gtoken": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/gtoken/-/gtoken-8.0.0.tgz", + "integrity": "sha512-+CqsMbHPiSTdtSO14O51eMNlrp9N79gmeqmXeouJOhfucAedHw9noVe/n5uJk3tbKE6a+6ZCQg3RPhVhHByAIw==", + "license": "MIT", + "dependencies": { + "gaxios": "^7.0.0", + "jws": "^4.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "packages/core/node_modules/http-errors": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.1.tgz", + "integrity": "sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==", + "license": "MIT", + "dependencies": { + "depd": "~2.0.0", + "inherits": "~2.0.4", + "setprototypeof": "~1.2.0", + "statuses": "~2.0.2", + "toidentifier": "~1.0.1" + }, + "engines": { + "node": ">= 0.8" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "packages/core/node_modules/iconv-lite": { + "version": "0.7.1", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.7.1.tgz", + "integrity": "sha512-2Tth85cXwGFHfvRgZWszZSvdo+0Xsqmw8k8ZwxScfcBneNUraK+dxRxRm24nszx80Y0TVio8kKLt5sLE7ZCLlw==", + "license": "MIT", + "dependencies": { + "safer-buffer": ">= 2.1.2 < 3.0.0" + }, + "engines": { + "node": ">=0.10.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, "packages/core/node_modules/ignore": { "version": "7.0.5", "resolved": "https://registry.npmjs.org/ignore/-/ignore-7.0.5.tgz", @@ -16648,6 +18378,27 @@ "node": ">= 4" } }, + "packages/core/node_modules/media-typer": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/media-typer/-/media-typer-1.1.0.tgz", + "integrity": "sha512-aisnrDP4GNe06UcKFnV5bfMNPBUw4jsLGaWwWfnH3v02GnBuXX2MCVn5RbrWo0j3pczUilYblq7fQ7Nw2t5XKw==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "packages/core/node_modules/merge-descriptors": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/merge-descriptors/-/merge-descriptors-2.0.0.tgz", + "integrity": "sha512-Snk314V5ayFLhp3fkUREub6WtjBfPdCPY1Ln8/8munuLuiYhsABgBVWsozAG+MWMbVEvcdcpbi9R7ww22l9Q3g==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "packages/core/node_modules/mime": { "version": "4.0.7", "resolved": "https://registry.npmjs.org/mime/-/mime-4.0.7.tgz", @@ -16663,6 +18414,49 @@ "node": ">=16" } }, + "packages/core/node_modules/mime-types": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-3.0.2.tgz", + "integrity": "sha512-Lbgzdk0h4juoQ9fCKXW4by0UJqj+nOOrI9MJ1sSj4nI8aI2eo1qmvQEie4VD1glsS250n15LsWsYtCugiStS5A==", + "license": "MIT", + "dependencies": { + "mime-db": "^1.54.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "packages/core/node_modules/negotiator": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-1.0.0.tgz", + "integrity": "sha512-8Ofs/AUQh8MaEcrlq5xOX0CQ9ypTF5dl78mjlMNfOK08fzpgTHQRQPBxcPlEtIw0yRpws+Zo/3r+5WRby7u3Gg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "packages/core/node_modules/node-fetch": { + "version": "3.3.2", + "resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-3.3.2.tgz", + "integrity": "sha512-dRB78srN/l6gqWulah9SrxeYnxeddIG30+GOqK/9OlLVyLg3HPnr6SqOWTWOXKRwC2eGYCkZ59NNuSgvSrpgOA==", + "license": "MIT", + "dependencies": { + "data-uri-to-buffer": "^4.0.0", + "fetch-blob": "^3.1.4", + "formdata-polyfill": "^4.0.10" + }, + "engines": { + "node": "^12.20.0 || ^14.13.1 || >=16.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/node-fetch" + } + }, "packages/core/node_modules/picomatch": { "version": "4.0.3", "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz", @@ -16675,12 +18469,136 @@ "url": "https://github.com/sponsors/jonschlinkert" } }, + "packages/core/node_modules/qs": { + "version": "6.14.0", + "resolved": "https://registry.npmjs.org/qs/-/qs-6.14.0.tgz", + "integrity": "sha512-YWWTjgABSKcvs/nWBi9PycY/JiPJqOD4JA6o9Sej2AtvSGarXxKC3OQSk4pAarbdQlKAh5D4FCQkJNkW+GAn3w==", + "license": "BSD-3-Clause", + "dependencies": { + "side-channel": "^1.1.0" + }, + "engines": { + "node": ">=0.6" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "packages/core/node_modules/raw-body": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/raw-body/-/raw-body-3.0.2.tgz", + "integrity": "sha512-K5zQjDllxWkf7Z5xJdV0/B0WTNqx6vxG70zJE4N0kBs4LovmEYWJzQGxC9bS9RAKu3bgM40lrd5zoLJ12MQ5BA==", + "license": "MIT", + "dependencies": { + "bytes": "~3.1.2", + "http-errors": "~2.0.1", + "iconv-lite": "~0.7.0", + "unpipe": "~1.0.0" + }, + "engines": { + "node": ">= 0.10" + } + }, + "packages/core/node_modules/rimraf": { + "version": "5.0.10", + "resolved": "https://registry.npmjs.org/rimraf/-/rimraf-5.0.10.tgz", + "integrity": "sha512-l0OE8wL34P4nJH/H2ffoaniAokM2qSmrtXHmlpvYr5AVVX8msAyW0l8NVJFDxlSK4u3Uh/f41cQheDVdnYijwQ==", + "license": "ISC", + "dependencies": { + "glob": "^10.3.7" + }, + "bin": { + "rimraf": "dist/esm/bin.mjs" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "packages/core/node_modules/send": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/send/-/send-1.2.1.tgz", + "integrity": "sha512-1gnZf7DFcoIcajTjTwjwuDjzuz4PPcY2StKPlsGAQ1+YH20IRVrBaXSWmdjowTJ6u8Rc01PoYOGHXfP1mYcZNQ==", + "license": "MIT", + "dependencies": { + "debug": "^4.4.3", + "encodeurl": "^2.0.0", + "escape-html": "^1.0.3", + "etag": "^1.8.1", + "fresh": "^2.0.0", + "http-errors": "^2.0.1", + "mime-types": "^3.0.2", + "ms": "^2.1.3", + "on-finished": "^2.4.1", + "range-parser": "^1.2.1", + "statuses": "^2.0.2" + }, + "engines": { + "node": ">= 18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "packages/core/node_modules/serve-static": { + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/serve-static/-/serve-static-2.2.1.tgz", + "integrity": "sha512-xRXBn0pPqQTVQiC8wyQrKs2MOlX24zQ0POGaj0kultvoOCstBQM5yvOhAVSUwOMjQtTvsPWoNCHfPGwaaQJhTw==", + "license": "MIT", + "dependencies": { + "encodeurl": "^2.0.0", + "escape-html": "^1.0.3", + "parseurl": "^1.3.3", + "send": "^1.2.0" + }, + "engines": { + "node": ">= 18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "packages/core/node_modules/type-is": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/type-is/-/type-is-2.0.1.tgz", + "integrity": "sha512-OZs6gsjF4vMp32qrCbiVSkrFmXtG/AZhY3t0iAMrMBiAZyV9oALtXO8hsrHbMXF9x6L3grlFuwW2oAz7cav+Gw==", + "license": "MIT", + "dependencies": { + "content-type": "^1.0.5", + "media-typer": "^1.1.0", + "mime-types": "^3.0.0" + }, + "engines": { + "node": ">= 0.6" + } + }, + "packages/core/node_modules/undici": { + "version": "6.22.0", + "resolved": "https://registry.npmjs.org/undici/-/undici-6.22.0.tgz", + "integrity": "sha512-hU/10obOIu62MGYjdskASR3CUAiYaFTtC9Pa6vHyf//mAipSvSQg6od2CnJswq7fvzNS3zJhxoRkgNVaHurWKw==", + "license": "MIT", + "engines": { + "node": ">=18.17" + } + }, + "packages/core/node_modules/zod-to-json-schema": { + "version": "3.25.0", + "resolved": "https://registry.npmjs.org/zod-to-json-schema/-/zod-to-json-schema-3.25.0.tgz", + "integrity": "sha512-HvWtU2UG41LALjajJrML6uQejQhNJx+JBO9IflpSja4R03iNWfKXrj6W2h7ljuLyc1nKS+9yDyL/9tD1U/yBnQ==", + "license": "ISC", + "peerDependencies": { + "zod": "^3.25 || ^4" + } + }, "packages/sdk-typescript": { "name": "@qwen-code/sdk", "version": "0.1.0", "license": "Apache-2.0", "dependencies": { - "@modelcontextprotocol/sdk": "^1.0.4" + "@modelcontextprotocol/sdk": "^1.25.1", + "tiktoken": "^1.0.21", + "zod": "^3.25.0" }, "devDependencies": { "@types/node": "^20.14.0", @@ -16691,8 +18609,7 @@ "esbuild": "^0.25.12", "eslint": "^8.57.0", "typescript": "^5.4.5", - "vitest": "^1.6.0", - "zod": "^3.23.8" + "vitest": "^1.6.0" }, "engines": { "node": ">=18.0.0" @@ -17197,6 +19114,70 @@ "node": "^14.15.0 || ^16.10.0 || >=18.0.0" } }, + "packages/sdk-typescript/node_modules/@modelcontextprotocol/sdk": { + "version": "1.25.1", + "resolved": "https://registry.npmjs.org/@modelcontextprotocol/sdk/-/sdk-1.25.1.tgz", + "integrity": "sha512-yO28oVFFC7EBoiKdAn+VqRm+plcfv4v0xp6osG/VsCB0NlPZWi87ajbCZZ8f/RvOFLEu7//rSRmuZZ7lMoe3gQ==", + "license": "MIT", + "dependencies": { + "@hono/node-server": "^1.19.7", + "ajv": "^8.17.1", + "ajv-formats": "^3.0.1", + "content-type": "^1.0.5", + "cors": "^2.8.5", + "cross-spawn": "^7.0.5", + "eventsource": "^3.0.2", + "eventsource-parser": "^3.0.0", + "express": "^5.0.1", + "express-rate-limit": "^7.5.0", + "jose": "^6.1.1", + "json-schema-typed": "^8.0.2", + "pkce-challenge": "^5.0.0", + "raw-body": "^3.0.0", + "zod": "^3.25 || ^4.0", + "zod-to-json-schema": "^3.25.0" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@cfworker/json-schema": "^4.1.1", + "zod": "^3.25 || ^4.0" + }, + "peerDependenciesMeta": { + "@cfworker/json-schema": { + "optional": true + }, + "zod": { + "optional": false + } + } + }, + "packages/sdk-typescript/node_modules/@modelcontextprotocol/sdk/node_modules/ajv": { + "version": "8.17.1", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.17.1.tgz", + "integrity": "sha512-B/gBuNg5SiMTrPkC+A2+cW0RszwxYmn6VYxB/inlBStS5nx6xHIt/ehKRhIMhqusl7a8LjQoZnjCs5vhwxOQ1g==", + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.3", + "fast-uri": "^3.0.1", + "json-schema-traverse": "^1.0.0", + "require-from-string": "^2.0.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, + "packages/sdk-typescript/node_modules/@modelcontextprotocol/sdk/node_modules/zod-to-json-schema": { + "version": "3.25.0", + "resolved": "https://registry.npmjs.org/zod-to-json-schema/-/zod-to-json-schema-3.25.0.tgz", + "integrity": "sha512-HvWtU2UG41LALjajJrML6uQejQhNJx+JBO9IflpSja4R03iNWfKXrj6W2h7ljuLyc1nKS+9yDyL/9tD1U/yBnQ==", + "license": "ISC", + "peerDependencies": { + "zod": "^3.25 || ^4" + } + }, "packages/sdk-typescript/node_modules/@sinclair/typebox": { "version": "0.27.8", "resolved": "https://registry.npmjs.org/@sinclair/typebox/-/typebox-0.27.8.tgz", @@ -17515,6 +19496,19 @@ "url": "https://opencollective.com/vitest" } }, + "packages/sdk-typescript/node_modules/accepts": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/accepts/-/accepts-2.0.0.tgz", + "integrity": "sha512-5cvg6CtKwfgdmVqY1WIiXKc3Q1bkRqGLi+2W/6ao+6Y7gu/RCwRuAhGEzh5B4KlszSuTLgZYuqFqo5bImjNKng==", + "license": "MIT", + "dependencies": { + "mime-types": "^3.0.0", + "negotiator": "^1.0.0" + }, + "engines": { + "node": ">= 0.6" + } + }, "packages/sdk-typescript/node_modules/ansi-regex": { "version": "5.0.1", "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", @@ -17548,6 +19542,30 @@ "node": "*" } }, + "packages/sdk-typescript/node_modules/body-parser": { + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-2.2.1.tgz", + "integrity": "sha512-nfDwkulwiZYQIGwxdy0RUmowMhKcFVcYXUU7m4QlKYim1rUtg83xm2yjZ40QjDuc291AJjjeSc9b++AWHSgSHw==", + "license": "MIT", + "dependencies": { + "bytes": "^3.1.2", + "content-type": "^1.0.5", + "debug": "^4.4.3", + "http-errors": "^2.0.0", + "iconv-lite": "^0.7.0", + "on-finished": "^2.4.1", + "qs": "^6.14.0", + "raw-body": "^3.0.1", + "type-is": "^2.0.1" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, "packages/sdk-typescript/node_modules/brace-expansion": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.2.tgz", @@ -17590,6 +19608,45 @@ "node": "*" } }, + "packages/sdk-typescript/node_modules/content-disposition": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-1.0.1.tgz", + "integrity": "sha512-oIXISMynqSqm241k6kcQ5UwttDILMK4BiurCfGEREw6+X9jkkpEe5T9FZaApyLGGOnFuyMWZpdolTXMtvEJ08Q==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "packages/sdk-typescript/node_modules/cookie-signature": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.2.2.tgz", + "integrity": "sha512-D76uU73ulSXrD1UXF4KE2TMxVVwhsnCgfAyTg9k8P6KGZjlXKrOLe4dJQKI3Bxi5wjesZoFXJWElNWBjPZMbhg==", + "license": "MIT", + "engines": { + "node": ">=6.6.0" + } + }, + "packages/sdk-typescript/node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, "packages/sdk-typescript/node_modules/deep-eql": { "version": "4.1.4", "resolved": "https://registry.npmjs.org/deep-eql/-/deep-eql-4.1.4.tgz", @@ -17763,6 +19820,49 @@ "url": "https://opencollective.com/eslint" } }, + "packages/sdk-typescript/node_modules/express": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/express/-/express-5.2.1.tgz", + "integrity": "sha512-hIS4idWWai69NezIdRt2xFVofaF4j+6INOpJlVOLDO8zXGpUVEVzIYk12UUi2JzjEzWL3IOAxcTubgz9Po0yXw==", + "license": "MIT", + "dependencies": { + "accepts": "^2.0.0", + "body-parser": "^2.2.1", + "content-disposition": "^1.0.0", + "content-type": "^1.0.5", + "cookie": "^0.7.1", + "cookie-signature": "^1.2.1", + "debug": "^4.4.0", + "depd": "^2.0.0", + "encodeurl": "^2.0.0", + "escape-html": "^1.0.3", + "etag": "^1.8.1", + "finalhandler": "^2.1.0", + "fresh": "^2.0.0", + "http-errors": "^2.0.0", + "merge-descriptors": "^2.0.0", + "mime-types": "^3.0.0", + "on-finished": "^2.4.1", + "once": "^1.4.0", + "parseurl": "^1.3.3", + "proxy-addr": "^2.0.7", + "qs": "^6.14.0", + "range-parser": "^1.2.1", + "router": "^2.2.0", + "send": "^1.1.0", + "serve-static": "^2.2.0", + "statuses": "^2.0.1", + "type-is": "^2.0.1", + "vary": "^1.1.2" + }, + "engines": { + "node": ">= 18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, "packages/sdk-typescript/node_modules/file-entry-cache": { "version": "6.0.1", "resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-6.0.1.tgz", @@ -17776,6 +19876,27 @@ "node": "^10.12.0 || >=12.0.0" } }, + "packages/sdk-typescript/node_modules/finalhandler": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-2.1.1.tgz", + "integrity": "sha512-S8KoZgRZN+a5rNwqTxlZZePjT/4cnm0ROV70LedRHZ0p8u9fRID0hJUZQpkKLzro8LfmC8sx23bY6tVNxv8pQA==", + "license": "MIT", + "dependencies": { + "debug": "^4.4.0", + "encodeurl": "^2.0.0", + "escape-html": "^1.0.3", + "on-finished": "^2.4.1", + "parseurl": "^1.3.3", + "statuses": "^2.0.1" + }, + "engines": { + "node": ">= 18.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, "packages/sdk-typescript/node_modules/flat-cache": { "version": "3.2.0", "resolved": "https://registry.npmjs.org/flat-cache/-/flat-cache-3.2.0.tgz", @@ -17791,6 +19912,15 @@ "node": "^10.12.0 || >=12.0.0" } }, + "packages/sdk-typescript/node_modules/fresh": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/fresh/-/fresh-2.0.0.tgz", + "integrity": "sha512-Rx/WycZ60HOaqLKAi6cHRKKI7zxWbJ31MhntmtwMoaTeF7XFH9hhBp8vITaMidfljRQ6eYWCKkaTK+ykVJHP2A==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, "packages/sdk-typescript/node_modules/glob": { "version": "7.2.3", "resolved": "https://registry.npmjs.org/glob/-/glob-7.2.3.tgz", @@ -17850,6 +19980,42 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "packages/sdk-typescript/node_modules/http-errors": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.1.tgz", + "integrity": "sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==", + "license": "MIT", + "dependencies": { + "depd": "~2.0.0", + "inherits": "~2.0.4", + "setprototypeof": "~1.2.0", + "statuses": "~2.0.2", + "toidentifier": "~1.0.1" + }, + "engines": { + "node": ">= 0.8" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "packages/sdk-typescript/node_modules/iconv-lite": { + "version": "0.7.1", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.7.1.tgz", + "integrity": "sha512-2Tth85cXwGFHfvRgZWszZSvdo+0Xsqmw8k8ZwxScfcBneNUraK+dxRxRm24nszx80Y0TVio8kKLt5sLE7ZCLlw==", + "license": "MIT", + "dependencies": { + "safer-buffer": ">= 2.1.2 < 3.0.0" + }, + "engines": { + "node": ">=0.10.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, "packages/sdk-typescript/node_modules/is-path-inside": { "version": "3.0.3", "resolved": "https://registry.npmjs.org/is-path-inside/-/is-path-inside-3.0.3.tgz", @@ -17860,6 +20026,12 @@ "node": ">=8" } }, + "packages/sdk-typescript/node_modules/json-schema-traverse": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz", + "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==", + "license": "MIT" + }, "packages/sdk-typescript/node_modules/loupe": { "version": "2.3.7", "resolved": "https://registry.npmjs.org/loupe/-/loupe-2.3.7.tgz", @@ -17870,6 +20042,52 @@ "get-func-name": "^2.0.1" } }, + "packages/sdk-typescript/node_modules/media-typer": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/media-typer/-/media-typer-1.1.0.tgz", + "integrity": "sha512-aisnrDP4GNe06UcKFnV5bfMNPBUw4jsLGaWwWfnH3v02GnBuXX2MCVn5RbrWo0j3pczUilYblq7fQ7Nw2t5XKw==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "packages/sdk-typescript/node_modules/merge-descriptors": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/merge-descriptors/-/merge-descriptors-2.0.0.tgz", + "integrity": "sha512-Snk314V5ayFLhp3fkUREub6WtjBfPdCPY1Ln8/8munuLuiYhsABgBVWsozAG+MWMbVEvcdcpbi9R7ww22l9Q3g==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "packages/sdk-typescript/node_modules/mime-types": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-3.0.2.tgz", + "integrity": "sha512-Lbgzdk0h4juoQ9fCKXW4by0UJqj+nOOrI9MJ1sSj4nI8aI2eo1qmvQEie4VD1glsS250n15LsWsYtCugiStS5A==", + "license": "MIT", + "dependencies": { + "mime-db": "^1.54.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "packages/sdk-typescript/node_modules/negotiator": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-1.0.0.tgz", + "integrity": "sha512-8Ofs/AUQh8MaEcrlq5xOX0CQ9ypTF5dl78mjlMNfOK08fzpgTHQRQPBxcPlEtIw0yRpws+Zo/3r+5WRby7u3Gg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, "packages/sdk-typescript/node_modules/p-limit": { "version": "5.0.0", "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-5.0.0.tgz", @@ -17918,6 +20136,36 @@ "node": "^14.15.0 || ^16.10.0 || >=18.0.0" } }, + "packages/sdk-typescript/node_modules/qs": { + "version": "6.14.0", + "resolved": "https://registry.npmjs.org/qs/-/qs-6.14.0.tgz", + "integrity": "sha512-YWWTjgABSKcvs/nWBi9PycY/JiPJqOD4JA6o9Sej2AtvSGarXxKC3OQSk4pAarbdQlKAh5D4FCQkJNkW+GAn3w==", + "license": "BSD-3-Clause", + "dependencies": { + "side-channel": "^1.1.0" + }, + "engines": { + "node": ">=0.6" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "packages/sdk-typescript/node_modules/raw-body": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/raw-body/-/raw-body-3.0.2.tgz", + "integrity": "sha512-K5zQjDllxWkf7Z5xJdV0/B0WTNqx6vxG70zJE4N0kBs4LovmEYWJzQGxC9bS9RAKu3bgM40lrd5zoLJ12MQ5BA==", + "license": "MIT", + "dependencies": { + "bytes": "~3.1.2", + "http-errors": "~2.0.1", + "iconv-lite": "~0.7.0", + "unpipe": "~1.0.0" + }, + "engines": { + "node": ">= 0.10" + } + }, "packages/sdk-typescript/node_modules/react-is": { "version": "18.3.1", "resolved": "https://registry.npmjs.org/react-is/-/react-is-18.3.1.tgz", @@ -17925,6 +20173,51 @@ "dev": true, "license": "MIT" }, + "packages/sdk-typescript/node_modules/send": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/send/-/send-1.2.1.tgz", + "integrity": "sha512-1gnZf7DFcoIcajTjTwjwuDjzuz4PPcY2StKPlsGAQ1+YH20IRVrBaXSWmdjowTJ6u8Rc01PoYOGHXfP1mYcZNQ==", + "license": "MIT", + "dependencies": { + "debug": "^4.4.3", + "encodeurl": "^2.0.0", + "escape-html": "^1.0.3", + "etag": "^1.8.1", + "fresh": "^2.0.0", + "http-errors": "^2.0.1", + "mime-types": "^3.0.2", + "ms": "^2.1.3", + "on-finished": "^2.4.1", + "range-parser": "^1.2.1", + "statuses": "^2.0.2" + }, + "engines": { + "node": ">= 18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "packages/sdk-typescript/node_modules/serve-static": { + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/serve-static/-/serve-static-2.2.1.tgz", + "integrity": "sha512-xRXBn0pPqQTVQiC8wyQrKs2MOlX24zQ0POGaj0kultvoOCstBQM5yvOhAVSUwOMjQtTvsPWoNCHfPGwaaQJhTw==", + "license": "MIT", + "dependencies": { + "encodeurl": "^2.0.0", + "escape-html": "^1.0.3", + "parseurl": "^1.3.3", + "send": "^1.2.0" + }, + "engines": { + "node": ">= 18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, "packages/sdk-typescript/node_modules/slash": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/slash/-/slash-3.0.0.tgz", @@ -18022,6 +20315,20 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "packages/sdk-typescript/node_modules/type-is": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/type-is/-/type-is-2.0.1.tgz", + "integrity": "sha512-OZs6gsjF4vMp32qrCbiVSkrFmXtG/AZhY3t0iAMrMBiAZyV9oALtXO8hsrHbMXF9x6L3grlFuwW2oAz7cav+Gw==", + "license": "MIT", + "dependencies": { + "content-type": "^1.0.5", + "media-typer": "^1.1.0", + "mime-types": "^3.0.0" + }, + "engines": { + "node": ">= 0.6" + } + }, "packages/sdk-typescript/node_modules/vite-node": { "version": "1.6.1", "resolved": "https://registry.npmjs.org/vite-node/-/vite-node-1.6.1.tgz", @@ -19106,7 +21413,7 @@ }, "packages/test-utils": { "name": "@qwen-code/qwen-code-test-utils", - "version": "0.4.1", + "version": "0.6.0", "dev": true, "license": "Apache-2.0", "devDependencies": { @@ -19118,32 +21425,120 @@ }, "packages/vscode-ide-companion": { "name": "qwen-code-vscode-ide-companion", - "version": "0.4.1", + "version": "0.6.0", "license": "LICENSE", "dependencies": { - "@modelcontextprotocol/sdk": "^1.15.1", + "@modelcontextprotocol/sdk": "^1.25.1", "cors": "^2.8.5", "express": "^5.1.0", + "markdown-it": "^14.1.0", + "react": "^19.1.0", + "react-dom": "^19.1.0", + "semver": "^7.7.2", "zod": "^3.25.76" }, "devDependencies": { "@types/cors": "^2.8.19", "@types/express": "^5.0.3", + "@types/markdown-it": "^14.1.2", "@types/node": "20.x", - "@types/vscode": "^1.99.0", + "@types/react": "^19.1.8", + "@types/react-dom": "^19.1.6", + "@types/semver": "^7.7.1", + "@types/vscode": "^1.85.0", "@typescript-eslint/eslint-plugin": "^8.31.1", "@typescript-eslint/parser": "^8.31.1", "@vscode/vsce": "^3.6.0", + "autoprefixer": "^10.4.22", "esbuild": "^0.25.3", "eslint": "^9.25.1", + "eslint-plugin-react-hooks": "^5.2.0", "npm-run-all2": "^8.0.2", + "postcss": "^8.5.6", + "tailwindcss": "^3.4.18", "typescript": "^5.8.3", "vitest": "^3.2.4" }, "engines": { - "vscode": "^1.99.0" + "vscode": "^1.85.0" } }, + "packages/vscode-ide-companion/node_modules/@modelcontextprotocol/sdk": { + "version": "1.25.1", + "resolved": "https://registry.npmjs.org/@modelcontextprotocol/sdk/-/sdk-1.25.1.tgz", + "integrity": "sha512-yO28oVFFC7EBoiKdAn+VqRm+plcfv4v0xp6osG/VsCB0NlPZWi87ajbCZZ8f/RvOFLEu7//rSRmuZZ7lMoe3gQ==", + "license": "MIT", + "dependencies": { + "@hono/node-server": "^1.19.7", + "ajv": "^8.17.1", + "ajv-formats": "^3.0.1", + "content-type": "^1.0.5", + "cors": "^2.8.5", + "cross-spawn": "^7.0.5", + "eventsource": "^3.0.2", + "eventsource-parser": "^3.0.0", + "express": "^5.0.1", + "express-rate-limit": "^7.5.0", + "jose": "^6.1.1", + "json-schema-typed": "^8.0.2", + "pkce-challenge": "^5.0.0", + "raw-body": "^3.0.0", + "zod": "^3.25 || ^4.0", + "zod-to-json-schema": "^3.25.0" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@cfworker/json-schema": "^4.1.1", + "zod": "^3.25 || ^4.0" + }, + "peerDependenciesMeta": { + "@cfworker/json-schema": { + "optional": true + }, + "zod": { + "optional": false + } + } + }, + "packages/vscode-ide-companion/node_modules/@modelcontextprotocol/sdk/node_modules/zod-to-json-schema": { + "version": "3.25.0", + "resolved": "https://registry.npmjs.org/zod-to-json-schema/-/zod-to-json-schema-3.25.0.tgz", + "integrity": "sha512-HvWtU2UG41LALjajJrML6uQejQhNJx+JBO9IflpSja4R03iNWfKXrj6W2h7ljuLyc1nKS+9yDyL/9tD1U/yBnQ==", + "license": "ISC", + "peerDependencies": { + "zod": "^3.25 || ^4" + } + }, + "packages/vscode-ide-companion/node_modules/@types/react": { + "version": "18.3.27", + "resolved": "https://registry.npmjs.org/@types/react/-/react-18.3.27.tgz", + "integrity": "sha512-cisd7gxkzjBKU2GgdYrTdtQx1SORymWyaAFhaxQPK9bYO9ot3Y5OikQRvY0VYQtvwjeQnizCINJAenh/V7MK2w==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/prop-types": "*", + "csstype": "^3.2.2" + } + }, + "packages/vscode-ide-companion/node_modules/@types/react-dom": { + "version": "18.3.7", + "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-18.3.7.tgz", + "integrity": "sha512-MEe3UeoENYVFXzoXEWsvcpg6ZvlrFNlOQ7EOsvhI3CfAXwzPfO8Qwuxd40nepsYKqyyVQnTdEfv68q91yLcKrQ==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "@types/react": "^18.0.0" + } + }, + "packages/vscode-ide-companion/node_modules/@types/semver": { + "version": "7.7.1", + "resolved": "https://registry.npmjs.org/@types/semver/-/semver-7.7.1.tgz", + "integrity": "sha512-FmgJfu+MOcQ370SD0ev7EI8TlCAfKYU+B4m5T3yXc1CiRN94g/SZPtsCkk506aUDtlMnFZvasDwHHUcZUEaYuA==", + "dev": true, + "license": "MIT" + }, "packages/vscode-ide-companion/node_modules/@types/vscode": { "version": "1.99.0", "resolved": "https://registry.npmjs.org/@types/vscode/-/vscode-1.99.0.tgz", @@ -19151,6 +21546,22 @@ "dev": true, "license": "MIT" }, + "packages/vscode-ide-companion/node_modules/ajv": { + "version": "8.17.1", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.17.1.tgz", + "integrity": "sha512-B/gBuNg5SiMTrPkC+A2+cW0RszwxYmn6VYxB/inlBStS5nx6xHIt/ehKRhIMhqusl7a8LjQoZnjCs5vhwxOQ1g==", + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.3", + "fast-uri": "^3.0.1", + "json-schema-traverse": "^1.0.0", + "require-from-string": "^2.0.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, "packages/vscode-ide-companion/node_modules/content-disposition": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-1.0.0.tgz", @@ -19163,6 +21574,13 @@ "node": ">= 0.6" } }, + "packages/vscode-ide-companion/node_modules/csstype": { + "version": "3.2.3", + "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", + "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", + "dev": true, + "license": "MIT" + }, "packages/vscode-ide-companion/node_modules/express": { "version": "5.1.0", "resolved": "https://registry.npmjs.org/express/-/express-5.1.0.tgz", @@ -19222,6 +21640,12 @@ "node": ">= 0.8" } }, + "packages/vscode-ide-companion/node_modules/json-schema-traverse": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz", + "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==", + "license": "MIT" + }, "packages/vscode-ide-companion/node_modules/qs": { "version": "6.14.0", "resolved": "https://registry.npmjs.org/qs/-/qs-6.14.0.tgz", @@ -19237,6 +21661,40 @@ "url": "https://github.com/sponsors/ljharb" } }, + "packages/vscode-ide-companion/node_modules/react": { + "version": "18.3.1", + "resolved": "https://registry.npmjs.org/react/-/react-18.3.1.tgz", + "integrity": "sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ==", + "license": "MIT", + "dependencies": { + "loose-envify": "^1.1.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "packages/vscode-ide-companion/node_modules/react-dom": { + "version": "18.3.1", + "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-18.3.1.tgz", + "integrity": "sha512-5m4nQKp+rZRb09LNH59GM4BxTh9251/ylbKIbpe7TpGxfJ+9kv6BLkLBXIjjspbgbnIBNqlI23tRnTWT0snUIw==", + "license": "MIT", + "dependencies": { + "loose-envify": "^1.1.0", + "scheduler": "^0.23.2" + }, + "peerDependencies": { + "react": "^18.3.1" + } + }, + "packages/vscode-ide-companion/node_modules/scheduler": { + "version": "0.23.2", + "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.23.2.tgz", + "integrity": "sha512-UOShsPwz7NrMUqhR6t0hWjFduvOzbtv7toDH1/hIrfRNIDBnnBWd0CwJTGvTpngVlmwGCdP9/Zl/tVrDqcuYzQ==", + "license": "MIT", + "dependencies": { + "loose-envify": "^1.1.0" + } + }, "packages/vscode-ide-companion/node_modules/send": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/send/-/send-1.2.0.tgz", diff --git a/package.json b/package.json index 4049e2085..c239067ff 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@qwen-code/qwen-code", - "version": "0.4.1", + "version": "0.6.0", "engines": { "node": ">=20.0.0" }, @@ -13,14 +13,11 @@ "url": "git+https://github.com/QwenLM/qwen-code.git" }, "config": { - "sandboxImageUri": "ghcr.io/qwenlm/qwen-code:0.4.1" + "sandboxImageUri": "ghcr.io/qwenlm/qwen-code:0.6.0" }, "scripts": { "start": "cross-env node scripts/start.js", "debug": "cross-env DEBUG=1 node --inspect-brk scripts/start.js", - "auth:npm": "npx google-artifactregistry-auth", - "auth:docker": "gcloud auth configure-docker us-west1-docker.pkg.dev", - "auth": "npm run auth:npm && npm run auth:docker", "generate": "node scripts/generate-git-commit-info.js", "build": "node scripts/build.js", "build-and-start": "npm run build && npm run start", @@ -93,9 +90,8 @@ "eslint-plugin-license-header": "^0.8.0", "eslint-plugin-react": "^7.37.5", "eslint-plugin-react-hooks": "^5.2.0", - "glob": "^10.4.5", + "glob": "^10.5.0", "globals": "^16.0.0", - "google-artifactregistry-auth": "^3.4.0", "husky": "^9.1.7", "json": "^11.0.0", "lint-staged": "^16.1.6", diff --git a/packages/cli/package.json b/packages/cli/package.json index 5571db1ff..f2083fe19 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -1,6 +1,6 @@ { "name": "@qwen-code/qwen-code", - "version": "0.4.1", + "version": "0.6.0", "description": "Qwen Code", "repository": { "type": "git", @@ -33,13 +33,13 @@ "dist" ], "config": { - "sandboxImageUri": "ghcr.io/qwenlm/qwen-code:0.4.1" + "sandboxImageUri": "ghcr.io/qwenlm/qwen-code:0.6.0" }, "dependencies": { - "@google/genai": "1.16.0", + "@google/genai": "1.30.0", "@iarna/toml": "^2.2.5", "@qwen-code/qwen-code-core": "file:../core", - "@modelcontextprotocol/sdk": "^1.15.1", + "@modelcontextprotocol/sdk": "^1.25.1", "@types/update-notifier": "^6.0.8", "ansi-regex": "^6.2.2", "command-exists": "^1.2.9", @@ -47,7 +47,7 @@ "diff": "^7.0.0", "dotenv": "^17.1.0", "fzf": "^0.5.2", - "glob": "^10.4.5", + "glob": "^10.5.0", "highlight.js": "^11.11.1", "ink": "^6.2.3", "ink-gradient": "^3.0.0", @@ -63,8 +63,8 @@ "string-width": "^7.1.0", "strip-ansi": "^7.1.0", "strip-json-comments": "^3.1.1", - "tar": "^7.5.1", - "undici": "^7.10.0", + "tar": "^7.5.2", + "undici": "^6.22.0", "extract-zip": "^2.0.1", "update-notifier": "^7.3.1", "wrap-ansi": "9.0.2", diff --git a/packages/cli/src/acp-integration/acp.ts b/packages/cli/src/acp-integration/acp.ts index 2ef78bbd9..84ba5ff55 100644 --- a/packages/cli/src/acp-integration/acp.ts +++ b/packages/cli/src/acp-integration/acp.ts @@ -88,6 +88,16 @@ export class AgentSideConnection implements Client { ); } + /** + * Streams authentication updates (e.g. Qwen OAuth authUri) to the client. + */ + async authenticateUpdate(params: schema.AuthenticateUpdate): Promise { + return await this.#connection.sendNotification( + schema.CLIENT_METHODS.authenticate_update, + params, + ); + } + /** * Request permission before running a tool * @@ -241,9 +251,11 @@ class Connection { ).toResult(); } + let errorName; let details; if (error instanceof Error) { + errorName = error.name; details = error.message; } else if ( typeof error === 'object' && @@ -254,6 +266,10 @@ class Connection { details = error.message; } + if (errorName === 'TokenManagerError') { + return RequestError.authRequired(details).toResult(); + } + return RequestError.internalError(details).toResult(); } } @@ -357,6 +373,7 @@ export interface Client { params: schema.RequestPermissionRequest, ): Promise; sessionUpdate(params: schema.SessionNotification): Promise; + authenticateUpdate(params: schema.AuthenticateUpdate): Promise; writeTextFile( params: schema.WriteTextFileRequest, ): Promise; diff --git a/packages/cli/src/acp-integration/acpAgent.ts b/packages/cli/src/acp-integration/acpAgent.ts index fc3c4ccca..91ce53cb0 100644 --- a/packages/cli/src/acp-integration/acpAgent.ts +++ b/packages/cli/src/acp-integration/acpAgent.ts @@ -6,15 +6,19 @@ import type { ReadableStream, WritableStream } from 'node:stream/web'; -import type { Config, ConversationRecord } from '@qwen-code/qwen-code-core'; import { APPROVAL_MODE_INFO, APPROVAL_MODES, AuthType, clearCachedCredentialFile, + QwenOAuth2Event, + qwenOAuth2Events, MCPServerConfig, SessionService, buildApiHistoryFromConversation, + type Config, + type ConversationRecord, + type DeviceAuthorizationData, } from '@qwen-code/qwen-code-core'; import type { ApprovalModeValue } from './schema.js'; import * as acp from './acp.js'; @@ -123,13 +127,33 @@ class GeminiAgent { async authenticate({ methodId }: acp.AuthenticateRequest): Promise { const method = z.nativeEnum(AuthType).parse(methodId); + let authUri: string | undefined; + const authUriHandler = (deviceAuth: DeviceAuthorizationData) => { + authUri = deviceAuth.verification_uri_complete; + // Send the auth URL to ACP client as soon as it's available (refreshAuth is blocking). + void this.client.authenticateUpdate({ _meta: { authUri } }); + }; + + if (method === AuthType.QWEN_OAUTH) { + qwenOAuth2Events.once(QwenOAuth2Event.AuthUri, authUriHandler); + } + await clearCachedCredentialFile(); - await this.config.refreshAuth(method); - this.settings.setValue( - SettingScope.User, - 'security.auth.selectedType', - method, - ); + try { + await this.config.refreshAuth(method); + this.settings.setValue( + SettingScope.User, + 'security.auth.selectedType', + method, + ); + } finally { + // Ensure we don't leak listeners if auth fails early. + if (method === AuthType.QWEN_OAUTH) { + qwenOAuth2Events.off(QwenOAuth2Event.AuthUri, authUriHandler); + } + } + + return; } async newSession({ @@ -268,14 +292,17 @@ class GeminiAgent { private async ensureAuthenticated(config: Config): Promise { const selectedType = this.settings.merged.security?.auth?.selectedType; if (!selectedType) { - throw acp.RequestError.authRequired(); + throw acp.RequestError.authRequired('No Selected Type'); } try { - await config.refreshAuth(selectedType); + // Use true for the second argument to ensure only cached credentials are used + await config.refreshAuth(selectedType, true); } catch (e) { console.error(`Authentication failed: ${e}`); - throw acp.RequestError.authRequired(); + throw acp.RequestError.authRequired( + 'Authentication failed: ' + (e as Error).message, + ); } } diff --git a/packages/cli/src/acp-integration/schema.ts b/packages/cli/src/acp-integration/schema.ts index 8f21c74c6..a557c5197 100644 --- a/packages/cli/src/acp-integration/schema.ts +++ b/packages/cli/src/acp-integration/schema.ts @@ -20,6 +20,7 @@ export const AGENT_METHODS = { export const CLIENT_METHODS = { fs_read_text_file: 'fs/read_text_file', fs_write_text_file: 'fs/write_text_file', + authenticate_update: 'authenticate/update', session_request_permission: 'session/request_permission', session_update: 'session/update', }; @@ -57,8 +58,6 @@ export type CancelNotification = z.infer; export type AuthenticateRequest = z.infer; -export type AuthenticateResponse = z.infer; - export type NewSessionResponse = z.infer; export type LoadSessionResponse = z.infer; @@ -247,7 +246,13 @@ export const authenticateRequestSchema = z.object({ methodId: z.string(), }); -export const authenticateResponseSchema = z.null(); +export const authenticateUpdateSchema = z.object({ + _meta: z.object({ + authUri: z.string(), + }), +}); + +export type AuthenticateUpdate = z.infer; export const newSessionResponseSchema = z.object({ sessionId: z.string(), @@ -316,6 +321,23 @@ export const annotationsSchema = z.object({ priority: z.number().optional().nullable(), }); +export const usageSchema = z.object({ + promptTokens: z.number().optional().nullable(), + completionTokens: z.number().optional().nullable(), + thoughtsTokens: z.number().optional().nullable(), + totalTokens: z.number().optional().nullable(), + cachedTokens: z.number().optional().nullable(), +}); + +export type Usage = z.infer; + +export const sessionUpdateMetaSchema = z.object({ + usage: usageSchema.optional().nullable(), + durationMs: z.number().optional().nullable(), +}); + +export type SessionUpdateMeta = z.infer; + export const requestPermissionResponseSchema = z.object({ outcome: requestPermissionOutcomeSchema, }); @@ -500,10 +522,12 @@ export const sessionUpdateSchema = z.union([ z.object({ content: contentBlockSchema, sessionUpdate: z.literal('agent_message_chunk'), + _meta: sessionUpdateMetaSchema.optional().nullable(), }), z.object({ content: contentBlockSchema, sessionUpdate: z.literal('agent_thought_chunk'), + _meta: sessionUpdateMetaSchema.optional().nullable(), }), z.object({ content: z.array(toolCallContentSchema).optional(), @@ -536,7 +560,6 @@ export const sessionUpdateSchema = z.union([ export const agentResponseSchema = z.union([ initializeResponseSchema, - authenticateResponseSchema, newSessionResponseSchema, loadSessionResponseSchema, promptResponseSchema, diff --git a/packages/cli/src/acp-integration/service/filesystem.test.ts b/packages/cli/src/acp-integration/service/filesystem.test.ts new file mode 100644 index 000000000..70ccfc2d3 --- /dev/null +++ b/packages/cli/src/acp-integration/service/filesystem.test.ts @@ -0,0 +1,59 @@ +/** + * @license + * Copyright 2025 Qwen + * SPDX-License-Identifier: Apache-2.0 + */ + +import { describe, expect, it, vi } from 'vitest'; +import type { FileSystemService } from '@qwen-code/qwen-code-core'; +import { AcpFileSystemService } from './filesystem.js'; + +const createFallback = (): FileSystemService => ({ + readTextFile: vi.fn(), + writeTextFile: vi.fn(), + findFiles: vi.fn().mockReturnValue([]), +}); + +describe('AcpFileSystemService', () => { + describe('readTextFile ENOENT handling', () => { + it('parses path from ACP ENOENT message (quoted)', async () => { + const client = { + readTextFile: vi + .fn() + .mockResolvedValue({ content: 'ERROR: ENOENT: "/remote/file.txt"' }), + } as unknown as import('../acp.js').Client; + + const svc = new AcpFileSystemService( + client, + 'session-1', + { readTextFile: true, writeTextFile: true }, + createFallback(), + ); + + await expect(svc.readTextFile('/local/file.txt')).rejects.toMatchObject({ + code: 'ENOENT', + path: '/remote/file.txt', + }); + }); + + it('falls back to requested path when none provided', async () => { + const client = { + readTextFile: vi.fn().mockResolvedValue({ content: 'ERROR: ENOENT:' }), + } as unknown as import('../acp.js').Client; + + const svc = new AcpFileSystemService( + client, + 'session-2', + { readTextFile: true, writeTextFile: true }, + createFallback(), + ); + + await expect( + svc.readTextFile('/fallback/path.txt'), + ).rejects.toMatchObject({ + code: 'ENOENT', + path: '/fallback/path.txt', + }); + }); + }); +}); diff --git a/packages/cli/src/acp-integration/service/filesystem.ts b/packages/cli/src/acp-integration/service/filesystem.ts index c7db7235a..7bcaee2d6 100644 --- a/packages/cli/src/acp-integration/service/filesystem.ts +++ b/packages/cli/src/acp-integration/service/filesystem.ts @@ -30,6 +30,20 @@ export class AcpFileSystemService implements FileSystemService { limit: null, }); + if (response.content.startsWith('ERROR: ENOENT:')) { + // Treat ACP error strings as structured ENOENT errors without + // assuming a specific platform format. + const match = /^ERROR:\s*ENOENT:\s*(?.*)$/i.exec(response.content); + const err = new Error(response.content) as NodeJS.ErrnoException; + err.code = 'ENOENT'; + err.errno = -2; + const rawPath = match?.groups?.['path']?.trim(); + err['path'] = rawPath + ? rawPath.replace(/^['"]|['"]$/g, '') || filePath + : filePath; + throw err; + } + return response.content; } diff --git a/packages/cli/src/acp-integration/session/HistoryReplayer.test.ts b/packages/cli/src/acp-integration/session/HistoryReplayer.test.ts index 834515921..c9cf65fb8 100644 --- a/packages/cli/src/acp-integration/session/HistoryReplayer.test.ts +++ b/packages/cli/src/acp-integration/session/HistoryReplayer.test.ts @@ -411,4 +411,48 @@ describe('HistoryReplayer', () => { ]); }); }); + + describe('usage metadata replay', () => { + it('should emit usage metadata after assistant message content', async () => { + const record: ChatRecord = { + uuid: 'assistant-uuid', + parentUuid: 'user-uuid', + sessionId: 'test-session', + timestamp: new Date().toISOString(), + type: 'assistant', + cwd: '/test', + version: '1.0.0', + message: { + role: 'model', + parts: [{ text: 'Hello!' }], + }, + usageMetadata: { + promptTokenCount: 100, + candidatesTokenCount: 50, + totalTokenCount: 150, + }, + }; + + await replayer.replay([record]); + + expect(sendUpdateSpy).toHaveBeenCalledTimes(2); + expect(sendUpdateSpy).toHaveBeenNthCalledWith(1, { + sessionUpdate: 'agent_message_chunk', + content: { type: 'text', text: 'Hello!' }, + }); + expect(sendUpdateSpy).toHaveBeenNthCalledWith(2, { + sessionUpdate: 'agent_message_chunk', + content: { type: 'text', text: '' }, + _meta: { + usage: { + promptTokens: 100, + completionTokens: 50, + thoughtsTokens: undefined, + totalTokens: 150, + cachedTokens: undefined, + }, + }, + }); + }); + }); }); diff --git a/packages/cli/src/acp-integration/session/HistoryReplayer.ts b/packages/cli/src/acp-integration/session/HistoryReplayer.ts index 53a1ed8a9..0ecbccb9b 100644 --- a/packages/cli/src/acp-integration/session/HistoryReplayer.ts +++ b/packages/cli/src/acp-integration/session/HistoryReplayer.ts @@ -4,8 +4,11 @@ * SPDX-License-Identifier: Apache-2.0 */ -import type { ChatRecord } from '@qwen-code/qwen-code-core'; -import type { Content } from '@google/genai'; +import type { ChatRecord, TaskResultDisplay } from '@qwen-code/qwen-code-core'; +import type { + Content, + GenerateContentResponseUsageMetadata, +} from '@google/genai'; import type { SessionContext } from './types.js'; import { MessageEmitter } from './emitters/MessageEmitter.js'; import { ToolCallEmitter } from './emitters/ToolCallEmitter.js'; @@ -52,6 +55,9 @@ export class HistoryReplayer { if (record.message) { await this.replayContent(record.message, 'assistant'); } + if (record.usageMetadata) { + await this.replayUsageMetadata(record.usageMetadata); + } break; case 'tool_result': @@ -88,11 +94,22 @@ export class HistoryReplayer { toolName: functionName, callId, args: part.functionCall.args as Record, + status: 'in_progress', }); } } } + /** + * Replays usage metadata. + * @param usageMetadata - The usage metadata to replay + */ + private async replayUsageMetadata( + usageMetadata: GenerateContentResponseUsageMetadata, + ): Promise { + await this.messageEmitter.emitUsageMetadata(usageMetadata); + } + /** * Replays a tool result record. */ @@ -118,6 +135,54 @@ export class HistoryReplayer { // Note: args aren't stored in tool_result records by default args: undefined, }); + + // Special handling: Task tool execution summary contains token usage + const { resultDisplay } = result ?? {}; + if ( + !!resultDisplay && + typeof resultDisplay === 'object' && + 'type' in resultDisplay && + (resultDisplay as { type?: unknown }).type === 'task_execution' + ) { + await this.emitTaskUsageFromResultDisplay( + resultDisplay as TaskResultDisplay, + ); + } + } + + /** + * Emits token usage from a TaskResultDisplay execution summary, if present. + */ + private async emitTaskUsageFromResultDisplay( + resultDisplay: TaskResultDisplay, + ): Promise { + const summary = resultDisplay.executionSummary; + if (!summary) { + return; + } + + const usageMetadata: GenerateContentResponseUsageMetadata = {}; + + if (Number.isFinite(summary.inputTokens)) { + usageMetadata.promptTokenCount = summary.inputTokens; + } + if (Number.isFinite(summary.outputTokens)) { + usageMetadata.candidatesTokenCount = summary.outputTokens; + } + if (Number.isFinite(summary.thoughtTokens)) { + usageMetadata.thoughtsTokenCount = summary.thoughtTokens; + } + if (Number.isFinite(summary.cachedTokens)) { + usageMetadata.cachedContentTokenCount = summary.cachedTokens; + } + if (Number.isFinite(summary.totalTokens)) { + usageMetadata.totalTokenCount = summary.totalTokens; + } + + // Only emit if we captured at least one token metric + if (Object.keys(usageMetadata).length > 0) { + await this.messageEmitter.emitUsageMetadata(usageMetadata); + } } /** diff --git a/packages/cli/src/acp-integration/session/Session.ts b/packages/cli/src/acp-integration/session/Session.ts index b4d79433a..1d90ed20b 100644 --- a/packages/cli/src/acp-integration/session/Session.ts +++ b/packages/cli/src/acp-integration/session/Session.ts @@ -4,7 +4,12 @@ * SPDX-License-Identifier: Apache-2.0 */ -import type { Content, FunctionCall, Part } from '@google/genai'; +import type { + Content, + FunctionCall, + GenerateContentResponseUsageMetadata, + Part, +} from '@google/genai'; import type { Config, GeminiChat, @@ -55,6 +60,7 @@ import type { SessionContext, ToolCallStartParams } from './types.js'; import { HistoryReplayer } from './HistoryReplayer.js'; import { ToolCallEmitter } from './emitters/ToolCallEmitter.js'; import { PlanEmitter } from './emitters/PlanEmitter.js'; +import { MessageEmitter } from './emitters/MessageEmitter.js'; import { SubAgentTracker } from './SubAgentTracker.js'; /** @@ -79,6 +85,7 @@ export class Session implements SessionContext { private readonly historyReplayer: HistoryReplayer; private readonly toolCallEmitter: ToolCallEmitter; private readonly planEmitter: PlanEmitter; + private readonly messageEmitter: MessageEmitter; // Implement SessionContext interface readonly sessionId: string; @@ -96,6 +103,7 @@ export class Session implements SessionContext { this.toolCallEmitter = new ToolCallEmitter(this); this.planEmitter = new PlanEmitter(this); this.historyReplayer = new HistoryReplayer(this); + this.messageEmitter = new MessageEmitter(this); } getId(): string { @@ -192,6 +200,8 @@ export class Session implements SessionContext { } const functionCalls: FunctionCall[] = []; + let usageMetadata: GenerateContentResponseUsageMetadata | null = null; + const streamStartTime = Date.now(); try { const responseStream = await chat.sendMessageStream( @@ -222,20 +232,18 @@ export class Session implements SessionContext { continue; } - const content: acp.ContentBlock = { - type: 'text', - text: part.text, - }; - - this.sendUpdate({ - sessionUpdate: part.thought - ? 'agent_thought_chunk' - : 'agent_message_chunk', - content, - }); + this.messageEmitter.emitMessage( + part.text, + 'assistant', + part.thought, + ); } } + if (resp.type === StreamEventType.CHUNK && resp.value.usageMetadata) { + usageMetadata = resp.value.usageMetadata; + } + if (resp.type === StreamEventType.CHUNK && resp.value.functionCalls) { functionCalls.push(...resp.value.functionCalls); } @@ -251,6 +259,15 @@ export class Session implements SessionContext { throw error; } + if (usageMetadata) { + const durationMs = Date.now() - streamStartTime; + await this.messageEmitter.emitUsageMetadata( + usageMetadata, + '', + durationMs, + ); + } + if (functionCalls.length > 0) { const toolResponseParts: Part[] = []; @@ -444,7 +461,9 @@ export class Session implements SessionContext { } const confirmationDetails = - await invocation.shouldConfirmExecute(abortSignal); + this.config.getApprovalMode() !== ApprovalMode.YOLO + ? await invocation.shouldConfirmExecute(abortSignal) + : false; if (confirmationDetails) { const content: acp.ToolCallContent[] = []; @@ -522,6 +541,7 @@ export class Session implements SessionContext { callId, toolName: fc.name, args, + status: 'in_progress', }; await this.toolCallEmitter.emitStart(startParams); } diff --git a/packages/cli/src/acp-integration/session/SubAgentTracker.test.ts b/packages/cli/src/acp-integration/session/SubAgentTracker.test.ts index 074c81629..f2bb7cc50 100644 --- a/packages/cli/src/acp-integration/session/SubAgentTracker.test.ts +++ b/packages/cli/src/acp-integration/session/SubAgentTracker.test.ts @@ -208,7 +208,7 @@ describe('SubAgentTracker', () => { expect.objectContaining({ sessionUpdate: 'tool_call', toolCallId: 'call-123', - status: 'in_progress', + status: 'pending', title: 'read_file', content: [], locations: [], diff --git a/packages/cli/src/acp-integration/session/SubAgentTracker.ts b/packages/cli/src/acp-integration/session/SubAgentTracker.ts index c6c832929..1e745b925 100644 --- a/packages/cli/src/acp-integration/session/SubAgentTracker.ts +++ b/packages/cli/src/acp-integration/session/SubAgentTracker.ts @@ -9,6 +9,7 @@ import type { SubAgentToolCallEvent, SubAgentToolResultEvent, SubAgentApprovalRequestEvent, + SubAgentUsageEvent, ToolCallConfirmationDetails, AnyDeclarativeTool, AnyToolInvocation, @@ -20,6 +21,7 @@ import { import { z } from 'zod'; import type { SessionContext } from './types.js'; import { ToolCallEmitter } from './emitters/ToolCallEmitter.js'; +import { MessageEmitter } from './emitters/MessageEmitter.js'; import type * as acp from '../acp.js'; /** @@ -62,6 +64,7 @@ const basicPermissionOptions: readonly PermissionOptionConfig[] = [ */ export class SubAgentTracker { private readonly toolCallEmitter: ToolCallEmitter; + private readonly messageEmitter: MessageEmitter; private readonly toolStates = new Map< string, { @@ -76,6 +79,7 @@ export class SubAgentTracker { private readonly client: acp.Client, ) { this.toolCallEmitter = new ToolCallEmitter(ctx); + this.messageEmitter = new MessageEmitter(ctx); } /** @@ -92,16 +96,19 @@ export class SubAgentTracker { const onToolCall = this.createToolCallHandler(abortSignal); const onToolResult = this.createToolResultHandler(abortSignal); const onApproval = this.createApprovalHandler(abortSignal); + const onUsageMetadata = this.createUsageMetadataHandler(abortSignal); eventEmitter.on(SubAgentEventType.TOOL_CALL, onToolCall); eventEmitter.on(SubAgentEventType.TOOL_RESULT, onToolResult); eventEmitter.on(SubAgentEventType.TOOL_WAITING_APPROVAL, onApproval); + eventEmitter.on(SubAgentEventType.USAGE_METADATA, onUsageMetadata); return [ () => { eventEmitter.off(SubAgentEventType.TOOL_CALL, onToolCall); eventEmitter.off(SubAgentEventType.TOOL_RESULT, onToolResult); eventEmitter.off(SubAgentEventType.TOOL_WAITING_APPROVAL, onApproval); + eventEmitter.off(SubAgentEventType.USAGE_METADATA, onUsageMetadata); // Clean up any remaining states this.toolStates.clear(); }, @@ -252,6 +259,20 @@ export class SubAgentTracker { }; } + /** + * Creates a handler for usage metadata events. + */ + private createUsageMetadataHandler( + abortSignal: AbortSignal, + ): (...args: unknown[]) => void { + return (...args: unknown[]) => { + const event = args[0] as SubAgentUsageEvent; + if (abortSignal.aborted) return; + + this.messageEmitter.emitUsageMetadata(event.usage, '', event.durationMs); + }; + } + /** * Converts confirmation details to permission options for the client. */ diff --git a/packages/cli/src/acp-integration/session/emitters/MessageEmitter.test.ts b/packages/cli/src/acp-integration/session/emitters/MessageEmitter.test.ts index 52a41a483..d0b1ae870 100644 --- a/packages/cli/src/acp-integration/session/emitters/MessageEmitter.test.ts +++ b/packages/cli/src/acp-integration/session/emitters/MessageEmitter.test.ts @@ -148,4 +148,59 @@ describe('MessageEmitter', () => { }); }); }); + + describe('emitUsageMetadata', () => { + it('should emit agent_message_chunk with _meta.usage containing token counts', async () => { + const usageMetadata = { + promptTokenCount: 100, + candidatesTokenCount: 50, + thoughtsTokenCount: 25, + totalTokenCount: 175, + cachedContentTokenCount: 10, + }; + + await emitter.emitUsageMetadata(usageMetadata); + + expect(sendUpdateSpy).toHaveBeenCalledWith({ + sessionUpdate: 'agent_message_chunk', + content: { type: 'text', text: '' }, + _meta: { + usage: { + promptTokens: 100, + completionTokens: 50, + thoughtsTokens: 25, + totalTokens: 175, + cachedTokens: 10, + }, + }, + }); + }); + + it('should include durationMs in _meta when provided', async () => { + const usageMetadata = { + promptTokenCount: 10, + candidatesTokenCount: 5, + thoughtsTokenCount: 2, + totalTokenCount: 17, + cachedContentTokenCount: 1, + }; + + await emitter.emitUsageMetadata(usageMetadata, 'done', 1234); + + expect(sendUpdateSpy).toHaveBeenCalledWith({ + sessionUpdate: 'agent_message_chunk', + content: { type: 'text', text: 'done' }, + _meta: { + usage: { + promptTokens: 10, + completionTokens: 5, + thoughtsTokens: 2, + totalTokens: 17, + cachedTokens: 1, + }, + durationMs: 1234, + }, + }); + }); + }); }); diff --git a/packages/cli/src/acp-integration/session/emitters/MessageEmitter.ts b/packages/cli/src/acp-integration/session/emitters/MessageEmitter.ts index 9ac8943a4..39cdf6a72 100644 --- a/packages/cli/src/acp-integration/session/emitters/MessageEmitter.ts +++ b/packages/cli/src/acp-integration/session/emitters/MessageEmitter.ts @@ -4,6 +4,8 @@ * SPDX-License-Identifier: Apache-2.0 */ +import type { GenerateContentResponseUsageMetadata } from '@google/genai'; +import type { Usage } from '../../schema.js'; import { BaseEmitter } from './BaseEmitter.js'; /** @@ -24,6 +26,16 @@ export class MessageEmitter extends BaseEmitter { }); } + /** + * Emits an agent thought chunk. + */ + async emitAgentThought(text: string): Promise { + await this.sendUpdate({ + sessionUpdate: 'agent_thought_chunk', + content: { type: 'text', text }, + }); + } + /** * Emits an agent message chunk. */ @@ -35,12 +47,28 @@ export class MessageEmitter extends BaseEmitter { } /** - * Emits an agent thought chunk. + * Emits usage metadata. */ - async emitAgentThought(text: string): Promise { + async emitUsageMetadata( + usageMetadata: GenerateContentResponseUsageMetadata, + text: string = '', + durationMs?: number, + ): Promise { + const usage: Usage = { + promptTokens: usageMetadata.promptTokenCount, + completionTokens: usageMetadata.candidatesTokenCount, + thoughtsTokens: usageMetadata.thoughtsTokenCount, + totalTokens: usageMetadata.totalTokenCount, + cachedTokens: usageMetadata.cachedContentTokenCount, + }; + + const meta = + typeof durationMs === 'number' ? { usage, durationMs } : { usage }; + await this.sendUpdate({ - sessionUpdate: 'agent_thought_chunk', + sessionUpdate: 'agent_message_chunk', content: { type: 'text', text }, + _meta: meta, }); } diff --git a/packages/cli/src/acp-integration/session/emitters/ToolCallEmitter.test.ts b/packages/cli/src/acp-integration/session/emitters/ToolCallEmitter.test.ts index 52e13399a..4616b8592 100644 --- a/packages/cli/src/acp-integration/session/emitters/ToolCallEmitter.test.ts +++ b/packages/cli/src/acp-integration/session/emitters/ToolCallEmitter.test.ts @@ -71,7 +71,7 @@ describe('ToolCallEmitter', () => { expect(sendUpdateSpy).toHaveBeenCalledWith({ sessionUpdate: 'tool_call', toolCallId: 'call-123', - status: 'in_progress', + status: 'pending', title: 'unknown_tool', // Falls back to tool name content: [], locations: [], @@ -94,7 +94,7 @@ describe('ToolCallEmitter', () => { expect(sendUpdateSpy).toHaveBeenCalledWith({ sessionUpdate: 'tool_call', toolCallId: 'call-456', - status: 'in_progress', + status: 'pending', title: 'edit_file: Test tool description', content: [], locations: [{ path: '/test/file.ts', line: 10 }], @@ -144,7 +144,7 @@ describe('ToolCallEmitter', () => { expect(sendUpdateSpy).toHaveBeenCalledWith({ sessionUpdate: 'tool_call', toolCallId: 'call-fail', - status: 'in_progress', + status: 'pending', title: 'failing_tool', // Fallback to tool name content: [], locations: [], // Fallback to empty @@ -493,7 +493,7 @@ describe('ToolCallEmitter', () => { type: 'content', content: { type: 'text', - text: '{"output":"test output"}', + text: 'test output', }, }, ], @@ -650,7 +650,7 @@ describe('ToolCallEmitter', () => { content: [ { type: 'content', - content: { type: 'text', text: '{"output":"Function output"}' }, + content: { type: 'text', text: 'Function output' }, }, ], rawOutput: 'raw result', diff --git a/packages/cli/src/acp-integration/session/emitters/ToolCallEmitter.ts b/packages/cli/src/acp-integration/session/emitters/ToolCallEmitter.ts index 4c25570a6..9859ed78e 100644 --- a/packages/cli/src/acp-integration/session/emitters/ToolCallEmitter.ts +++ b/packages/cli/src/acp-integration/session/emitters/ToolCallEmitter.ts @@ -59,7 +59,7 @@ export class ToolCallEmitter extends BaseEmitter { await this.sendUpdate({ sessionUpdate: 'tool_call', toolCallId: params.callId, - status: 'in_progress', + status: params.status || 'pending', title, content: [], locations, @@ -275,7 +275,18 @@ export class ToolCallEmitter extends BaseEmitter { // Handle functionResponse parts - stringify the response if ('functionResponse' in part && part.functionResponse) { try { - const responseText = JSON.stringify(part.functionResponse.response); + const resp = part.functionResponse.response as Record< + string, + unknown + >; + const outputField = resp['output']; + const errorField = resp['error']; + const responseText = + typeof outputField === 'string' + ? outputField + : typeof errorField === 'string' + ? errorField + : JSON.stringify(resp); result.push({ type: 'content', content: { type: 'text', text: responseText }, diff --git a/packages/cli/src/acp-integration/session/types.ts b/packages/cli/src/acp-integration/session/types.ts index 0c8f60a0a..7812fb036 100644 --- a/packages/cli/src/acp-integration/session/types.ts +++ b/packages/cli/src/acp-integration/session/types.ts @@ -35,6 +35,8 @@ export interface ToolCallStartParams { callId: string; /** Arguments passed to the tool */ args?: Record; + /** Status of the tool call */ + status?: 'pending' | 'in_progress' | 'completed' | 'failed'; } /** diff --git a/packages/cli/src/config/auth.ts b/packages/cli/src/config/auth.ts index 83761e3ec..9f5d50a07 100644 --- a/packages/cli/src/config/auth.ts +++ b/packages/cli/src/config/auth.ts @@ -26,5 +26,37 @@ export function validateAuthMethod(authMethod: string): string | null { return null; } + if (authMethod === AuthType.USE_ANTHROPIC) { + const hasApiKey = process.env['ANTHROPIC_API_KEY']; + if (!hasApiKey) { + return 'ANTHROPIC_API_KEY environment variable not found.'; + } + + const hasBaseUrl = process.env['ANTHROPIC_BASE_URL']; + if (!hasBaseUrl) { + return 'ANTHROPIC_BASE_URL environment variable not found.'; + } + + return null; + } + + if (authMethod === AuthType.USE_GEMINI) { + const hasApiKey = process.env['GEMINI_API_KEY']; + if (!hasApiKey) { + return 'GEMINI_API_KEY environment variable not found. Please set it in your .env file or environment variables.'; + } + return null; + } + + if (authMethod === AuthType.USE_VERTEX_AI) { + const hasApiKey = process.env['GOOGLE_API_KEY']; + if (!hasApiKey) { + return 'GOOGLE_API_KEY environment variable not found. Please set it in your .env file or environment variables.'; + } + + process.env['GOOGLE_GENAI_USE_VERTEXAI'] = 'true'; + return null; + } + return 'Invalid auth method selected.'; } diff --git a/packages/cli/src/config/config.test.ts b/packages/cli/src/config/config.test.ts index 8cb882906..0b95f7857 100644 --- a/packages/cli/src/config/config.test.ts +++ b/packages/cli/src/config/config.test.ts @@ -206,6 +206,18 @@ describe('parseArguments', () => { expect(argv.prompt).toBeUndefined(); }); + it('should allow -r flag as alias for --resume', async () => { + process.argv = ['node', 'script.js', '-r', 'session-123']; + const argv = await parseArguments({} as Settings); + expect(argv.resume).toBe('session-123'); + }); + + it('should allow -c flag as alias for --continue', async () => { + process.argv = ['node', 'script.js', '-c']; + const argv = await parseArguments({} as Settings); + expect(argv.continue).toBe(true); + }); + it('should convert positional query argument to prompt by default', async () => { process.argv = ['node', 'script.js', 'Hi Gemini']; const argv = await parseArguments({} as Settings); @@ -2102,7 +2114,14 @@ describe('loadCliConfig model selection', () => { }); it('always prefers model from argvs', async () => { - process.argv = ['node', 'script.js', '--model', 'qwen3-coder-plus']; + process.argv = [ + 'node', + 'script.js', + '--auth-type', + 'openai', + '--model', + 'qwen3-coder-plus', + ]; const argv = await parseArguments({} as Settings); const config = await loadCliConfig( { @@ -2122,7 +2141,14 @@ describe('loadCliConfig model selection', () => { }); it('selects the model from argvs if provided', async () => { - process.argv = ['node', 'script.js', '--model', 'qwen3-coder-plus']; + process.argv = [ + 'node', + 'script.js', + '--auth-type', + 'openai', + '--model', + 'qwen3-coder-plus', + ]; const argv = await parseArguments({} as Settings); const config = await loadCliConfig( { diff --git a/packages/cli/src/config/config.ts b/packages/cli/src/config/config.ts index 3212996d2..7cd7d685a 100755 --- a/packages/cli/src/config/config.ts +++ b/packages/cli/src/config/config.ts @@ -112,6 +112,7 @@ export interface CliArgs { allowedMcpServerNames: string[] | undefined; allowedTools: string[] | undefined; experimentalAcp: boolean | undefined; + experimentalSkills: boolean | undefined; extensions: string[] | undefined; listExtensions: boolean | undefined; openaiLogging: boolean | undefined; @@ -130,6 +131,11 @@ export interface CliArgs { inputFormat?: string | undefined; outputFormat: string | undefined; includePartialMessages?: boolean; + /** + * If chat recording is disabled, the chat history would not be recorded, + * so --continue and --resume would not take effect. + */ + chatRecording: boolean | undefined; /** Resume the most recent session for the current project */ continue: boolean | undefined; /** Resume a specific session by its ID */ @@ -138,6 +144,7 @@ export interface CliArgs { coreTools: string[] | undefined; excludeTools: string[] | undefined; authType: string | undefined; + channel: string | undefined; } function normalizeOutputFormat( @@ -232,6 +239,11 @@ export async function parseArguments(settings: Settings): Promise { 'proxy', 'Use the "proxy" setting in settings.json instead. This flag will be removed in a future version.', ) + .option('chat-recording', { + type: 'boolean', + description: + 'Enable chat recording to disk. If false, chat history is not saved and --continue/--resume will not work.', + }) .command('$0 [query..]', 'Launch Qwen Code CLI', (yargsInstance: Argv) => yargsInstance .positional('query', { @@ -288,7 +300,6 @@ export async function parseArguments(settings: Settings): Promise { 'Set the approval mode: plan (plan only), default (prompt for approval), auto-edit (auto-approve edit tools), yolo (auto-approve all tools)', }) .option('checkpointing', { - alias: 'c', type: 'boolean', description: 'Enables checkpointing of file edits', default: false, @@ -297,6 +308,16 @@ export async function parseArguments(settings: Settings): Promise { type: 'boolean', description: 'Starts the agent in ACP mode', }) + .option('experimental-skills', { + type: 'boolean', + description: 'Enable experimental Skills feature', + default: false, + }) + .option('channel', { + type: 'string', + choices: ['VSCode', 'ACP', 'SDK', 'CI'], + description: 'Channel identifier (VSCode, ACP, SDK, CI)', + }) .option('allowed-mcp-server-names', { type: 'array', string: true, @@ -406,12 +427,14 @@ export async function parseArguments(settings: Settings): Promise { default: false, }) .option('continue', { + alias: 'c', type: 'boolean', description: 'Resume the most recent session for the current project.', default: false, }) .option('resume', { + alias: 'r', type: 'string', description: 'Resume a specific session by its ID. Use without an ID to show session picker.', @@ -443,7 +466,13 @@ export async function parseArguments(settings: Settings): Promise { }) .option('auth-type', { type: 'string', - choices: [AuthType.USE_OPENAI, AuthType.QWEN_OAUTH], + choices: [ + AuthType.USE_OPENAI, + AuthType.USE_ANTHROPIC, + AuthType.QWEN_OAUTH, + AuthType.USE_GEMINI, + AuthType.USE_VERTEX_AI, + ], description: 'Authentication type', }) .deprecateOption( @@ -559,6 +588,12 @@ export async function parseArguments(settings: Settings): Promise { // The import format is now only controlled by settings.memoryImportFormat // We no longer accept it as a CLI argument + + // Apply ACP fallback: if experimental-acp is present but no explicit --channel, treat as ACP + if (result['experimentalAcp'] && !result['channel']) { + (result as Record)['channel'] = 'ACP'; + } + return result as unknown as CliArgs; } @@ -842,11 +877,30 @@ export async function loadCliConfig( ); } + const selectedAuthType = + (argv.authType as AuthType | undefined) || + settings.security?.auth?.selectedType; + + const apiKey = + (selectedAuthType === AuthType.USE_OPENAI + ? argv.openaiApiKey || + process.env['OPENAI_API_KEY'] || + settings.security?.auth?.apiKey + : '') || ''; + const baseUrl = + (selectedAuthType === AuthType.USE_OPENAI + ? argv.openaiBaseUrl || + process.env['OPENAI_BASE_URL'] || + settings.security?.auth?.baseUrl + : '') || ''; const resolvedModel = argv.model || - process.env['OPENAI_MODEL'] || - process.env['QWEN_MODEL'] || - settings.model?.name; + (selectedAuthType === AuthType.USE_OPENAI + ? process.env['OPENAI_MODEL'] || + process.env['QWEN_MODEL'] || + settings.model?.name + : '') || + ''; const sandboxConfig = await loadSandboxConfig(settings, argv); const screenReader = @@ -928,27 +982,20 @@ export async function loadCliConfig( maxSessionTurns: argv.maxSessionTurns ?? settings.model?.maxSessionTurns ?? -1, experimentalZedIntegration: argv.experimentalAcp || false, + experimentalSkills: argv.experimentalSkills || false, listExtensions: argv.listExtensions || false, extensions: allExtensions, blockedMcpServers, noBrowser: !!process.env['NO_BROWSER'], - authType: - (argv.authType as AuthType | undefined) || - settings.security?.auth?.selectedType, + authType: selectedAuthType, inputFormat, outputFormat, includePartialMessages, generationConfig: { ...(settings.model?.generationConfig || {}), model: resolvedModel, - apiKey: - argv.openaiApiKey || - process.env['OPENAI_API_KEY'] || - settings.security?.auth?.apiKey, - baseUrl: - argv.openaiBaseUrl || - process.env['OPENAI_BASE_URL'] || - settings.security?.auth?.baseUrl, + apiKey, + baseUrl, enableOpenAILogging: (typeof argv.openaiLogging === 'undefined' ? settings.model?.enableOpenAILogging @@ -980,9 +1027,16 @@ export async function loadCliConfig( enableToolOutputTruncation: settings.tools?.enableToolOutputTruncation, eventEmitter: appEvents, useSmartEdit: argv.useSmartEdit ?? settings.useSmartEdit, + gitCoAuthor: settings.general?.gitCoAuthor, output: { format: outputSettingsFormat, }, + channel: argv.channel, + // Precedence: explicit CLI flag > settings file > default(true). + // NOTE: do NOT set a yargs default for `chat-recording`, otherwise argv will + // always be true and the settings file can never disable recording. + chatRecording: + argv.chatRecording ?? settings.general?.chatRecording ?? true, }); } diff --git a/packages/cli/src/config/extension.test.ts b/packages/cli/src/config/extension.test.ts index 6f50c301d..2bae9c1ec 100644 --- a/packages/cli/src/config/extension.test.ts +++ b/packages/cli/src/config/extension.test.ts @@ -56,6 +56,17 @@ vi.mock('simple-git', () => ({ }), })); +vi.mock('./extensions/github.js', async (importOriginal) => { + const actual = + await importOriginal(); + return { + ...actual, + downloadFromGitHubRelease: vi + .fn() + .mockRejectedValue(new Error('Mocked GitHub release download failure')), + }; +}); + vi.mock('os', async (importOriginal) => { const mockedOs = await importOriginal(); return { diff --git a/packages/cli/src/config/extension.ts b/packages/cli/src/config/extension.ts index c9fde1286..2d30cc41f 100644 --- a/packages/cli/src/config/extension.ts +++ b/packages/cli/src/config/extension.ts @@ -581,7 +581,7 @@ function extensionConsentString(extensionConfig: ExtensionConfig): string { } if (extensionConfig.contextFileName) { output.push( - `This extension will append info to your gemini.md context using ${extensionConfig.contextFileName}`, + `This extension will append info to your QWEN.md context using ${extensionConfig.contextFileName}`, ); } if (extensionConfig.excludeTools) { diff --git a/packages/cli/src/config/extensions/update.test.ts b/packages/cli/src/config/extensions/update.test.ts index 3e916ec91..849857e4d 100644 --- a/packages/cli/src/config/extensions/update.test.ts +++ b/packages/cli/src/config/extensions/update.test.ts @@ -41,6 +41,17 @@ vi.mock('simple-git', () => ({ }), })); +vi.mock('../extensions/github.js', async (importOriginal) => { + const actual = + await importOriginal(); + return { + ...actual, + downloadFromGitHubRelease: vi + .fn() + .mockRejectedValue(new Error('Mocked GitHub release download failure')), + }; +}); + vi.mock('os', async (importOriginal) => { const mockedOs = await importOriginal(); return { diff --git a/packages/cli/src/config/settingsSchema.ts b/packages/cli/src/config/settingsSchema.ts index d95f4dbbd..2fe467ba9 100644 --- a/packages/cli/src/config/settingsSchema.ts +++ b/packages/cli/src/config/settingsSchema.ts @@ -147,6 +147,16 @@ const SETTINGS_SCHEMA = { description: 'Disable update notification prompts.', showInDialog: false, }, + gitCoAuthor: { + type: 'boolean', + label: 'Git Co-Author', + category: 'General', + requiresRestart: false, + default: true, + description: + 'Automatically add a Co-authored-by trailer to git commit messages when commits are made through Qwen Code.', + showInDialog: false, + }, checkpointing: { type: 'object', label: 'Checkpointing', @@ -191,8 +201,29 @@ const SETTINGS_SCHEMA = { { value: 'auto', label: 'Auto (detect from system)' }, { value: 'en', label: 'English' }, { value: 'zh', label: '中文 (Chinese)' }, + { value: 'ru', label: 'Русский (Russian)' }, ], }, + terminalBell: { + type: 'boolean', + label: 'Terminal Bell', + category: 'General', + requiresRestart: false, + default: true, + description: + 'Play terminal bell sound when response completes or needs approval.', + showInDialog: true, + }, + chatRecording: { + type: 'boolean', + label: 'Chat Recording', + category: 'General', + requiresRestart: true, + default: true, + description: + 'Enable saving chat history to disk. Disabling this will also prevent --continue and --resume from working.', + showInDialog: false, + }, }, }, output: { @@ -263,7 +294,7 @@ const SETTINGS_SCHEMA = { requiresRestart: false, default: false, description: - 'Show Gemini CLI status and thoughts in the terminal window title', + 'Show Qwen Code status and thoughts in the terminal window title', showInDialog: true, }, hideTips: { @@ -291,7 +322,7 @@ const SETTINGS_SCHEMA = { requiresRestart: false, default: false, description: - 'Hide the context summary (GEMINI.md, MCP servers) above the input.', + 'Hide the context summary (QWEN.md, MCP servers) above the input.', showInDialog: true, }, footer: { @@ -497,7 +528,7 @@ const SETTINGS_SCHEMA = { category: 'Model', requiresRestart: false, default: undefined as string | undefined, - description: 'The Gemini model to use for conversations.', + description: 'The model to use for conversations.', showInDialog: false, }, maxSessionTurns: { @@ -628,6 +659,22 @@ const SETTINGS_SCHEMA = { childKey: 'disableCacheControl', showInDialog: true, }, + schemaCompliance: { + type: 'enum', + label: 'Tool Schema Compliance', + category: 'Generation Configuration', + requiresRestart: false, + default: 'auto', + description: + 'The compliance mode for tool schemas sent to the model. Use "openapi_30" for strict OpenAPI 3.0 compatibility (e.g., for Gemini).', + parentKey: 'generationConfig', + childKey: 'schemaCompliance', + showInDialog: true, + options: [ + { value: 'auto', label: 'Auto (Default)' }, + { value: 'openapi_30', label: 'OpenAPI 3.0 Strict' }, + ], + }, }, }, }, diff --git a/packages/cli/src/gemini.test.tsx b/packages/cli/src/gemini.test.tsx index f602d17d9..9fa0b8261 100644 --- a/packages/cli/src/gemini.test.tsx +++ b/packages/cli/src/gemini.test.tsx @@ -379,8 +379,8 @@ describe('gemini.tsx main function kitty protocol', () => { beforeEach(() => { // Set no relaunch in tests since process spawning causing issues in tests - originalEnvNoRelaunch = process.env['GEMINI_CLI_NO_RELAUNCH']; - process.env['GEMINI_CLI_NO_RELAUNCH'] = 'true'; + originalEnvNoRelaunch = process.env['QWEN_CODE_NO_RELAUNCH']; + process.env['QWEN_CODE_NO_RELAUNCH'] = 'true'; // eslint-disable-next-line @typescript-eslint/no-explicit-any if (!(process.stdin as any).setRawMode) { @@ -402,9 +402,9 @@ describe('gemini.tsx main function kitty protocol', () => { afterEach(() => { // Restore original env variables if (originalEnvNoRelaunch !== undefined) { - process.env['GEMINI_CLI_NO_RELAUNCH'] = originalEnvNoRelaunch; + process.env['QWEN_CODE_NO_RELAUNCH'] = originalEnvNoRelaunch; } else { - delete process.env['GEMINI_CLI_NO_RELAUNCH']; + delete process.env['QWEN_CODE_NO_RELAUNCH']; } }); @@ -461,6 +461,7 @@ describe('gemini.tsx main function kitty protocol', () => { allowedMcpServerNames: undefined, allowedTools: undefined, experimentalAcp: undefined, + experimentalSkills: undefined, extensions: undefined, listExtensions: undefined, openaiLogging: undefined, @@ -485,6 +486,8 @@ describe('gemini.tsx main function kitty protocol', () => { excludeTools: undefined, authType: undefined, maxSessionTurns: undefined, + channel: undefined, + chatRecording: undefined, }); await main(); diff --git a/packages/cli/src/gemini.tsx b/packages/cli/src/gemini.tsx index 18f191bc1..b05f12453 100644 --- a/packages/cli/src/gemini.tsx +++ b/packages/cli/src/gemini.tsx @@ -4,13 +4,8 @@ * SPDX-License-Identifier: Apache-2.0 */ -import type { Config } from '@qwen-code/qwen-code-core'; -import { - AuthType, - getOauthClient, - InputFormat, - logUserPrompt, -} from '@qwen-code/qwen-code-core'; +import type { Config, AuthType } from '@qwen-code/qwen-code-core'; +import { InputFormat, logUserPrompt } from '@qwen-code/qwen-code-core'; import { render } from 'ink'; import dns from 'node:dns'; import os from 'node:os'; @@ -58,7 +53,7 @@ import { getUserStartupWarnings } from './utils/userStartupWarnings.js'; import { getCliVersion } from './utils/version.js'; import { computeWindowTitle } from './utils/windowTitle.js'; import { validateNonInteractiveAuth } from './validateNonInterActiveAuth.js'; -import { showResumeSessionPicker } from './ui/components/ResumeSessionPicker.js'; +import { showResumeSessionPicker } from './ui/components/StandaloneSessionPicker.js'; export function validateDnsResolutionOrder( order: string | undefined, @@ -92,7 +87,7 @@ function getNodeMemoryArgs(isDebugMode: boolean): string[] { ); } - if (process.env['GEMINI_CLI_NO_RELAUNCH']) { + if (process.env['QWEN_CODE_NO_RELAUNCH']) { return []; } @@ -399,15 +394,6 @@ export async function main() { initializationResult = await initializeApp(config, settings); } - if ( - settings.merged.security?.auth?.selectedType === - AuthType.LOGIN_WITH_GOOGLE && - config.isBrowserLaunchSuppressed() - ) { - // Do oauth before app renders to make copying the link possible. - await getOauthClient(settings.merged.security.auth.selectedType, config); - } - if (config.getExperimentalZedIntegration()) { return runAcpAgent(config, settings, extensions, argv); } diff --git a/packages/cli/src/i18n/index.ts b/packages/cli/src/i18n/index.ts index 2cad8decf..7436336b3 100644 --- a/packages/cli/src/i18n/index.ts +++ b/packages/cli/src/i18n/index.ts @@ -9,7 +9,7 @@ import * as path from 'node:path'; import { fileURLToPath, pathToFileURL } from 'node:url'; import { homedir } from 'node:os'; -export type SupportedLanguage = 'en' | 'zh' | string; // Allow custom language codes +export type SupportedLanguage = 'en' | 'zh' | 'ru' | string; // Allow custom language codes // State let currentLanguage: SupportedLanguage = 'en'; @@ -51,10 +51,12 @@ export function detectSystemLanguage(): SupportedLanguage { const envLang = process.env['QWEN_CODE_LANG'] || process.env['LANG']; if (envLang?.startsWith('zh')) return 'zh'; if (envLang?.startsWith('en')) return 'en'; + if (envLang?.startsWith('ru')) return 'ru'; try { const locale = Intl.DateTimeFormat().resolvedOptions().locale; if (locale.startsWith('zh')) return 'zh'; + if (locale.startsWith('ru')) return 'ru'; } catch { // Fallback to default } diff --git a/packages/cli/src/i18n/locales/en.js b/packages/cli/src/i18n/locales/en.js index c2217757c..2682c8fcb 100644 --- a/packages/cli/src/i18n/locales/en.js +++ b/packages/cli/src/i18n/locales/en.js @@ -310,6 +310,7 @@ export default { 'Tool Output Truncation Lines': 'Tool Output Truncation Lines', 'Folder Trust': 'Folder Trust', 'Vision Model Preview': 'Vision Model Preview', + 'Tool Schema Compliance': 'Tool Schema Compliance', // Settings enum options 'Auto (detect from system)': 'Auto (detect from system)', Text: 'Text', @@ -635,8 +636,8 @@ export default { 'The /directory add command is not supported in restrictive sandbox profiles. Please use --include-directories when starting the session instead.': 'The /directory add command is not supported in restrictive sandbox profiles. Please use --include-directories when starting the session instead.', "Error adding '{{path}}': {{error}}": "Error adding '{{path}}': {{error}}", - 'Successfully added GEMINI.md files from the following directories if there are:\n- {{directories}}': - 'Successfully added GEMINI.md files from the following directories if there are:\n- {{directories}}', + 'Successfully added QWEN.md files from the following directories if there are:\n- {{directories}}': + 'Successfully added QWEN.md files from the following directories if there are:\n- {{directories}}', 'Error refreshing memory: {{error}}': 'Error refreshing memory: {{error}}', 'Successfully added directories:\n- {{directories}}': 'Successfully added directories:\n- {{directories}}', @@ -867,6 +868,7 @@ export default { // Exit Screen / Stats // ============================================================================ 'Agent powering down. Goodbye!': 'Agent powering down. Goodbye!', + 'To continue this session, run': 'To continue this session, run', 'Interaction Summary': 'Interaction Summary', 'Session ID:': 'Session ID:', 'Tool Calls:': 'Tool Calls:', diff --git a/packages/cli/src/i18n/locales/ru.js b/packages/cli/src/i18n/locales/ru.js new file mode 100644 index 000000000..009578be6 --- /dev/null +++ b/packages/cli/src/i18n/locales/ru.js @@ -0,0 +1,1121 @@ +/** + * @license + * Copyright 2025 Qwen + * SPDX-License-Identifier: Apache-2.0 + */ + +// Русский перевод для Qwen Code CLI +// Ключ служит одновременно ключом перевода и текстом по умолчанию + +export default { + // ============================================================================ + // Справка / Компоненты интерфейса + // ============================================================================ + 'Basics:': 'Основы:', + 'Add context': 'Добавить контекст', + 'Use {{symbol}} to specify files for context (e.g., {{example}}) to target specific files or folders.': + 'Используйте {{symbol}} для добавления файлов в контекст (например, {{example}}) для выбора конкретных файлов или папок).', + '@': '@', + '@src/myFile.ts': '@src/myFile.ts', + 'Shell mode': 'Режим терминала', + 'YOLO mode': 'Режим YOLO', + 'plan mode': 'Режим планирования', + 'auto-accept edits': 'Режим принятия правок', + 'Accepting edits': 'Принятие правок', + '(shift + tab to cycle)': '(shift + tab для переключения)', + 'Execute shell commands via {{symbol}} (e.g., {{example1}}) or use natural language (e.g., {{example2}}).': + 'Выполняйте команды терминала через {{symbol}} (например, {{example1}}) или используйте естественный язык (например, {{example2}}).', + '!': '!', + '!npm run start': '!npm run start', + 'start server': 'start server', + 'Commands:': 'Команды:', + 'shell command': 'команда терминала', + 'Model Context Protocol command (from external servers)': + 'Команда Model Context Protocol (из внешних серверов)', + 'Keyboard Shortcuts:': 'Горячие клавиши:', + 'Jump through words in the input': 'Переход по словам во вводе', + 'Close dialogs, cancel requests, or quit application': + 'Закрыть диалоги, отменить запросы или выйти из приложения', + 'New line': 'Новая строка', + 'New line (Alt+Enter works for certain linux distros)': + 'Новая строка (Alt+Enter работает только в некоторых дистрибутивах Linux)', + 'Clear the screen': 'Очистить экран', + 'Open input in external editor': 'Открыть ввод во внешнем редакторе', + 'Send message': 'Отправить сообщение', + 'Initializing...': 'Инициализация...', + 'Connecting to MCP servers... ({{connected}}/{{total}})': + 'Подключение к MCP-серверам... ({{connected}}/{{total}})', + 'Type your message or @path/to/file': 'Введите сообщение или @путь/к/файлу', + "Press 'i' for INSERT mode and 'Esc' for NORMAL mode.": + "Нажмите 'i' для режима ВСТАВКА и 'Esc' для ОБЫЧНОГО режима.", + 'Cancel operation / Clear input (double press)': + 'Отменить операцию / Очистить ввод (двойное нажатие)', + 'Cycle approval modes': 'Переключение режимов подтверждения', + 'Cycle through your prompt history': 'Пролистать историю запросов', + 'For a full list of shortcuts, see {{docPath}}': + 'Полный список горячих клавиш см. в {{docPath}}', + 'docs/keyboard-shortcuts.md': 'docs/keyboard-shortcuts.md', + 'for help on Qwen Code': 'Справка по Qwen Code', + 'show version info': 'Просмотр информации о версии', + 'submit a bug report': 'Отправка отчёта об ошибке', + 'About Qwen Code': 'Об Qwen Code', + + // ============================================================================ + // Поля системной информации + // ============================================================================ + 'CLI Version': 'Версия CLI', + 'Git Commit': 'Git-коммит', + Model: 'Модель', + Sandbox: 'Песочница', + 'OS Platform': 'Платформа ОС', + 'OS Arch': 'Архитектура ОС', + 'OS Release': 'Версия ОС', + 'Node.js Version': 'Версия Node.js', + 'NPM Version': 'Версия NPM', + 'Session ID': 'ID сессии', + 'Auth Method': 'Метод авторизации', + 'Base URL': 'Базовый URL', + 'Memory Usage': 'Использование памяти', + 'IDE Client': 'Клиент IDE', + + // ============================================================================ + // Команды - Общие + // ============================================================================ + 'Analyzes the project and creates a tailored QWEN.md file.': + 'Анализ проекта и создание адаптированного файла QWEN.md', + 'list available Qwen Code tools. Usage: /tools [desc]': + 'Просмотр доступных инструментов Qwen Code. Использование: /tools [desc]', + 'Available Qwen Code CLI tools:': 'Доступные инструменты Qwen Code CLI:', + 'No tools available': 'Нет доступных инструментов', + 'View or change the approval mode for tool usage': + 'Просмотр или изменение режима подтверждения для использования инструментов', + 'View or change the language setting': + 'Просмотр или изменение настроек языка', + 'change the theme': 'Изменение темы', + 'Select Theme': 'Выбор темы', + Preview: 'Предпросмотр', + '(Use Enter to select, Tab to configure scope)': + '(Enter для выбора, Tab для настройки области)', + '(Use Enter to apply scope, Tab to select theme)': + '(Enter для применения области, Tab для выбора темы)', + 'Theme configuration unavailable due to NO_COLOR env variable.': + 'Настройка темы недоступна из-за переменной окружения NO_COLOR.', + 'Theme "{{themeName}}" not found.': 'Тема "{{themeName}}" не найдена.', + 'Theme "{{themeName}}" not found in selected scope.': + 'Тема "{{themeName}}" не найдена в выбранной области.', + 'clear the screen and conversation history': + 'Очистка экрана и истории диалога', + 'Compresses the context by replacing it with a summary.': + 'Сжатие контекста заменой на краткую сводку', + 'open full Qwen Code documentation in your browser': + 'Открытие полной документации Qwen Code в браузере', + 'Configuration not available.': 'Конфигурация недоступна.', + 'change the auth method': 'Изменение метода авторизации', + 'Copy the last result or code snippet to clipboard': + 'Копирование последнего результата или фрагмента кода в буфер обмена', + + // ============================================================================ + // Команды - Агенты + // ============================================================================ + 'Manage subagents for specialized task delegation.': + 'Управление подагентами для делегирования специализированных задач', + 'Manage existing subagents (view, edit, delete).': + 'Управление существующими подагентами (просмотр, правка, удаление)', + 'Create a new subagent with guided setup.': + 'Создание нового подагента с пошаговой настройкой', + + // ============================================================================ + // Агенты - Диалог управления + // ============================================================================ + Agents: 'Агенты', + 'Choose Action': 'Выберите действие', + 'Edit {{name}}': 'Редактировать {{name}}', + 'Edit Tools: {{name}}': 'Редактировать инструменты: {{name}}', + 'Edit Color: {{name}}': 'Редактировать цвет: {{name}}', + 'Delete {{name}}': 'Удалить {{name}}', + 'Unknown Step': 'Неизвестный шаг', + 'Esc to close': 'Esc для закрытия', + 'Enter to select, ↑↓ to navigate, Esc to close': + 'Enter для выбора, ↑↓ для навигации, Esc для закрытия', + 'Esc to go back': 'Esc для возврата', + 'Enter to confirm, Esc to cancel': 'Enter для подтверждения, Esc для отмены', + 'Enter to select, ↑↓ to navigate, Esc to go back': + 'Enter для выбора, ↑↓ для навигации, Esc для возврата', + 'Invalid step: {{step}}': 'Неверный шаг: {{step}}', + 'No subagents found.': 'Подагенты не найдены.', + "Use '/agents create' to create your first subagent.": + "Используйте '/agents create' для создания первого подагента.", + '(built-in)': '(встроенный)', + '(overridden by project level agent)': + '(переопределен агентом уровня проекта)', + 'Project Level ({{path}})': 'Уровень проекта ({{path}})', + 'User Level ({{path}})': 'Уровень пользователя ({{path}})', + 'Built-in Agents': 'Встроенные агенты', + 'Using: {{count}} agents': 'Используется: {{count}} агент(ов)', + 'View Agent': 'Просмотреть агента', + 'Edit Agent': 'Редактировать агента', + 'Delete Agent': 'Удалить агента', + Back: 'Назад', + 'No agent selected': 'Агент не выбран', + 'File Path: ': 'Путь к файлу: ', + 'Tools: ': 'Инструменты: ', + 'Color: ': 'Цвет: ', + 'Description:': 'Описание:', + 'System Prompt:': 'Системный промпт:', + 'Open in editor': 'Открыть в редакторе', + 'Edit tools': 'Редактировать инструменты', + 'Edit color': 'Редактировать цвет', + '❌ Error:': '❌ Ошибка:', + 'Are you sure you want to delete agent "{{name}}"?': + 'Вы уверены, что хотите удалить агента "{{name}}"?', + // ============================================================================ + // Агенты - Мастер создания + // ============================================================================ + 'Project Level (.qwen/agents/)': 'Уровень проекта (.qwen/agents/)', + 'User Level (~/.qwen/agents/)': 'Уровень пользователя (~/.qwen/agents/)', + '✅ Subagent Created Successfully!': '✅ Подагент успешно создан!', + 'Subagent "{{name}}" has been saved to {{level}} level.': + 'Подагент "{{name}}" сохранен на уровне {{level}}.', + 'Name: ': 'Имя: ', + 'Location: ': 'Расположение: ', + '❌ Error saving subagent:': '❌ Ошибка сохранения подагента:', + 'Warnings:': 'Предупреждения:', + 'Name "{{name}}" already exists at {{level}} level - will overwrite existing subagent': + 'Имя "{{name}}" уже существует на уровне {{level}} - существующий подагент будет перезаписан', + 'Name "{{name}}" exists at user level - project level will take precedence': + 'Имя "{{name}}" существует на уровне пользователя - уровень проекта будет иметь приоритет', + 'Name "{{name}}" exists at project level - existing subagent will take precedence': + 'Имя "{{name}}" существует на уровне проекта - существующий подагент будет иметь приоритет', + 'Description is over {{length}} characters': + 'Описание превышает {{length}} символов', + 'System prompt is over {{length}} characters': + 'Системный промпт превышает {{length}} символов', + // Агенты - Шаги мастера создания + 'Step {{n}}: Choose Location': 'Шаг {{n}}: Выберите расположение', + 'Step {{n}}: Choose Generation Method': 'Шаг {{n}}: Выберите метод генерации', + 'Generate with Qwen Code (Recommended)': + 'Сгенерировать с помощью Qwen Code (Рекомендуется)', + 'Manual Creation': 'Ручное создание', + 'Describe what this subagent should do and when it should be used. (Be comprehensive for best results)': + 'Опишите, что должен делать этот подагент и когда его следует использовать. (Будьте подробны для лучших результатов)', + 'e.g., Expert code reviewer that reviews code based on best practices...': + 'например, Экспертный ревьювер кода, проверяющий код на соответствие лучшим практикам...', + 'Generating subagent configuration...': 'Генерация конфигурации подагента...', + 'Failed to generate subagent: {{error}}': + 'Не удалось сгенерировать подагента: {{error}}', + 'Step {{n}}: Describe Your Subagent': 'Шаг {{n}}: Опишите подагента', + 'Step {{n}}: Enter Subagent Name': 'Шаг {{n}}: Введите имя подагента', + 'Step {{n}}: Enter System Prompt': 'Шаг {{n}}: Введите системный промпт', + 'Step {{n}}: Enter Description': 'Шаг {{n}}: Введите описание', + // Агенты - Выбор инструментов + 'Step {{n}}: Select Tools': 'Шаг {{n}}: Выберите инструменты', + 'All Tools (Default)': 'Все инструменты (по умолчанию)', + 'All Tools': 'Все инструменты', + 'Read-only Tools': 'Инструменты только для чтения', + 'Read & Edit Tools': 'Инструменты для чтения и редактирования', + 'Read & Edit & Execution Tools': + 'Инструменты для чтения, редактирования и выполнения', + 'All tools selected, including MCP tools': + 'Все инструменты выбраны, включая инструменты MCP', + 'Selected tools:': 'Выбранные инструменты:', + 'Read-only tools:': 'Инструменты только для чтения:', + 'Edit tools:': 'Инструменты редактирования:', + 'Execution tools:': 'Инструменты выполнения:', + 'Step {{n}}: Choose Background Color': 'Шаг {{n}}: Выберите цвет фона', + 'Step {{n}}: Confirm and Save': 'Шаг {{n}}: Подтвердите и сохраните', + // Агенты - Навигация и инструкции + 'Esc to cancel': 'Esc для отмены', + 'Press Enter to save, e to save and edit, Esc to go back': + 'Enter для сохранения, e для сохранения и редактирования, Esc для возврата', + 'Press Enter to continue, {{navigation}}Esc to {{action}}': + 'Enter для продолжения, {{navigation}}Esc для {{action}}', + cancel: 'отмены', + 'go back': 'возврата', + '↑↓ to navigate, ': '↑↓ для навигации, ', + 'Enter a clear, unique name for this subagent.': + 'Введите четкое, уникальное имя для этого подагента.', + 'e.g., Code Reviewer': 'например, Ревьювер кода', + 'Name cannot be empty.': 'Имя не может быть пустым.', + "Write the system prompt that defines this subagent's behavior. Be comprehensive for best results.": + 'Напишите системный промпт, определяющий поведение подагента. Будьте подробны для лучших результатов.', + 'e.g., You are an expert code reviewer...': + 'например, Вы экспертный ревьювер кода...', + 'System prompt cannot be empty.': 'Системный промпт не может быть пустым.', + 'Describe when and how this subagent should be used.': + 'Опишите, когда и как следует использовать этого подагента.', + 'e.g., Reviews code for best practices and potential bugs.': + 'например, Проверяет код на соответствие лучшим практикам и потенциальные ошибки.', + 'Description cannot be empty.': 'Описание не может быть пустым.', + 'Failed to launch editor: {{error}}': + 'Не удалось запустить редактор: {{error}}', + 'Failed to save and edit subagent: {{error}}': + 'Не удалось сохранить и отредактировать подагента: {{error}}', + + // ============================================================================ + // Команды - Общие (продолжение) + // ============================================================================ + 'View and edit Qwen Code settings': 'Просмотр и изменение настроек Qwen Code', + Settings: 'Настройки', + '(Use Enter to select{{tabText}})': '(Enter для выбора{{tabText}})', + ', Tab to change focus': ', Tab для смены фокуса', + 'To see changes, Qwen Code must be restarted. Press r to exit and apply changes now.': + 'Для применения изменений необходимо перезапустить Qwen Code. Нажмите r для выхода и применения изменений.', + + // ============================================================================ + // Метки настроек + // ============================================================================ + 'Vim Mode': 'Режим Vim', + 'Disable Auto Update': 'Отключить автообновление', + 'Enable Prompt Completion': 'Включить автодополнение промптов', + 'Debug Keystroke Logging': 'Логирование нажатий клавиш для отладки', + Language: 'Язык', + 'Output Format': 'Формат вывода', + 'Hide Window Title': 'Скрыть заголовок окна', + 'Show Status in Title': 'Показывать статус в заголовке', + 'Hide Tips': 'Скрыть подсказки', + 'Hide Banner': 'Скрыть баннер', + 'Hide Context Summary': 'Скрыть сводку контекста', + 'Hide CWD': 'Скрыть текущую директорию', + 'Hide Sandbox Status': 'Скрыть статус песочницы', + 'Hide Model Info': 'Скрыть информацию о модели', + 'Hide Footer': 'Скрыть нижний колонтитул', + 'Show Memory Usage': 'Показывать использование памяти', + 'Show Line Numbers': 'Показывать номера строк', + 'Show Citations': 'Показывать цитаты', + 'Custom Witty Phrases': 'Пользовательские остроумные фразы', + 'Enable Welcome Back': 'Включить приветствие при возврате', + 'Disable Loading Phrases': 'Отключить фразы при загрузке', + 'Screen Reader Mode': 'Режим программы чтения с экрана', + 'IDE Mode': 'Режим IDE', + 'Max Session Turns': 'Макс. количество ходов сессии', + 'Skip Next Speaker Check': 'Пропустить проверку следующего говорящего', + 'Skip Loop Detection': 'Пропустить обнаружение циклов', + 'Skip Startup Context': 'Пропустить начальный контекст', + 'Enable OpenAI Logging': 'Включить логирование OpenAI', + 'OpenAI Logging Directory': 'Директория логов OpenAI', + Timeout: 'Таймаут', + 'Max Retries': 'Макс. количество попыток', + 'Disable Cache Control': 'Отключить управление кэшем', + 'Memory Discovery Max Dirs': 'Макс. директорий для поиска в памяти', + 'Load Memory From Include Directories': + 'Загружать память из включенных директорий', + 'Respect .gitignore': 'Учитывать .gitignore', + 'Respect .qwenignore': 'Учитывать .qwenignore', + 'Enable Recursive File Search': 'Включить рекурсивный поиск файлов', + 'Disable Fuzzy Search': 'Отключить нечеткий поиск', + 'Enable Interactive Shell': 'Включить интерактивный терминал', + 'Show Color': 'Показывать цвета', + 'Auto Accept': 'Автоподтверждение', + 'Use Ripgrep': 'Использовать Ripgrep', + 'Use Builtin Ripgrep': 'Использовать встроенный Ripgrep', + 'Enable Tool Output Truncation': 'Включить обрезку вывода инструментов', + 'Tool Output Truncation Threshold': 'Порог обрезки вывода инструментов', + 'Tool Output Truncation Lines': 'Лимит строк вывода инструментов', + 'Folder Trust': 'Доверие к папке', + 'Vision Model Preview': 'Визуальная модель (предпросмотр)', + // Варианты перечислений настроек + 'Auto (detect from system)': 'Авто (определить из системы)', + Text: 'Текст', + JSON: 'JSON', + Plan: 'План', + Default: 'По умолчанию', + 'Auto Edit': 'Авторедактирование', + YOLO: 'YOLO', + 'toggle vim mode on/off': 'Включение/выключение режима vim', + 'check session stats. Usage: /stats [model|tools]': + 'Просмотр статистики сессии. Использование: /stats [model|tools]', + 'Show model-specific usage statistics.': + 'Показать статистику использования модели.', + 'Show tool-specific usage statistics.': + 'Показать статистику использования инструментов.', + 'exit the cli': 'Выход из CLI', + 'list configured MCP servers and tools, or authenticate with OAuth-enabled servers': + 'Показать настроенные MCP-серверы и инструменты, или авторизоваться на серверах с поддержкой OAuth', + 'Manage workspace directories': + 'Управление директориями рабочего пространства', + 'Add directories to the workspace. Use comma to separate multiple paths': + 'Добавить директории в рабочее пространство. Используйте запятую для разделения путей', + 'Show all directories in the workspace': + 'Показать все директории в рабочем пространстве', + 'set external editor preference': + 'Установка предпочитаемого внешнего редактора', + 'Manage extensions': 'Управление расширениями', + 'List active extensions': 'Показать активные расширения', + 'Update extensions. Usage: update |--all': + 'Обновить расширения. Использование: update |--all', + 'manage IDE integration': 'Управление интеграцией с IDE', + 'check status of IDE integration': 'Проверить статус интеграции с IDE', + 'install required IDE companion for {{ideName}}': + 'Установить необходимый компаньон IDE для {{ideName}}', + 'enable IDE integration': 'Включение интеграции с IDE', + 'disable IDE integration': 'Отключение интеграции с IDE', + 'IDE integration is not supported in your current environment. To use this feature, run Qwen Code in one of these supported IDEs: VS Code or VS Code forks.': + 'Интеграция с IDE не поддерживается в вашем окружении. Для использования этой функции запустите Qwen Code в одной из поддерживаемых IDE: VS Code или форках VS Code.', + 'Set up GitHub Actions': 'Настройка GitHub Actions', + 'Configure terminal keybindings for multiline input (VS Code, Cursor, Windsurf, Trae)': + 'Настройка привязки клавиш терминала для многострочного ввода (VS Code, Cursor, Windsurf, Trae)', + 'Please restart your terminal for the changes to take effect.': + 'Пожалуйста, перезапустите терминал для применения изменений.', + 'Failed to configure terminal: {{error}}': + 'Не удалось настроить терминал: {{error}}', + 'Could not determine {{terminalName}} config path on Windows: APPDATA environment variable is not set.': + 'Не удалось определить путь конфигурации {{terminalName}} в Windows: переменная окружения APPDATA не установлена.', + '{{terminalName}} keybindings.json exists but is not a valid JSON array. Please fix the file manually or delete it to allow automatic configuration.': + '{{terminalName}} keybindings.json существует, но не является корректным массивом JSON. Пожалуйста, исправьте файл вручную или удалите его для автоматической настройки.', + 'File: {{file}}': 'Файл: {{file}}', + 'Failed to parse {{terminalName}} keybindings.json. The file contains invalid JSON. Please fix the file manually or delete it to allow automatic configuration.': + 'Не удалось разобрать {{terminalName}} keybindings.json. Файл содержит некорректный JSON. Пожалуйста, исправьте файл вручную или удалите его для автоматической настройки.', + 'Error: {{error}}': 'Ошибка: {{error}}', + 'Shift+Enter binding already exists': 'Привязка Shift+Enter уже существует', + 'Ctrl+Enter binding already exists': 'Привязка Ctrl+Enter уже существует', + 'Existing keybindings detected. Will not modify to avoid conflicts.': + 'Обнаружены существующие привязки клавиш. Не будут изменены во избежание конфликтов.', + 'Please check and modify manually if needed: {{file}}': + 'Пожалуйста, проверьте и измените вручную при необходимости: {{file}}', + 'Added Shift+Enter and Ctrl+Enter keybindings to {{terminalName}}.': + 'Добавлены привязки Shift+Enter и Ctrl+Enter для {{terminalName}}.', + 'Modified: {{file}}': 'Изменено: {{file}}', + '{{terminalName}} keybindings already configured.': + 'Привязки клавиш {{terminalName}} уже настроены.', + 'Failed to configure {{terminalName}}.': + 'Не удалось настроить {{terminalName}}.', + 'Your terminal is already configured for an optimal experience with multiline input (Shift+Enter and Ctrl+Enter).': + 'Ваш терминал уже настроен для оптимальной работы с многострочным вводом (Shift+Enter и Ctrl+Enter).', + 'Could not detect terminal type. Supported terminals: VS Code, Cursor, Windsurf, and Trae.': + 'Не удалось определить тип терминала. Поддерживаемые терминалы: VS Code, Cursor, Windsurf и Trae.', + 'Terminal "{{terminal}}" is not supported yet.': + 'Терминал "{{terminal}}" еще не поддерживается.', + + // ============================================================================ + // Команды - Язык + // ============================================================================ + 'Invalid language. Available: en-US, zh-CN': + 'Неверный язык. Доступны: en-US, zh-CN, ru-RU', + 'Language subcommands do not accept additional arguments.': + 'Подкоманды языка не принимают дополнительных аргументов.', + 'Current UI language: {{lang}}': 'Текущий язык интерфейса: {{lang}}', + 'Current LLM output language: {{lang}}': 'Текущий язык вывода LLM: {{lang}}', + 'LLM output language not set': 'Язык вывода LLM не установлен', + 'Set UI language': 'Установка языка интерфейса', + 'Set LLM output language': 'Установка языка вывода LLM', + 'Usage: /language ui [zh-CN|en-US]': + 'Использование: /language ui [zh-CN|en-US|ru-RU]', + 'Usage: /language output ': 'Использование: /language output ', + 'Example: /language output 中文': 'Пример: /language output 中文', + 'Example: /language output English': 'Пример: /language output English', + 'Example: /language output 日本語': 'Пример: /language output 日本語', + 'UI language changed to {{lang}}': 'Язык интерфейса изменен на {{lang}}', + 'LLM output language rule file generated at {{path}}': + 'Файл правил языка вывода LLM создан в {{path}}', + 'Please restart the application for the changes to take effect.': + 'Пожалуйста, перезапустите приложение для применения изменений.', + 'Failed to generate LLM output language rule file: {{error}}': + 'Не удалось создать файл правил языка вывода LLM: {{error}}', + 'Invalid command. Available subcommands:': + 'Неверная команда. Доступные подкоманды:', + 'Available subcommands:': 'Доступные подкоманды:', + 'To request additional UI language packs, please open an issue on GitHub.': + 'Для запроса дополнительных языковых пакетов интерфейса, пожалуйста, создайте обращение на GitHub.', + 'Available options:': 'Доступные варианты:', + ' - zh-CN: Simplified Chinese': ' - zh-CN: Упрощенный китайский', + ' - en-US: English': ' - en-US: Английский', + ' - ru-RU: Russian': ' - ru-RU: Русский', + 'Set UI language to Simplified Chinese (zh-CN)': + 'Установить язык интерфейса на упрощенный китайский (zh-CN)', + 'Set UI language to English (en-US)': + 'Установить язык интерфейса на английский (en-US)', + + // ============================================================================ + // Команды - Режим подтверждения + // ============================================================================ + 'Approval Mode': 'Режим подтверждения', + 'Current approval mode: {{mode}}': 'Текущий режим подтверждения: {{mode}}', + 'Available approval modes:': 'Доступные режимы подтверждения:', + 'Approval mode changed to: {{mode}}': + 'Режим подтверждения изменен на: {{mode}}', + 'Approval mode changed to: {{mode}} (saved to {{scope}} settings{{location}})': + 'Режим подтверждения изменен на: {{mode}} (сохранено в настройках {{scope}}{{location}})', + 'Usage: /approval-mode [--session|--user|--project]': + 'Использование: /approval-mode [--session|--user|--project]', + 'Scope subcommands do not accept additional arguments.': + 'Подкоманды области не принимают дополнительных аргументов.', + 'Plan mode - Analyze only, do not modify files or execute commands': + 'Режим планирования - только анализ, без изменения файлов или выполнения команд', + 'Default mode - Require approval for file edits or shell commands': + 'Режим по умолчанию - требуется подтверждение для редактирования файлов или команд терминала', + 'Auto-edit mode - Automatically approve file edits': + 'Режим авторедактирования - автоматическое подтверждение изменений файлов', + 'YOLO mode - Automatically approve all tools': + 'Режим YOLO - автоматическое подтверждение всех инструментов', + '{{mode}} mode': 'Режим {{mode}}', + 'Settings service is not available; unable to persist the approval mode.': + 'Служба настроек недоступна; невозможно сохранить режим подтверждения.', + 'Failed to save approval mode: {{error}}': + 'Не удалось сохранить режим подтверждения: {{error}}', + 'Failed to change approval mode: {{error}}': + 'Не удалось изменить режим подтверждения: {{error}}', + 'Apply to current session only (temporary)': + 'Применить только к текущей сессии (временно)', + 'Persist for this project/workspace': + 'Сохранить для этого проекта/рабочего пространства', + 'Persist for this user on this machine': + 'Сохранить для этого пользователя на этой машине', + 'Analyze only, do not modify files or execute commands': + 'Только анализ, без изменения файлов или выполнения команд', + 'Require approval for file edits or shell commands': + 'Требуется подтверждение для редактирования файлов или команд терминала', + 'Automatically approve file edits': + 'Автоматически подтверждать изменения файлов', + 'Automatically approve all tools': + 'Автоматически подтверждать все инструменты', + 'Workspace approval mode exists and takes priority. User-level change will have no effect.': + 'Режим подтверждения рабочего пространства существует и имеет приоритет. Изменение на уровне пользователя не будет иметь эффекта.', + '(Use Enter to select, Tab to change focus)': + '(Enter для выбора, Tab для смены фокуса)', + 'Apply To': 'Применить к', + 'User Settings': 'Настройки пользователя', + 'Workspace Settings': 'Настройки рабочего пространства', + + // ============================================================================ + // Команды - Память + // ============================================================================ + 'Commands for interacting with memory.': + 'Команды для взаимодействия с памятью', + 'Show the current memory contents.': 'Показать текущее содержимое памяти.', + 'Show project-level memory contents.': 'Показать память уровня проекта.', + 'Show global memory contents.': 'Показать глобальную память.', + 'Add content to project-level memory.': + 'Добавить содержимое в память уровня проекта.', + 'Add content to global memory.': 'Добавить содержимое в глобальную память.', + 'Refresh the memory from the source.': 'Обновить память из источника.', + 'Usage: /memory add --project ': + 'Использование: /memory add --project <текст для запоминания>', + 'Usage: /memory add --global ': + 'Использование: /memory add --global <текст для запоминания>', + 'Attempting to save to project memory: "{{text}}"': + 'Попытка сохранить в память проекта: "{{text}}"', + 'Attempting to save to global memory: "{{text}}"': + 'Попытка сохранить в глобальную память: "{{text}}"', + 'Current memory content from {{count}} file(s):': + 'Текущее содержимое памяти из {{count}} файла(ов):', + 'Memory is currently empty.': 'Память в настоящее время пуста.', + 'Project memory file not found or is currently empty.': + 'Файл памяти проекта не найден или в настоящее время пуст.', + 'Global memory file not found or is currently empty.': + 'Файл глобальной памяти не найден или в настоящее время пуст.', + 'Global memory is currently empty.': + 'Глобальная память в настоящее время пуста.', + 'Global memory content:\n\n---\n{{content}}\n---': + 'Содержимое глобальной памяти:\n\n---\n{{content}}\n---', + 'Project memory content from {{path}}:\n\n---\n{{content}}\n---': + 'Содержимое памяти проекта из {{path}}:\n\n---\n{{content}}\n---', + 'Project memory is currently empty.': + 'Память проекта в настоящее время пуста.', + 'Refreshing memory from source files...': + 'Обновление памяти из исходных файлов...', + 'Add content to the memory. Use --global for global memory or --project for project memory.': + 'Добавить содержимое в память. Используйте --global для глобальной памяти или --project для памяти проекта.', + 'Usage: /memory add [--global|--project] ': + 'Использование: /memory add [--global|--project] <текст для запоминания>', + 'Attempting to save to memory {{scope}}: "{{fact}}"': + 'Попытка сохранить в память {{scope}}: "{{fact}}"', + + // ============================================================================ + // Команды - MCP + // ============================================================================ + 'Authenticate with an OAuth-enabled MCP server': + 'Авторизоваться на MCP-сервере с поддержкой OAuth', + 'List configured MCP servers and tools': + 'Просмотр настроенных MCP-серверов и инструментов', + 'Restarts MCP servers.': 'Перезапустить MCP-серверы.', + 'Config not loaded.': 'Конфигурация не загружена.', + 'Could not retrieve tool registry.': + 'Не удалось получить реестр инструментов.', + 'No MCP servers configured with OAuth authentication.': + 'Нет MCP-серверов, настроенных с авторизацией OAuth.', + 'MCP servers with OAuth authentication:': 'MCP-серверы с авторизацией OAuth:', + 'Use /mcp auth to authenticate.': + 'Используйте /mcp auth <имя-сервера> для авторизации.', + "MCP server '{{name}}' not found.": "MCP-сервер '{{name}}' не найден.", + "Successfully authenticated and refreshed tools for '{{name}}'.": + "Успешно авторизовано и обновлены инструменты для '{{name}}'.", + "Failed to authenticate with MCP server '{{name}}': {{error}}": + "Не удалось авторизоваться на MCP-сервере '{{name}}': {{error}}", + "Re-discovering tools from '{{name}}'...": + "Повторное обнаружение инструментов от '{{name}}'...", + + // ============================================================================ + // Команды - Чат + // ============================================================================ + 'Manage conversation history.': 'Управление историей диалогов.', + 'List saved conversation checkpoints': + 'Показать сохраненные точки восстановления диалога', + 'No saved conversation checkpoints found.': + 'Не найдено сохраненных точек восстановления диалога.', + 'List of saved conversations:': 'Список сохраненных диалогов:', + 'Note: Newest last, oldest first': + 'Примечание: новые последними, старые первыми', + 'Save the current conversation as a checkpoint. Usage: /chat save ': + 'Сохранить текущий диалог как точку восстановления. Использование: /chat save <тег>', + 'Missing tag. Usage: /chat save ': + 'Отсутствует тег. Использование: /chat save <тег>', + 'Delete a conversation checkpoint. Usage: /chat delete ': + 'Удалить точку восстановления диалога. Использование: /chat delete <тег>', + 'Missing tag. Usage: /chat delete ': + 'Отсутствует тег. Использование: /chat delete <тег>', + "Conversation checkpoint '{{tag}}' has been deleted.": + "Точка восстановления диалога '{{tag}}' удалена.", + "Error: No checkpoint found with tag '{{tag}}'.": + "Ошибка: точка восстановления с тегом '{{tag}}' не найдена.", + 'Resume a conversation from a checkpoint. Usage: /chat resume ': + 'Возобновить диалог из точки восстановления. Использование: /chat resume <тег>', + 'Missing tag. Usage: /chat resume ': + 'Отсутствует тег. Использование: /chat resume <тег>', + 'No saved checkpoint found with tag: {{tag}}.': + 'Не найдена сохраненная точка восстановления с тегом: {{tag}}.', + 'A checkpoint with the tag {{tag}} already exists. Do you want to overwrite it?': + 'Точка восстановления с тегом {{tag}} уже существует. Перезаписать?', + 'No chat client available to save conversation.': + 'Нет доступного клиента чата для сохранения диалога.', + 'Conversation checkpoint saved with tag: {{tag}}.': + 'Точка восстановления диалога сохранена с тегом: {{tag}}.', + 'No conversation found to save.': 'Нет диалога для сохранения.', + 'No chat client available to share conversation.': + 'Нет доступного клиента чата для экспорта диалога.', + 'Invalid file format. Only .md and .json are supported.': + 'Неверный формат файла. Поддерживаются только .md и .json.', + 'Error sharing conversation: {{error}}': + 'Ошибка при экспорте диалога: {{error}}', + 'Conversation shared to {{filePath}}': 'Диалог экспортирован в {{filePath}}', + 'No conversation found to share.': 'Нет диалога для экспорта.', + 'Share the current conversation to a markdown or json file. Usage: /chat share <путь-к-файлу>': + 'Экспортировать текущий диалог в markdown или json файл. Использование: /chat share <путь-к-файлу>', + + // ============================================================================ + // Команды - Резюме + // ============================================================================ + 'Generate a project summary and save it to .qwen/PROJECT_SUMMARY.md': + 'Сгенерировать сводку проекта и сохранить её в .qwen/PROJECT_SUMMARY.md', + 'No chat client available to generate summary.': + 'Нет доступного чат-клиента для генерации сводки.', + 'Already generating summary, wait for previous request to complete': + 'Генерация сводки уже выполняется, дождитесь завершения предыдущего запроса', + 'No conversation found to summarize.': + 'Не найдено диалогов для создания сводки.', + 'Failed to generate project context summary: {{error}}': + 'Не удалось сгенерировать сводку контекста проекта: {{error}}', + + // ============================================================================ + // Команды - Модель + // ============================================================================ + 'Switch the model for this session': 'Переключение модели для этой сессии', + 'Content generator configuration not available.': + 'Конфигурация генератора содержимого недоступна.', + 'Authentication type not available.': 'Тип авторизации недоступен.', + 'No models available for the current authentication type ({{authType}}).': + 'Нет доступных моделей для текущего типа авторизации ({{authType}}).', + + // ============================================================================ + // Команды - Очистка + // ============================================================================ + 'Clearing terminal and resetting chat.': 'Очистка терминала и сброс чата.', + 'Clearing terminal.': 'Очистка терминала.', + + // ============================================================================ + // Команды - Сжатие + // ============================================================================ + 'Already compressing, wait for previous request to complete': + 'Уже выполняется сжатие, дождитесь завершения предыдущего запроса', + 'Failed to compress chat history.': 'Не удалось сжать историю чата.', + 'Failed to compress chat history: {{error}}': + 'Не удалось сжать историю чата: {{error}}', + 'Compressing chat history': 'Сжатие истории чата', + 'Chat history compressed from {{originalTokens}} to {{newTokens}} tokens.': + 'История чата сжата с {{originalTokens}} до {{newTokens}} токенов.', + 'Compression was not beneficial for this history size.': + 'Сжатие не было полезным для этого размера истории.', + 'Chat history compression did not reduce size. This may indicate issues with the compression prompt.': + 'Сжатие истории чата не уменьшило размер. Это может указывать на проблемы с промптом сжатия.', + 'Could not compress chat history due to a token counting error.': + 'Не удалось сжать историю чата из-за ошибки подсчета токенов.', + 'Chat history is already compressed.': 'История чата уже сжата.', + + // ============================================================================ + // Команды - Директория + // ============================================================================ + 'Configuration is not available.': 'Конфигурация недоступна.', + 'Please provide at least one path to add.': + 'Пожалуйста, укажите хотя бы один путь для добавления.', + 'The /directory add command is not supported in restrictive sandbox profiles. Please use --include-directories when starting the session instead.': + 'Команда /directory add не поддерживается в ограничительных профилях песочницы. Пожалуйста, используйте --include-directories при запуске сессии.', + "Error adding '{{path}}': {{error}}": + "Ошибка при добавлении '{{path}}': {{error}}", + 'Successfully added GEMINI.md files from the following directories if there are:\n- {{directories}}': + 'Успешно добавлены файлы GEMINI.md из следующих директорий (если они есть):\n- {{directories}}', + 'Error refreshing memory: {{error}}': + 'Ошибка при обновлении памяти: {{error}}', + 'Successfully added directories:\n- {{directories}}': + 'Успешно добавлены директории:\n- {{directories}}', + 'Current workspace directories:\n{{directories}}': + 'Текущие директории рабочего пространства:\n{{directories}}', + + // ============================================================================ + // Команды - Документация + // ============================================================================ + 'Please open the following URL in your browser to view the documentation:\n{{url}}': + 'Пожалуйста, откройте следующий URL в браузере для просмотра документации:\n{{url}}', + 'Opening documentation in your browser: {{url}}': + 'Открытие документации в браузере: {{url}}', + + // ============================================================================ + // Диалоги - Подтверждение инструментов + // ============================================================================ + 'Do you want to proceed?': 'Вы хотите продолжить?', + 'Yes, allow once': 'Да, разрешить один раз', + 'Allow always': 'Всегда разрешать', + No: 'Нет', + 'No (esc)': 'Нет (esc)', + 'Yes, allow always for this session': 'Да, всегда разрешать для этой сессии', + 'Modify in progress:': 'Идет изменение:', + 'Save and close external editor to continue': + 'Сохраните и закройте внешний редактор для продолжения', + 'Apply this change?': 'Применить это изменение?', + 'Yes, allow always': 'Да, всегда разрешать', + 'Modify with external editor': 'Изменить во внешнем редакторе', + 'No, suggest changes (esc)': 'Нет, предложить изменения (esc)', + "Allow execution of: '{{command}}'?": "Разрешить выполнение: '{{command}}'?", + 'Yes, allow always ...': 'Да, всегда разрешать ...', + 'Yes, and auto-accept edits': 'Да, и автоматически принимать правки', + 'Yes, and manually approve edits': 'Да, и вручную подтверждать правки', + 'No, keep planning (esc)': 'Нет, продолжить планирование (esc)', + 'URLs to fetch:': 'URL для загрузки:', + 'MCP Server: {{server}}': 'MCP-сервер: {{server}}', + 'Tool: {{tool}}': 'Инструмент: {{tool}}', + 'Allow execution of MCP tool "{{tool}}" from server "{{server}}"?': + 'Разрешить выполнение инструмента MCP "{{tool}}" с сервера "{{server}}"?', + 'Yes, always allow tool "{{tool}}" from server "{{server}}"': + 'Да, всегда разрешать инструмент "{{tool}}" с сервера "{{server}}"', + 'Yes, always allow all tools from server "{{server}}"': + 'Да, всегда разрешать все инструменты с сервера "{{server}}"', + + // ============================================================================ + // Диалоги - Подтверждение оболочки + // ============================================================================ + 'Shell Command Execution': 'Выполнение команды терминала', + 'A custom command wants to run the following shell commands:': + 'Пользовательская команда хочет выполнить следующие команды терминала:', + + // ============================================================================ + // Диалоги - Квота подписки Pro + // ============================================================================ + 'Pro quota limit reached for {{model}}.': + 'Исчерпана квота подписки Pro для {{model}}.', + 'Change auth (executes the /auth command)': + 'Изменить авторизацию (выполняет команду /auth)', + 'Continue with {{model}}': 'Продолжить с {{model}}', + + // ============================================================================ + // Диалоги - Приветствие при возвращении + // ============================================================================ + 'Current Plan:': 'Текущий план:', + 'Progress: {{done}}/{{total}} tasks completed': + 'Прогресс: {{done}}/{{total}} задач выполнено', + ', {{inProgress}} in progress': ', {{inProgress}} в процессе', + 'Pending Tasks:': 'Ожидающие задачи:', + 'What would you like to do?': 'Что вы хотите сделать?', + 'Choose how to proceed with your session:': + 'Выберите, как продолжить сессию:', + 'Start new chat session': 'Начать новую сессию чата', + 'Continue previous conversation': 'Продолжить предыдущий диалог', + '👋 Welcome back! (Last updated: {{timeAgo}})': + '👋 С возвращением! (Последнее обновление: {{timeAgo}})', + '🎯 Overall Goal:': '🎯 Общая цель:', + + // ============================================================================ + // Диалоги - Авторизация + // ============================================================================ + 'Get started': 'Начать', + 'How would you like to authenticate for this project?': + 'Как вы хотите авторизоваться для этого проекта?', + 'OpenAI API key is required to use OpenAI authentication.': + 'Для использования авторизации OpenAI требуется ключ API OpenAI.', + 'You must select an auth method to proceed. Press Ctrl+C again to exit.': + 'Вы должны выбрать метод авторизации для продолжения. Нажмите Ctrl+C снова для выхода.', + '(Use Enter to Set Auth)': '(Enter для установки авторизации)', + 'Terms of Services and Privacy Notice for Qwen Code': + 'Условия обслуживания и уведомление о конфиденциальности для Qwen Code', + 'Qwen OAuth': 'Qwen OAuth', + OpenAI: 'OpenAI', + 'Failed to login. Message: {{message}}': + 'Не удалось войти. Сообщение: {{message}}', + 'Authentication is enforced to be {{enforcedType}}, but you are currently using {{currentType}}.': + 'Авторизация должна быть {{enforcedType}}, но вы сейчас используете {{currentType}}.', + 'Qwen OAuth authentication timed out. Please try again.': + 'Время ожидания авторизации Qwen OAuth истекло. Пожалуйста, попробуйте снова.', + 'Qwen OAuth authentication cancelled.': 'Авторизация Qwen OAuth отменена.', + 'Qwen OAuth Authentication': 'Авторизация Qwen OAuth', + 'Please visit this URL to authorize:': + 'Пожалуйста, посетите этот URL для авторизации:', + 'Or scan the QR code below:': 'Или отсканируйте QR-код ниже:', + 'Waiting for authorization': 'Ожидание авторизации', + 'Time remaining:': 'Осталось времени:', + '(Press ESC or CTRL+C to cancel)': '(Нажмите ESC или CTRL+C для отмены)', + 'Qwen OAuth Authentication Timeout': 'Таймаут авторизации Qwen OAuth', + 'OAuth token expired (over {{seconds}} seconds). Please select authentication method again.': + 'Токен OAuth истек (более {{seconds}} секунд). Пожалуйста, выберите метод авторизации снова.', + 'Press any key to return to authentication type selection.': + 'Нажмите любую клавишу для возврата к выбору типа авторизации.', + 'Waiting for Qwen OAuth authentication...': + 'Ожидание авторизации Qwen OAuth...', + 'Note: Your existing API key in settings.json will not be cleared when using Qwen OAuth. You can switch back to OpenAI authentication later if needed.': + 'Примечание: Ваш существующий ключ API в settings.json не будет удален при использовании Qwen OAuth. Вы можете переключиться обратно на авторизацию OpenAI позже при необходимости.', + 'Authentication timed out. Please try again.': + 'Время ожидания авторизации истекло. Пожалуйста, попробуйте снова.', + 'Waiting for auth... (Press ESC or CTRL+C to cancel)': + 'Ожидание авторизации... (Нажмите ESC или CTRL+C для отмены)', + 'Failed to authenticate. Message: {{message}}': + 'Не удалось авторизоваться. Сообщение: {{message}}', + 'Authenticated successfully with {{authType}} credentials.': + 'Успешно авторизовано с учетными данными {{authType}}.', + 'Invalid QWEN_DEFAULT_AUTH_TYPE value: "{{value}}". Valid values are: {{validValues}}': + 'Неверное значение QWEN_DEFAULT_AUTH_TYPE: "{{value}}". Допустимые значения: {{validValues}}', + 'OpenAI Configuration Required': 'Требуется конфигурация OpenAI', + 'Please enter your OpenAI configuration. You can get an API key from': + 'Пожалуйста, введите конфигурацию OpenAI. Вы можете получить ключ API на', + 'API Key:': 'Ключ API:', + 'Invalid credentials: {{errorMessage}}': + 'Неверные учетные данные: {{errorMessage}}', + 'Failed to validate credentials': 'Не удалось проверить учетные данные', + 'Press Enter to continue, Tab/↑↓ to navigate, Esc to cancel': + 'Enter для продолжения, Tab/↑↓ для навигации, Esc для отмены', + + // ============================================================================ + // Диалоги - Модель + // ============================================================================ + 'Select Model': 'Выбрать модель', + '(Press Esc to close)': '(Нажмите Esc для закрытия)', + '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)', + '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)', + + // ============================================================================ + // Диалоги - Разрешения + // ============================================================================ + 'Manage folder trust settings': 'Управление настройками доверия к папкам', + + // ============================================================================ + // Строка состояния + // ============================================================================ + 'Using:': 'Используется:', + '{{count}} open file': '{{count}} открытый файл', + '{{count}} open files': '{{count}} открытых файла(ов)', + '(ctrl+g to view)': '(ctrl+g для просмотра)', + '{{count}} {{name}} file': '{{count}} файл {{name}}', + '{{count}} {{name}} files': '{{count}} файла(ов) {{name}}', + '{{count}} MCP server': '{{count}} MCP-сервер', + '{{count}} MCP servers': '{{count}} MCP-сервера(ов)', + '{{count}} Blocked': '{{count}} заблокирован(о)', + '(ctrl+t to view)': '(ctrl+t для просмотра)', + '(ctrl+t to toggle)': '(ctrl+t для переключения)', + 'Press Ctrl+C again to exit.': 'Нажмите Ctrl+C снова для выхода.', + 'Press Ctrl+D again to exit.': 'Нажмите Ctrl+D снова для выхода.', + 'Press Esc again to clear.': 'Нажмите Esc снова для очистки.', + + // ============================================================================ + // Статус MCP + // ============================================================================ + 'No MCP servers configured.': 'Не настроено MCP-серверов.', + 'Please view MCP documentation in your browser:': + 'Пожалуйста, просмотрите документацию MCP в браузере:', + 'or use the cli /docs command': 'или используйте команду cli /docs', + '⏳ MCP servers are starting up ({{count}} initializing)...': + '⏳ MCP-серверы запускаются ({{count}} инициализируется)...', + 'Note: First startup may take longer. Tool availability will update automatically.': + 'Примечание: Первый запуск может занять больше времени. Доступность инструментов обновится автоматически.', + 'Configured MCP servers:': 'Настроенные MCP-серверы:', + Ready: 'Готов', + 'Starting... (first startup may take longer)': + 'Запуск... (первый запуск может занять больше времени)', + Disconnected: 'Отключен', + '{{count}} tool': '{{count}} инструмент', + '{{count}} tools': '{{count}} инструмента(ов)', + '{{count}} prompt': '{{count}} промпт', + '{{count}} prompts': '{{count}} промпта(ов)', + '(from {{extensionName}})': '(от {{extensionName}})', + OAuth: 'OAuth', + 'OAuth expired': 'OAuth истек', + 'OAuth not authenticated': 'OAuth не авторизован', + 'tools and prompts will appear when ready': + 'инструменты и промпты появятся, когда будут готовы', + '{{count}} tools cached': '{{count}} инструмента(ов) в кэше', + 'Tools:': 'Инструменты:', + 'Parameters:': 'Параметры:', + 'Prompts:': 'Промпты:', + Blocked: 'Заблокировано', + '💡 Tips:': '💡 Подсказки:', + Use: 'Используйте', + 'to show server and tool descriptions': + 'для показа описаний сервера и инструментов', + 'to show tool parameter schemas': 'для показа схем параметров инструментов', + 'to hide descriptions': 'для скрытия описаний', + 'to authenticate with OAuth-enabled servers': + 'для авторизации на серверах с поддержкой OAuth', + Press: 'Нажмите', + 'to toggle tool descriptions on/off': + 'для переключения описаний инструментов', + "Starting OAuth authentication for MCP server '{{name}}'...": + "Начало авторизации OAuth для MCP-сервера '{{name}}'...", + 'Restarting MCP servers...': 'Перезапуск MCP-серверов...', + + // ============================================================================ + // Подсказки при запуске + // ============================================================================ + 'Tips for getting started:': 'Подсказки для начала работы:', + '1. Ask questions, edit files, or run commands.': + '1. Задавайте вопросы, редактируйте файлы или выполняйте команды.', + '2. Be specific for the best results.': + '2. Будьте конкретны для лучших результатов.', + 'files to customize your interactions with Qwen Code.': + 'файлы для настройки взаимодействия с Qwen Code.', + 'for more information.': 'для получения дополнительной информации.', + + // ============================================================================ + // Экран выхода / Статистика + // ============================================================================ + 'Agent powering down. Goodbye!': 'Агент завершает работу. До свидания!', + 'Interaction Summary': 'Сводка взаимодействия', + 'Session ID:': 'ID сессии:', + 'Tool Calls:': 'Вызовы инструментов:', + 'Success Rate:': 'Процент успеха:', + 'User Agreement:': 'Согласие пользователя:', + reviewed: 'проверено', + 'Code Changes:': 'Изменения кода:', + Performance: 'Производительность', + 'Wall Time:': 'Общее время:', + 'Agent Active:': 'Активность агента:', + 'API Time:': 'Время API:', + 'Tool Time:': 'Время инструментов:', + 'Session Stats': 'Статистика сессии', + 'Model Usage': 'Использование модели', + Reqs: 'Запросов', + 'Input Tokens': 'Входных токенов', + 'Output Tokens': 'Выходных токенов', + 'Savings Highlight:': 'Экономия:', + 'of input tokens were served from the cache, reducing costs.': + 'входных токенов обслужено из кэша, снижая затраты.', + 'Tip: For a full token breakdown, run `/stats model`.': + 'Подсказка: Для полной разбивки токенов выполните `/stats model`.', + 'Model Stats For Nerds': 'Статистика модели для гиков', + 'Tool Stats For Nerds': 'Статистика инструментов для гиков', + Metric: 'Метрика', + API: 'API', + Requests: 'Запросы', + Errors: 'Ошибки', + 'Avg Latency': 'Средняя задержка', + Tokens: 'Токены', + Total: 'Всего', + Prompt: 'Промпт', + Cached: 'Кэшировано', + Thoughts: 'Размышления', + Tool: 'Инструмент', + Output: 'Вывод', + 'No API calls have been made in this session.': + 'В этой сессии не было вызовов API.', + 'Tool Name': 'Имя инструмента', + Calls: 'Вызовы', + 'Success Rate': 'Процент успеха', + 'Avg Duration': 'Средняя длительность', + 'User Decision Summary': 'Сводка решений пользователя', + 'Total Reviewed Suggestions:': 'Всего проверено предложений:', + ' » Accepted:': ' » Принято:', + ' » Rejected:': ' » Отклонено:', + ' » Modified:': ' » Изменено:', + ' Overall Agreement Rate:': ' Общий процент согласия:', + 'No tool calls have been made in this session.': + 'В этой сессии не было вызовов инструментов.', + 'Session start time is unavailable, cannot calculate stats.': + 'Время начала сессии недоступно, невозможно рассчитать статистику.', + + // ============================================================================ + // Loading Phrases + // ============================================================================ + 'Waiting for user confirmation...': + 'Ожидание подтверждения от пользователя...', + '(esc to cancel, {{time}})': '(esc для отмены, {{time}})', + "I'm Feeling Lucky": 'Мне повезёт!', + 'Shipping awesomeness... ': 'Доставляем крутизну... ', + 'Painting the serifs back on...': 'Рисуем засечки на буквах...', + 'Navigating the slime mold...': 'Пробираемся через слизевиков..', + 'Consulting the digital spirits...': 'Советуемся с цифровыми духами...', + 'Reticulating splines...': 'Сглаживание сплайнов...', + 'Warming up the AI hamsters...': 'Разогреваем ИИ-хомячков...', + 'Asking the magic conch shell...': 'Спрашиваем волшебную ракушку...', + 'Generating witty retort...': 'Генерируем остроумный ответ...', + 'Polishing the algorithms...': 'Полируем алгоритмы...', + "Don't rush perfection (or my code)...": + 'Не торопите совершенство (или мой код)...', + 'Brewing fresh bytes...': 'Завариваем свежие байты...', + 'Counting electrons...': 'Пересчитываем электроны...', + 'Engaging cognitive processors...': 'Задействуем когнитивные процессоры...', + 'Checking for syntax errors in the universe...': + 'Ищем синтаксические ошибки во вселенной...', + 'One moment, optimizing humor...': 'Секундочку, оптимизируем юмор...', + 'Shuffling punchlines...': 'Перетасовываем панчлайны...', + 'Untangling neural nets...': 'Распутаваем нейросети...', + 'Compiling brilliance...': 'Компилируем гениальность...', + 'Loading wit.exe...': 'Загружаем yumor.exe...', + 'Summoning the cloud of wisdom...': 'Призываем облако мудрости...', + 'Preparing a witty response...': 'Готовим остроумный ответ...', + "Just a sec, I'm debugging reality...": 'Секунду, идёт отладка реальности...', + 'Confuzzling the options...': 'Запутываем варианты...', + 'Tuning the cosmic frequencies...': 'Настраиваем космические частоты...', + 'Crafting a response worthy of your patience...': + 'Создаем ответ, достойный вашего терпения...', + 'Compiling the 1s and 0s...': 'Компилируем единички и нолики...', + 'Resolving dependencies... and existential crises...': + 'Разрешаем зависимости... и экзистенциальные кризисы...', + 'Defragmenting memories... both RAM and personal...': + 'Дефрагментация памяти... и оперативной, и личной...', + 'Rebooting the humor module...': 'Перезагрузка модуля юмора...', + 'Caching the essentials (mostly cat memes)...': + 'Кэшируем самое важное (в основном мемы с котиками)...', + 'Optimizing for ludicrous speed': 'Оптимизация для безумной скорости', + "Swapping bits... don't tell the bytes...": + 'Меняем биты... только байтам не говорите...', + 'Garbage collecting... be right back...': 'Сборка мусора... скоро вернусь...', + 'Assembling the interwebs...': 'Сборка интернетов...', + 'Converting coffee into code...': 'Превращаем кофе в код...', + 'Updating the syntax for reality...': 'Обновляем синтаксис реальности...', + 'Rewiring the synapses...': 'Переподключаем синапсы...', + 'Looking for a misplaced semicolon...': 'Ищем лишнюю точку с запятой...', + "Greasin' the cogs of the machine...": 'Смазываем шестерёнки машины...', + 'Pre-heating the servers...': 'Разогреваем серверы...', + 'Calibrating the flux capacitor...': 'Калибруем потоковый накопитель...', + 'Engaging the improbability drive...': 'Включаем двигатель невероятности...', + 'Channeling the Force...': 'Направляем Силу...', + 'Aligning the stars for optimal response...': + 'Выравниваем звёзды для оптимального ответа...', + 'So say we all...': 'Так скажем мы все...', + 'Loading the next great idea...': 'Загрузка следующей великой идеи...', + "Just a moment, I'm in the zone...": 'Минутку, я в потоке...', + 'Preparing to dazzle you with brilliance...': + 'Готовлюсь ослепить вас гениальностью...', + "Just a tick, I'm polishing my wit...": 'Секунду, полирую остроумие...', + "Hold tight, I'm crafting a masterpiece...": 'Держитесь, создаю шедевр...', + "Just a jiffy, I'm debugging the universe...": + 'Мигом, отлаживаю вселенную...', + "Just a moment, I'm aligning the pixels...": 'Момент, выравниваю пиксели...', + "Just a sec, I'm optimizing the humor...": 'Секунду, оптимизирую юмор...', + "Just a moment, I'm tuning the algorithms...": + 'Момент, настраиваю алгоритмы...', + 'Warp speed engaged...': 'Варп-скорость включена...', + 'Mining for more Dilithium crystals...': 'Добываем кристаллы дилития...', + "Don't panic...": 'Без паники...', + 'Following the white rabbit...': 'Следуем за белым кроликом...', + 'The truth is in here... somewhere...': 'Истина где-то здесь... внутри...', + 'Blowing on the cartridge...': 'Продуваем картридж...', + 'Loading... Do a barrel roll!': 'Загрузка... Сделай бочку!', + 'Waiting for the respawn...': 'Ждем респауна...', + 'Finishing the Kessel Run in less than 12 parsecs...': + 'Делаем Дугу Кесселя менее чем за 12 парсеков...', + "The cake is not a lie, it's just still loading...": + 'Тортик — не ложь, он просто ещё грузится...', + 'Fiddling with the character creation screen...': + 'Возимся с экраном создания персонажа...', + "Just a moment, I'm finding the right meme...": + 'Минутку, ищу подходящий мем...', + "Pressing 'A' to continue...": "Нажимаем 'A' для продолжения...", + 'Herding digital cats...': 'Пасём цифровых котов...', + 'Polishing the pixels...': 'Полируем пиксели...', + 'Finding a suitable loading screen pun...': + 'Ищем подходящий каламбур для экрана загрузки...', + 'Distracting you with this witty phrase...': + 'Отвлекаем вас этой остроумной фразой...', + 'Almost there... probably...': 'Почти готово... вроде...', + 'Our hamsters are working as fast as they can...': + 'Наши хомячки работают изо всех сил...', + 'Giving Cloudy a pat on the head...': 'Гладим Облачко по голове...', + 'Petting the cat...': 'Гладим кота...', + 'Rickrolling my boss...': 'Рикроллим начальника...', + 'Never gonna give you up, never gonna let you down...': + 'Never gonna give you up, never gonna let you down...', + 'Slapping the bass...': 'Лабаем бас-гитару...', + 'Tasting the snozberries...': 'Пробуем снузберри на вкус...', + "I'm going the distance, I'm going for speed...": + 'Иду до конца, иду на скорость...', + 'Is this the real life? Is this just fantasy?...': + 'Is this the real life? Is this just fantasy?...', + "I've got a good feeling about this...": 'У меня хорошее предчувствие...', + 'Poking the bear...': 'Дразним медведя... (Не лезь...)', + 'Doing research on the latest memes...': 'Изучаем свежие мемы...', + 'Figuring out how to make this more witty...': + 'Думаем, как сделать это остроумнее...', + 'Hmmm... let me think...': 'Хмм... дайте подумать...', + 'What do you call a fish with no eyes? A fsh...': + 'Как называется бумеранг, который не возвращается? Палка...', + 'Why did the computer go to therapy? It had too many bytes...': + 'Почему компьютер простудился? Потому что оставил окна открытыми...', + "Why don't programmers like nature? It has too many bugs...": + 'Почему программисты не любят гулять на улице? Там среда не настроена...', + 'Why do programmers prefer dark mode? Because light attracts bugs...': + 'Почему программисты предпочитают тёмную тему? Потому что в темноте не видно багов...', + 'Why did the developer go broke? Because they used up all their cache...': + 'Почему разработчик разорился? Потому что потратил весь свой кэш...', + "What can you do with a broken pencil? Nothing, it's pointless...": + 'Что можно делать со сломанным карандашом? Ничего — он тупой...', + 'Applying percussive maintenance...': 'Провожу настройку методом тыка...', + 'Searching for the correct USB orientation...': + 'Ищем, какой стороной вставлять флешку...', + 'Ensuring the magic smoke stays inside the wires...': + 'Следим, чтобы волшебный дым не вышел из проводов...', + 'Rewriting in Rust for no particular reason...': + 'Переписываем всё на Rust без особой причины...', + 'Trying to exit Vim...': 'Пытаемся выйти из Vim...', + 'Spinning up the hamster wheel...': 'Раскручиваем колесо для хомяка...', + "That's not a bug, it's an undocumented feature...": 'Это не баг, а фича...', + 'Engage.': 'Поехали!', + "I'll be back... with an answer.": 'Я вернусь... с ответом.', + 'My other process is a TARDIS...': 'Мой другой процесс — это ТАРДИС...', + 'Communing with the machine spirit...': 'Общаемся с духом машины...', + 'Letting the thoughts marinate...': 'Даем мыслям замариноваться...', + 'Just remembered where I put my keys...': + 'Только что вспомнил, куда положил ключи...', + 'Pondering the orb...': 'Размышляю над сферой...', + "I've seen things you people wouldn't believe... like a user who reads loading messages.": + 'Я видел такое, во что вы, люди, просто не поверите... например, пользователя, читающего сообщения загрузки.', + 'Initiating thoughtful gaze...': 'Инициируем задумчивый взгляд...', + "What's a computer's favorite snack? Microchips.": + 'Что сервер заказывает в баре? Пинг-коладу.', + "Why do Java developers wear glasses? Because they don't C#.": + 'Почему Java-разработчики не убираются дома? Они ждут сборщик мусора...', + 'Charging the laser... pew pew!': 'Заряжаем лазер... пиу-пиу!', + 'Dividing by zero... just kidding!': 'Делим на ноль... шучу!', + 'Looking for an adult superviso... I mean, processing.': + 'Ищу взрослых для присмот... в смысле, обрабатываю.', + 'Making it go beep boop.': 'Делаем бип-буп.', + 'Buffering... because even AIs need a moment.': + 'Буферизация... даже ИИ нужно мгновение.', + 'Entangling quantum particles for a faster response...': + 'Запутываем квантовые частицы для быстрого ответа...', + 'Polishing the chrome... on the algorithms.': + 'Полируем хром... на алгоритмах.', + 'Are you not entertained? (Working on it!)': + 'Вы ещё не развлеклись?! Разве вы не за этим сюда пришли?!', + 'Summoning the code gremlins... to help, of course.': + 'Призываем гремлинов кода... для помощи, конечно же.', + 'Just waiting for the dial-up tone to finish...': + 'Ждем, пока закончится звук dial-up модема...', + 'Recalibrating the humor-o-meter.': 'Перекалибровка юморометра.', + 'My other loading screen is even funnier.': + 'Мой другой экран загрузки ещё смешнее.', + "Pretty sure there's a cat walking on the keyboard somewhere...": + 'Кажется, где-то по клавиатуре гуляет кот...', + 'Enhancing... Enhancing... Still loading.': + 'Улучшаем... Ещё улучшаем... Всё ещё грузится.', + "It's not a bug, it's a feature... of this loading screen.": + 'Это не баг, это фича... экрана загрузки.', + 'Have you tried turning it off and on again? (The loading screen, not me.)': + 'Пробовали выключить и включить снова? (Экран загрузки, не меня!)', + 'Constructing additional pylons...': 'Нужно построить больше пилонов...', +}; diff --git a/packages/cli/src/i18n/locales/zh.js b/packages/cli/src/i18n/locales/zh.js index adeb85f17..b8f49a776 100644 --- a/packages/cli/src/i18n/locales/zh.js +++ b/packages/cli/src/i18n/locales/zh.js @@ -300,6 +300,7 @@ export default { 'Tool Output Truncation Lines': '工具输出截断行数', 'Folder Trust': '文件夹信任', 'Vision Model Preview': '视觉模型预览', + 'Tool Schema Compliance': '工具 Schema 兼容性', // Settings enum options 'Auto (detect from system)': '自动(从系统检测)', Text: '文本', @@ -601,8 +602,8 @@ export default { 'The /directory add command is not supported in restrictive sandbox profiles. Please use --include-directories when starting the session instead.': '/directory add 命令在限制性沙箱配置文件中不受支持。请改为在启动会话时使用 --include-directories。', "Error adding '{{path}}': {{error}}": "添加 '{{path}}' 时出错:{{error}}", - 'Successfully added GEMINI.md files from the following directories if there are:\n- {{directories}}': - '如果存在,已成功从以下目录添加 GEMINI.md 文件:\n- {{directories}}', + 'Successfully added QWEN.md files from the following directories if there are:\n- {{directories}}': + '如果存在,已成功从以下目录添加 QWEN.md 文件:\n- {{directories}}', 'Error refreshing memory: {{error}}': '刷新内存时出错:{{error}}', 'Successfully added directories:\n- {{directories}}': '成功添加目录:\n- {{directories}}', @@ -820,6 +821,7 @@ export default { // Exit Screen / Stats // ============================================================================ 'Agent powering down. Goodbye!': 'Qwen Code 正在关闭,再见!', + 'To continue this session, run': '要继续此会话,请运行', 'Interaction Summary': '交互摘要', 'Session ID:': '会话 ID:', 'Tool Calls:': '工具调用:', diff --git a/packages/cli/src/nonInteractive/io/BaseJsonOutputAdapter.ts b/packages/cli/src/nonInteractive/io/BaseJsonOutputAdapter.ts index 915fb7213..ef6655370 100644 --- a/packages/cli/src/nonInteractive/io/BaseJsonOutputAdapter.ts +++ b/packages/cli/src/nonInteractive/io/BaseJsonOutputAdapter.ts @@ -610,8 +610,6 @@ export abstract class BaseJsonOutputAdapter { const errorText = parseAndFormatApiError( event.value.error, this.config.getContentGeneratorConfig()?.authType, - undefined, - this.config.getModel(), ); this.appendText(state, errorText, null); break; diff --git a/packages/cli/src/nonInteractiveCli.ts b/packages/cli/src/nonInteractiveCli.ts index 1614c304b..1719a0532 100644 --- a/packages/cli/src/nonInteractiveCli.ts +++ b/packages/cli/src/nonInteractiveCli.ts @@ -221,8 +221,6 @@ export async function runNonInteractive( const errorText = parseAndFormatApiError( event.value.error, config.getContentGeneratorConfig()?.authType, - undefined, - config.getModel(), ); process.stderr.write(`${errorText}\n`); } diff --git a/packages/cli/src/services/BuiltinCommandLoader.test.ts b/packages/cli/src/services/BuiltinCommandLoader.test.ts index 9d649b2f4..7c8e6fc57 100644 --- a/packages/cli/src/services/BuiltinCommandLoader.test.ts +++ b/packages/cli/src/services/BuiltinCommandLoader.test.ts @@ -58,7 +58,6 @@ vi.mock('../ui/commands/authCommand.js', () => ({ authCommand: {} })); vi.mock('../ui/commands/bugCommand.js', () => ({ bugCommand: {} })); vi.mock('../ui/commands/clearCommand.js', () => ({ clearCommand: {} })); vi.mock('../ui/commands/compressCommand.js', () => ({ compressCommand: {} })); -vi.mock('../ui/commands/corgiCommand.js', () => ({ corgiCommand: {} })); vi.mock('../ui/commands/docsCommand.js', () => ({ docsCommand: {} })); vi.mock('../ui/commands/editorCommand.js', () => ({ editorCommand: {} })); vi.mock('../ui/commands/extensionsCommand.js', () => ({ diff --git a/packages/cli/src/services/BuiltinCommandLoader.ts b/packages/cli/src/services/BuiltinCommandLoader.ts index 100fbef97..c9fc5801a 100644 --- a/packages/cli/src/services/BuiltinCommandLoader.ts +++ b/packages/cli/src/services/BuiltinCommandLoader.ts @@ -15,7 +15,6 @@ import { bugCommand } from '../ui/commands/bugCommand.js'; import { clearCommand } from '../ui/commands/clearCommand.js'; import { compressCommand } from '../ui/commands/compressCommand.js'; import { copyCommand } from '../ui/commands/copyCommand.js'; -import { corgiCommand } from '../ui/commands/corgiCommand.js'; import { docsCommand } from '../ui/commands/docsCommand.js'; import { directoryCommand } from '../ui/commands/directoryCommand.js'; import { editorCommand } from '../ui/commands/editorCommand.js'; @@ -30,6 +29,7 @@ import { modelCommand } from '../ui/commands/modelCommand.js'; import { permissionsCommand } from '../ui/commands/permissionsCommand.js'; import { quitCommand } from '../ui/commands/quitCommand.js'; import { restoreCommand } from '../ui/commands/restoreCommand.js'; +import { resumeCommand } from '../ui/commands/resumeCommand.js'; import { settingsCommand } from '../ui/commands/settingsCommand.js'; import { statsCommand } from '../ui/commands/statsCommand.js'; import { summaryCommand } from '../ui/commands/summaryCommand.js'; @@ -63,7 +63,6 @@ export class BuiltinCommandLoader implements ICommandLoader { clearCommand, compressCommand, copyCommand, - corgiCommand, docsCommand, directoryCommand, editorCommand, @@ -78,6 +77,7 @@ export class BuiltinCommandLoader implements ICommandLoader { ...(this.config?.getFolderTrust() ? [permissionsCommand] : []), quitCommand, restoreCommand(this.config), + resumeCommand, statsCommand, summaryCommand, themeCommand, diff --git a/packages/cli/src/services/McpPromptLoader.test.ts b/packages/cli/src/services/McpPromptLoader.test.ts index c58c3daaa..3e42fb827 100644 --- a/packages/cli/src/services/McpPromptLoader.test.ts +++ b/packages/cli/src/services/McpPromptLoader.test.ts @@ -28,7 +28,7 @@ const mockPrompt = { { name: 'trail', required: false, description: "The animal's trail." }, ], invoke: vi.fn().mockResolvedValue({ - messages: [{ content: { text: 'Hello, world!' } }], + messages: [{ content: { type: 'text', text: 'Hello, world!' } }], }), }; diff --git a/packages/cli/src/services/McpPromptLoader.ts b/packages/cli/src/services/McpPromptLoader.ts index 47858a13e..36da96d6c 100644 --- a/packages/cli/src/services/McpPromptLoader.ts +++ b/packages/cli/src/services/McpPromptLoader.ts @@ -123,7 +123,10 @@ export class McpPromptLoader implements ICommandLoader { }; } - if (!result.messages?.[0]?.content?.['text']) { + const firstMessage = result.messages?.[0]; + const content = firstMessage?.content; + + if (content?.type !== 'text') { return { type: 'message', messageType: 'error', @@ -134,7 +137,7 @@ export class McpPromptLoader implements ICommandLoader { return { type: 'submit_prompt', - content: JSON.stringify(result.messages[0].content.text), + content: JSON.stringify(content.text), }; } catch (error) { return { diff --git a/packages/cli/src/test-utils/mockCommandContext.ts b/packages/cli/src/test-utils/mockCommandContext.ts index 59f26cf28..fd825b9df 100644 --- a/packages/cli/src/test-utils/mockCommandContext.ts +++ b/packages/cli/src/test-utils/mockCommandContext.ts @@ -56,10 +56,10 @@ export const createMockCommandContext = ( pendingItem: null, setPendingItem: vi.fn(), loadHistory: vi.fn(), - toggleCorgiMode: vi.fn(), toggleVimEnabled: vi.fn(), extensionsUpdateState: new Map(), setExtensionsUpdateState: vi.fn(), + reloadCommands: vi.fn(), // eslint-disable-next-line @typescript-eslint/no-explicit-any } as any, session: { diff --git a/packages/cli/src/ui/AppContainer.test.tsx b/packages/cli/src/ui/AppContainer.test.tsx index 0abb960c0..60426f1dd 100644 --- a/packages/cli/src/ui/AppContainer.test.tsx +++ b/packages/cli/src/ui/AppContainer.test.tsx @@ -23,7 +23,6 @@ import { } from '@qwen-code/qwen-code-core'; import type { LoadedSettings } from '../config/settings.js'; import type { InitializationResult } from '../core/initializer.js'; -import { useQuotaAndFallback } from './hooks/useQuotaAndFallback.js'; import { UIStateContext, type UIState } from './contexts/UIStateContext.js'; import { UIActionsContext, @@ -56,7 +55,6 @@ vi.mock('./App.js', () => ({ App: TestContextConsumer, })); -vi.mock('./hooks/useQuotaAndFallback.js'); vi.mock('./hooks/useHistoryManager.js'); vi.mock('./hooks/useThemeCommand.js'); vi.mock('./auth/useAuth.js'); @@ -122,7 +120,6 @@ describe('AppContainer State Management', () => { let mockInitResult: InitializationResult; // Create typed mocks for all hooks - const mockedUseQuotaAndFallback = useQuotaAndFallback as Mock; const mockedUseHistory = useHistory as Mock; const mockedUseThemeCommand = useThemeCommand as Mock; const mockedUseAuthCommand = useAuthCommand as Mock; @@ -164,10 +161,6 @@ describe('AppContainer State Management', () => { capturedUIActions = null!; // **Provide a default return value for EVERY mocked hook.** - mockedUseQuotaAndFallback.mockReturnValue({ - proQuotaRequest: null, - handleProQuotaChoice: vi.fn(), - }); mockedUseHistory.mockReturnValue({ history: [], addItem: vi.fn(), @@ -567,75 +560,6 @@ describe('AppContainer State Management', () => { }); }); - describe('Quota and Fallback Integration', () => { - it('passes a null proQuotaRequest to UIStateContext by default', () => { - // The default mock from beforeEach already sets proQuotaRequest to null - render( - , - ); - - // Assert that the context value is as expected - expect(capturedUIState.proQuotaRequest).toBeNull(); - }); - - it('passes a valid proQuotaRequest to UIStateContext when provided by the hook', () => { - // Arrange: Create a mock request object that a UI dialog would receive - const mockRequest = { - failedModel: 'gemini-pro', - fallbackModel: 'gemini-flash', - resolve: vi.fn(), - }; - mockedUseQuotaAndFallback.mockReturnValue({ - proQuotaRequest: mockRequest, - handleProQuotaChoice: vi.fn(), - }); - - // Act: Render the container - render( - , - ); - - // Assert: The mock request is correctly passed through the context - expect(capturedUIState.proQuotaRequest).toEqual(mockRequest); - }); - - it('passes the handleProQuotaChoice function to UIActionsContext', () => { - // Arrange: Create a mock handler function - const mockHandler = vi.fn(); - mockedUseQuotaAndFallback.mockReturnValue({ - proQuotaRequest: null, - handleProQuotaChoice: mockHandler, - }); - - // Act: Render the container - render( - , - ); - - // Assert: The action in the context is the mock handler we provided - expect(capturedUIActions.handleProQuotaChoice).toBe(mockHandler); - - // You can even verify that the plumbed function is callable - capturedUIActions.handleProQuotaChoice('auth'); - expect(mockHandler).toHaveBeenCalledWith('auth'); - }); - }); - describe('Terminal Title Update Feature', () => { beforeEach(() => { // Reset mock stdout for each test diff --git a/packages/cli/src/ui/AppContainer.tsx b/packages/cli/src/ui/AppContainer.tsx index edda3d4d7..7921b2039 100644 --- a/packages/cli/src/ui/AppContainer.tsx +++ b/packages/cli/src/ui/AppContainer.tsx @@ -32,7 +32,6 @@ import { type Config, type IdeInfo, type IdeContext, - type UserTierId, DEFAULT_GEMINI_FLASH_MODEL, IdeClient, ideContextStore, @@ -48,11 +47,11 @@ import { useHistory } from './hooks/useHistoryManager.js'; import { useMemoryMonitor } from './hooks/useMemoryMonitor.js'; import { useThemeCommand } from './hooks/useThemeCommand.js'; import { useAuthCommand } from './auth/useAuth.js'; -import { useQuotaAndFallback } from './hooks/useQuotaAndFallback.js'; import { useEditorSettings } from './hooks/useEditorSettings.js'; import { useSettingsCommand } from './hooks/useSettingsCommand.js'; import { useModelCommand } from './hooks/useModelCommand.js'; import { useApprovalModeCommand } from './hooks/useApprovalModeCommand.js'; +import { useResumeCommand } from './hooks/useResumeCommand.js'; import { useSlashCommandProcessor } from './hooks/slashCommandProcessor.js'; import { useVimMode } from './contexts/VimModeContext.js'; import { useConsoleMessages } from './hooks/useConsoleMessages.js'; @@ -136,7 +135,6 @@ export const AppContainer = (props: AppContainerProps) => { const { settings, config, initializationResult } = props; const historyManager = useHistory(); useMemoryMonitor(historyManager); - const [corgiMode, setCorgiMode] = useState(false); const [debugMessage, setDebugMessage] = useState(''); const [quittingMessages, setQuittingMessages] = useState< HistoryItem[] | null @@ -192,8 +190,6 @@ export const AppContainer = (props: AppContainerProps) => { const [currentModel, setCurrentModel] = useState(getEffectiveModel()); - const [userTier] = useState(undefined); - const [isConfigInitialized, setConfigInitialized] = useState(false); const [userMessages, setUserMessages] = useState([]); @@ -204,7 +200,7 @@ export const AppContainer = (props: AppContainerProps) => { const { stdout } = useStdout(); // Additional hooks moved from App.tsx - const { stats: sessionStats } = useSessionStats(); + const { stats: sessionStats, startNewSession } = useSessionStats(); const logger = useLogger(config.storage, sessionStats.sessionId); const branchName = useGitBranchName(config.getTargetDir()); @@ -367,14 +363,6 @@ export const AppContainer = (props: AppContainerProps) => { cancelAuthentication, } = useAuthCommand(settings, config, historyManager.addItem); - const { proQuotaRequest, handleProQuotaChoice } = useQuotaAndFallback({ - config, - historyManager, - userTier, - setAuthState, - setModelSwitchedFromQuotaError, - }); - useInitializationAuthError(initializationResult.authError, onAuthError); // Sync user tier from config when authentication changes @@ -436,6 +424,18 @@ export const AppContainer = (props: AppContainerProps) => { const { isModelDialogOpen, openModelDialog, closeModelDialog } = useModelCommand(); + const { + isResumeDialogOpen, + openResumeDialog, + closeResumeDialog, + handleResume, + } = useResumeCommand({ + config, + historyManager, + startNewSession, + remount: refreshStatic, + }); + const { showWorkspaceMigrationDialog, workspaceExtensions, @@ -485,11 +485,11 @@ export const AppContainer = (props: AppContainerProps) => { }, 100); }, setDebugMessage, - toggleCorgiMode: () => setCorgiMode((prev) => !prev), dispatchExtensionStateUpdate, addConfirmUpdateExtensionRequest, openSubagentCreateDialog, openAgentsManagerDialog, + openResumeDialog, }), [ openAuthDialog, @@ -498,13 +498,13 @@ export const AppContainer = (props: AppContainerProps) => { openSettingsDialog, openModelDialog, setDebugMessage, - setCorgiMode, dispatchExtensionStateUpdate, openPermissionsDialog, openApprovalModeDialog, addConfirmUpdateExtensionRequest, openSubagentCreateDialog, openAgentsManagerDialog, + openResumeDialog, ], ); @@ -740,8 +740,7 @@ export const AppContainer = (props: AppContainerProps) => { !initError && !isProcessing && (streamingState === StreamingState.Idle || - streamingState === StreamingState.Responding) && - !proQuotaRequest; + streamingState === StreamingState.Responding); const [controlsHeight, setControlsHeight] = useState(0); @@ -945,6 +944,7 @@ export const AppContainer = (props: AppContainerProps) => { isFocused, streamingState, elapsedTime, + settings, }); // Dialog close functionality @@ -1193,10 +1193,10 @@ export const AppContainer = (props: AppContainerProps) => { isAuthenticating || isEditorDialogOpen || showIdeRestartPrompt || - !!proQuotaRequest || isSubagentCreateDialogOpen || isAgentsManagerDialogOpen || - isApprovalModeDialogOpen; + isApprovalModeDialogOpen || + isResumeDialogOpen; const pendingHistoryItems = useMemo( () => [...pendingSlashCommandHistoryItems, ...pendingGeminiHistoryItems], @@ -1218,13 +1218,13 @@ export const AppContainer = (props: AppContainerProps) => { qwenAuthState, editorError, isEditorDialogOpen, - corgiMode, debugMessage, quittingMessages, isSettingsDialogOpen, isModelDialogOpen, isPermissionsDialogOpen, isApprovalModeDialogOpen, + isResumeDialogOpen, slashCommands, pendingSlashCommandHistoryItems, commandContext, @@ -1263,8 +1263,6 @@ export const AppContainer = (props: AppContainerProps) => { showWorkspaceMigrationDialog, workspaceExtensions, currentModel, - userTier, - proQuotaRequest, contextFileNames, errorCount, availableTerminalHeight, @@ -1309,13 +1307,13 @@ export const AppContainer = (props: AppContainerProps) => { qwenAuthState, editorError, isEditorDialogOpen, - corgiMode, debugMessage, quittingMessages, isSettingsDialogOpen, isModelDialogOpen, isPermissionsDialogOpen, isApprovalModeDialogOpen, + isResumeDialogOpen, slashCommands, pendingSlashCommandHistoryItems, commandContext, @@ -1353,8 +1351,6 @@ export const AppContainer = (props: AppContainerProps) => { showAutoAcceptIndicator, showWorkspaceMigrationDialog, workspaceExtensions, - userTier, - proQuotaRequest, contextFileNames, errorCount, availableTerminalHeight, @@ -1416,7 +1412,6 @@ export const AppContainer = (props: AppContainerProps) => { handleClearScreen, onWorkspaceMigrationDialogOpen, onWorkspaceMigrationDialogClose, - handleProQuotaChoice, // Vision switch dialog handleVisionSwitchSelect, // Welcome back dialog @@ -1425,6 +1420,10 @@ export const AppContainer = (props: AppContainerProps) => { // Subagent dialogs closeSubagentCreateDialog, closeAgentsManagerDialog, + // Resume session dialog + openResumeDialog, + closeResumeDialog, + handleResume, }), [ handleThemeSelect, @@ -1450,13 +1449,16 @@ export const AppContainer = (props: AppContainerProps) => { handleClearScreen, onWorkspaceMigrationDialogOpen, onWorkspaceMigrationDialogClose, - handleProQuotaChoice, handleVisionSwitchSelect, handleWelcomeBackSelection, handleWelcomeBackClose, // Subagent dialogs closeSubagentCreateDialog, closeAgentsManagerDialog, + // Resume session dialog + openResumeDialog, + closeResumeDialog, + handleResume, ], ); diff --git a/packages/cli/src/ui/auth/AuthDialog.test.tsx b/packages/cli/src/ui/auth/AuthDialog.test.tsx index 343989417..0b99eed98 100644 --- a/packages/cli/src/ui/auth/AuthDialog.test.tsx +++ b/packages/cli/src/ui/auth/AuthDialog.test.tsx @@ -168,7 +168,7 @@ describe('AuthDialog', () => { it('should not show the GEMINI_API_KEY message if QWEN_DEFAULT_AUTH_TYPE is set to something else', () => { process.env['GEMINI_API_KEY'] = 'foobar'; - process.env['QWEN_DEFAULT_AUTH_TYPE'] = AuthType.LOGIN_WITH_GOOGLE; + process.env['QWEN_DEFAULT_AUTH_TYPE'] = AuthType.USE_OPENAI; const settings: LoadedSettings = new LoadedSettings( { @@ -212,7 +212,7 @@ describe('AuthDialog', () => { it('should show the GEMINI_API_KEY message if QWEN_DEFAULT_AUTH_TYPE is set to use api key', () => { process.env['GEMINI_API_KEY'] = 'foobar'; - process.env['QWEN_DEFAULT_AUTH_TYPE'] = AuthType.USE_GEMINI; + process.env['QWEN_DEFAULT_AUTH_TYPE'] = AuthType.USE_OPENAI; const settings: LoadedSettings = new LoadedSettings( { @@ -504,12 +504,12 @@ describe('AuthDialog', () => { }, { settings: { - security: { auth: { selectedType: AuthType.LOGIN_WITH_GOOGLE } }, + security: { auth: { selectedType: AuthType.USE_OPENAI } }, ui: { customThemes: {} }, mcpServers: {}, }, originalSettings: { - security: { auth: { selectedType: AuthType.LOGIN_WITH_GOOGLE } }, + security: { auth: { selectedType: AuthType.USE_OPENAI } }, ui: { customThemes: {} }, mcpServers: {}, }, diff --git a/packages/cli/src/ui/auth/useAuth.ts b/packages/cli/src/ui/auth/useAuth.ts index d23696900..c13f33c95 100644 --- a/packages/cli/src/ui/auth/useAuth.ts +++ b/packages/cli/src/ui/auth/useAuth.ts @@ -225,16 +225,26 @@ export const useAuthCommand = ( const defaultAuthType = process.env['QWEN_DEFAULT_AUTH_TYPE']; if ( defaultAuthType && - ![AuthType.QWEN_OAUTH, AuthType.USE_OPENAI].includes( - defaultAuthType as AuthType, - ) + ![ + AuthType.QWEN_OAUTH, + AuthType.USE_OPENAI, + AuthType.USE_ANTHROPIC, + AuthType.USE_GEMINI, + AuthType.USE_VERTEX_AI, + ].includes(defaultAuthType as AuthType) ) { onAuthError( t( 'Invalid QWEN_DEFAULT_AUTH_TYPE value: "{{value}}". Valid values are: {{validValues}}', { value: defaultAuthType, - validValues: [AuthType.QWEN_OAUTH, AuthType.USE_OPENAI].join(', '), + validValues: [ + AuthType.QWEN_OAUTH, + AuthType.USE_OPENAI, + AuthType.USE_ANTHROPIC, + AuthType.USE_GEMINI, + AuthType.USE_VERTEX_AI, + ].join(', '), }, ), ); diff --git a/packages/cli/src/ui/commands/corgiCommand.test.ts b/packages/cli/src/ui/commands/corgiCommand.test.ts deleted file mode 100644 index 3c25e8cd0..000000000 --- a/packages/cli/src/ui/commands/corgiCommand.test.ts +++ /dev/null @@ -1,34 +0,0 @@ -/** - * @license - * Copyright 2025 Google LLC - * SPDX-License-Identifier: Apache-2.0 - */ - -import { describe, it, expect, beforeEach, vi } from 'vitest'; -import { corgiCommand } from './corgiCommand.js'; -import { type CommandContext } from './types.js'; -import { createMockCommandContext } from '../../test-utils/mockCommandContext.js'; - -describe('corgiCommand', () => { - let mockContext: CommandContext; - - beforeEach(() => { - mockContext = createMockCommandContext(); - vi.spyOn(mockContext.ui, 'toggleCorgiMode'); - }); - - it('should call the toggleCorgiMode function on the UI context', async () => { - if (!corgiCommand.action) { - throw new Error('The corgi command must have an action.'); - } - - await corgiCommand.action(mockContext, ''); - - expect(mockContext.ui.toggleCorgiMode).toHaveBeenCalledTimes(1); - }); - - it('should have the correct name and description', () => { - expect(corgiCommand.name).toBe('corgi'); - expect(corgiCommand.description).toBe('Toggles corgi mode.'); - }); -}); diff --git a/packages/cli/src/ui/commands/corgiCommand.ts b/packages/cli/src/ui/commands/corgiCommand.ts deleted file mode 100644 index 2da6ad3ed..000000000 --- a/packages/cli/src/ui/commands/corgiCommand.ts +++ /dev/null @@ -1,17 +0,0 @@ -/** - * @license - * Copyright 2025 Google LLC - * SPDX-License-Identifier: Apache-2.0 - */ - -import { CommandKind, type SlashCommand } from './types.js'; - -export const corgiCommand: SlashCommand = { - name: 'corgi', - description: 'Toggles corgi mode.', - hidden: true, - kind: CommandKind.BUILT_IN, - action: (context, _args) => { - context.ui.toggleCorgiMode(); - }, -}; diff --git a/packages/cli/src/ui/commands/directoryCommand.tsx b/packages/cli/src/ui/commands/directoryCommand.tsx index e44530b78..536cc9bbf 100644 --- a/packages/cli/src/ui/commands/directoryCommand.tsx +++ b/packages/cli/src/ui/commands/directoryCommand.tsx @@ -130,7 +130,7 @@ export const directoryCommand: SlashCommand = { { type: MessageType.INFO, text: t( - 'Successfully added GEMINI.md files from the following directories if there are:\n- {{directories}}', + 'Successfully added QWEN.md files from the following directories if there are:\n- {{directories}}', { directories: added.join('\n- '), }, diff --git a/packages/cli/src/ui/commands/ideCommand.test.ts b/packages/cli/src/ui/commands/ideCommand.test.ts index a2a4539dc..1a5d493e0 100644 --- a/packages/cli/src/ui/commands/ideCommand.test.ts +++ b/packages/cli/src/ui/commands/ideCommand.test.ts @@ -15,7 +15,6 @@ vi.mock('@qwen-code/qwen-code-core', async (importOriginal) => { const original = await importOriginal(); return { ...original, - getOauthClient: vi.fn(original.getOauthClient), getIdeInstaller: vi.fn(original.getIdeInstaller), IdeClient: { getInstance: vi.fn(), diff --git a/packages/cli/src/ui/commands/languageCommand.test.ts b/packages/cli/src/ui/commands/languageCommand.test.ts new file mode 100644 index 000000000..5a4f395bd --- /dev/null +++ b/packages/cli/src/ui/commands/languageCommand.test.ts @@ -0,0 +1,600 @@ +/** + * @license + * Copyright 2025 Qwen + * SPDX-License-Identifier: Apache-2.0 + */ + +import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; +import * as fs from 'node:fs'; +import { type CommandContext, CommandKind } from './types.js'; +import { createMockCommandContext } from '../../test-utils/mockCommandContext.js'; + +// Mock i18n module +vi.mock('../../i18n/index.js', () => ({ + setLanguageAsync: vi.fn().mockResolvedValue(undefined), + getCurrentLanguage: vi.fn().mockReturnValue('en'), + t: vi.fn((key: string) => key), +})); + +// Mock settings module to avoid Storage side effect +vi.mock('../../config/settings.js', () => ({ + SettingScope: { + User: 'user', + Workspace: 'workspace', + Default: 'default', + }, +})); + +// Mock fs module +vi.mock('node:fs', async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + existsSync: vi.fn(), + readFileSync: vi.fn(), + writeFileSync: vi.fn(), + mkdirSync: vi.fn(), + default: { + ...actual, + existsSync: vi.fn(), + readFileSync: vi.fn(), + writeFileSync: vi.fn(), + mkdirSync: vi.fn(), + }, + }; +}); + +// Mock Storage from core +vi.mock('@qwen-code/qwen-code-core', async (importOriginal) => { + const actual = + await importOriginal(); + return { + ...actual, + Storage: { + getGlobalQwenDir: vi.fn().mockReturnValue('/mock/.qwen'), + getGlobalSettingsPath: vi + .fn() + .mockReturnValue('/mock/.qwen/settings.json'), + }, + }; +}); + +// Import modules after mocking +import * as i18n from '../../i18n/index.js'; +import { languageCommand } from './languageCommand.js'; + +describe('languageCommand', () => { + let mockContext: CommandContext; + + beforeEach(() => { + vi.clearAllMocks(); + mockContext = createMockCommandContext({ + services: { + config: { + getModel: vi.fn().mockReturnValue('test-model'), + }, + settings: { + merged: {}, + setValue: vi.fn(), + }, + }, + }); + + // Reset i18n mocks + vi.mocked(i18n.getCurrentLanguage).mockReturnValue('en'); + vi.mocked(i18n.t).mockImplementation((key: string) => key); + + // Reset fs mocks + vi.mocked(fs.existsSync).mockReturnValue(false); + }); + + afterEach(() => { + vi.clearAllMocks(); + }); + + describe('command metadata', () => { + it('should have the correct name', () => { + expect(languageCommand.name).toBe('language'); + }); + + it('should have a description', () => { + expect(languageCommand.description).toBeDefined(); + expect(typeof languageCommand.description).toBe('string'); + }); + + it('should be a built-in command', () => { + expect(languageCommand.kind).toBe(CommandKind.BUILT_IN); + }); + + it('should have subcommands', () => { + expect(languageCommand.subCommands).toBeDefined(); + expect(languageCommand.subCommands?.length).toBe(2); + }); + + it('should have ui and output subcommands', () => { + const subCommandNames = languageCommand.subCommands?.map((c) => c.name); + expect(subCommandNames).toContain('ui'); + expect(subCommandNames).toContain('output'); + }); + }); + + describe('main command action - no arguments', () => { + it('should show current language settings when no arguments provided', async () => { + if (!languageCommand.action) { + throw new Error('The language command must have an action.'); + } + + const result = await languageCommand.action(mockContext, ''); + + expect(result).toEqual({ + type: 'message', + messageType: 'info', + content: expect.stringContaining('Current UI language:'), + }); + }); + + it('should show available subcommands in help', async () => { + if (!languageCommand.action) { + throw new Error('The language command must have an action.'); + } + + const result = await languageCommand.action(mockContext, ''); + + expect(result).toEqual({ + type: 'message', + messageType: 'info', + content: expect.stringContaining('/language ui'), + }); + expect(result).toEqual({ + type: 'message', + messageType: 'info', + content: expect.stringContaining('/language output'), + }); + }); + + it('should show LLM output language when set', async () => { + vi.mocked(fs.existsSync).mockReturnValue(true); + vi.mocked(fs.readFileSync).mockReturnValue( + '# CRITICAL: Chinese Output Language Rule - HIGHEST PRIORITY', + ); + + // Make t() function handle interpolation for this test + vi.mocked(i18n.t).mockImplementation( + (key: string, params?: Record) => { + if (params && key.includes('{{lang}}')) { + return key.replace('{{lang}}', params['lang'] || ''); + } + return key; + }, + ); + + if (!languageCommand.action) { + throw new Error('The language command must have an action.'); + } + + const result = await languageCommand.action(mockContext, ''); + + expect(result).toEqual({ + type: 'message', + messageType: 'info', + content: expect.stringContaining('Current UI language:'), + }); + // Verify it correctly parses "Chinese" from the template format + expect(result).toEqual({ + type: 'message', + messageType: 'info', + content: expect.stringContaining('Chinese'), + }); + }); + }); + + describe('main command action - config not available', () => { + it('should return error when config is null', async () => { + mockContext.services.config = null; + + if (!languageCommand.action) { + throw new Error('The language command must have an action.'); + } + + const result = await languageCommand.action(mockContext, ''); + + expect(result).toEqual({ + type: 'message', + messageType: 'error', + content: expect.stringContaining('Configuration not available'), + }); + }); + }); + + describe('/language ui subcommand', () => { + it('should show help when no language argument provided', async () => { + if (!languageCommand.action) { + throw new Error('The language command must have an action.'); + } + + const result = await languageCommand.action(mockContext, 'ui'); + + expect(result).toEqual({ + type: 'message', + messageType: 'info', + content: expect.stringContaining('Usage: /language ui'), + }); + }); + + it('should set English with "en"', async () => { + if (!languageCommand.action) { + throw new Error('The language command must have an action.'); + } + + const result = await languageCommand.action(mockContext, 'ui en'); + + expect(i18n.setLanguageAsync).toHaveBeenCalledWith('en'); + expect(mockContext.services.settings.setValue).toHaveBeenCalled(); + expect(mockContext.ui.reloadCommands).toHaveBeenCalled(); + expect(result).toEqual({ + type: 'message', + messageType: 'info', + content: expect.stringContaining('UI language changed'), + }); + }); + + it('should set English with "en-US"', async () => { + if (!languageCommand.action) { + throw new Error('The language command must have an action.'); + } + + const result = await languageCommand.action(mockContext, 'ui en-US'); + + expect(i18n.setLanguageAsync).toHaveBeenCalledWith('en'); + expect(result).toEqual({ + type: 'message', + messageType: 'info', + content: expect.stringContaining('UI language changed'), + }); + }); + + it('should set English with "english"', async () => { + if (!languageCommand.action) { + throw new Error('The language command must have an action.'); + } + + const result = await languageCommand.action(mockContext, 'ui english'); + + expect(i18n.setLanguageAsync).toHaveBeenCalledWith('en'); + expect(result).toEqual({ + type: 'message', + messageType: 'info', + content: expect.stringContaining('UI language changed'), + }); + }); + + it('should set Chinese with "zh"', async () => { + if (!languageCommand.action) { + throw new Error('The language command must have an action.'); + } + + const result = await languageCommand.action(mockContext, 'ui zh'); + + expect(i18n.setLanguageAsync).toHaveBeenCalledWith('zh'); + expect(result).toEqual({ + type: 'message', + messageType: 'info', + content: expect.stringContaining('UI language changed'), + }); + }); + + it('should set Chinese with "zh-CN"', async () => { + if (!languageCommand.action) { + throw new Error('The language command must have an action.'); + } + + const result = await languageCommand.action(mockContext, 'ui zh-CN'); + + expect(i18n.setLanguageAsync).toHaveBeenCalledWith('zh'); + expect(result).toEqual({ + type: 'message', + messageType: 'info', + content: expect.stringContaining('UI language changed'), + }); + }); + + it('should set Chinese with "chinese"', async () => { + if (!languageCommand.action) { + throw new Error('The language command must have an action.'); + } + + const result = await languageCommand.action(mockContext, 'ui chinese'); + + expect(i18n.setLanguageAsync).toHaveBeenCalledWith('zh'); + expect(result).toEqual({ + type: 'message', + messageType: 'info', + content: expect.stringContaining('UI language changed'), + }); + }); + + it('should return error for invalid language', async () => { + if (!languageCommand.action) { + throw new Error('The language command must have an action.'); + } + + const result = await languageCommand.action(mockContext, 'ui invalid'); + + expect(i18n.setLanguageAsync).not.toHaveBeenCalled(); + expect(result).toEqual({ + type: 'message', + messageType: 'error', + content: expect.stringContaining('Invalid language'), + }); + }); + + it('should persist setting to user scope', async () => { + if (!languageCommand.action) { + throw new Error('The language command must have an action.'); + } + + await languageCommand.action(mockContext, 'ui en'); + + expect(mockContext.services.settings.setValue).toHaveBeenCalledWith( + expect.anything(), // SettingScope.User + 'general.language', + 'en', + ); + }); + }); + + describe('/language output subcommand', () => { + it('should show help when no language argument provided', async () => { + if (!languageCommand.action) { + throw new Error('The language command must have an action.'); + } + + const result = await languageCommand.action(mockContext, 'output'); + + expect(result).toEqual({ + type: 'message', + messageType: 'info', + content: expect.stringContaining('Usage: /language output'), + }); + }); + + it('should create LLM output language rule file', async () => { + if (!languageCommand.action) { + throw new Error('The language command must have an action.'); + } + + const result = await languageCommand.action( + mockContext, + 'output Chinese', + ); + + expect(fs.mkdirSync).toHaveBeenCalled(); + expect(fs.writeFileSync).toHaveBeenCalledWith( + expect.stringContaining('output-language.md'), + expect.stringContaining('Chinese'), + 'utf-8', + ); + expect(result).toEqual({ + type: 'message', + messageType: 'info', + content: expect.stringContaining( + 'LLM output language rule file generated', + ), + }); + }); + + it('should include restart notice in success message', async () => { + if (!languageCommand.action) { + throw new Error('The language command must have an action.'); + } + + const result = await languageCommand.action( + mockContext, + 'output Japanese', + ); + + expect(result).toEqual({ + type: 'message', + messageType: 'info', + content: expect.stringContaining('restart'), + }); + }); + + it('should handle file write errors gracefully', async () => { + vi.mocked(fs.writeFileSync).mockImplementation(() => { + throw new Error('Permission denied'); + }); + + if (!languageCommand.action) { + throw new Error('The language command must have an action.'); + } + + const result = await languageCommand.action(mockContext, 'output German'); + + expect(result).toEqual({ + type: 'message', + messageType: 'error', + content: expect.stringContaining('Failed to generate'), + }); + }); + }); + + describe('backward compatibility - direct language arguments', () => { + it('should set Chinese with direct "zh" argument', async () => { + if (!languageCommand.action) { + throw new Error('The language command must have an action.'); + } + + const result = await languageCommand.action(mockContext, 'zh'); + + expect(i18n.setLanguageAsync).toHaveBeenCalledWith('zh'); + expect(result).toEqual({ + type: 'message', + messageType: 'info', + content: expect.stringContaining('UI language changed'), + }); + }); + + it('should set English with direct "en" argument', async () => { + if (!languageCommand.action) { + throw new Error('The language command must have an action.'); + } + + const result = await languageCommand.action(mockContext, 'en'); + + expect(i18n.setLanguageAsync).toHaveBeenCalledWith('en'); + expect(result).toEqual({ + type: 'message', + messageType: 'info', + content: expect.stringContaining('UI language changed'), + }); + }); + + it('should return error for unknown direct argument', async () => { + if (!languageCommand.action) { + throw new Error('The language command must have an action.'); + } + + const result = await languageCommand.action(mockContext, 'unknown'); + + expect(i18n.setLanguageAsync).not.toHaveBeenCalled(); + expect(result).toEqual({ + type: 'message', + messageType: 'error', + content: expect.stringContaining('Invalid command'), + }); + }); + }); + + describe('ui subcommand object', () => { + const uiSubcommand = languageCommand.subCommands?.find( + (c) => c.name === 'ui', + ); + + it('should have correct metadata', () => { + expect(uiSubcommand).toBeDefined(); + expect(uiSubcommand?.name).toBe('ui'); + expect(uiSubcommand?.kind).toBe(CommandKind.BUILT_IN); + }); + + it('should have nested language subcommands', () => { + const nestedNames = uiSubcommand?.subCommands?.map((c) => c.name); + expect(nestedNames).toContain('zh-CN'); + expect(nestedNames).toContain('en-US'); + }); + + it('should have action that sets language', async () => { + if (!uiSubcommand?.action) { + throw new Error('UI subcommand must have an action.'); + } + + const result = await uiSubcommand.action(mockContext, 'en'); + + expect(i18n.setLanguageAsync).toHaveBeenCalledWith('en'); + expect(result).toEqual({ + type: 'message', + messageType: 'info', + content: expect.stringContaining('UI language changed'), + }); + }); + }); + + describe('output subcommand object', () => { + const outputSubcommand = languageCommand.subCommands?.find( + (c) => c.name === 'output', + ); + + it('should have correct metadata', () => { + expect(outputSubcommand).toBeDefined(); + expect(outputSubcommand?.name).toBe('output'); + expect(outputSubcommand?.kind).toBe(CommandKind.BUILT_IN); + }); + + it('should have action that generates rule file', async () => { + if (!outputSubcommand?.action) { + throw new Error('Output subcommand must have an action.'); + } + + // Ensure mocks are properly set for this test + vi.mocked(fs.mkdirSync).mockImplementation(() => undefined); + vi.mocked(fs.writeFileSync).mockImplementation(() => undefined); + + const result = await outputSubcommand.action(mockContext, 'French'); + + expect(fs.writeFileSync).toHaveBeenCalled(); + expect(result).toEqual({ + type: 'message', + messageType: 'info', + content: expect.stringContaining( + 'LLM output language rule file generated', + ), + }); + }); + }); + + describe('nested ui language subcommands', () => { + const uiSubcommand = languageCommand.subCommands?.find( + (c) => c.name === 'ui', + ); + const zhCNSubcommand = uiSubcommand?.subCommands?.find( + (c) => c.name === 'zh-CN', + ); + const enUSSubcommand = uiSubcommand?.subCommands?.find( + (c) => c.name === 'en-US', + ); + + it('zh-CN should have aliases', () => { + expect(zhCNSubcommand?.altNames).toContain('zh'); + expect(zhCNSubcommand?.altNames).toContain('chinese'); + }); + + it('en-US should have aliases', () => { + expect(enUSSubcommand?.altNames).toContain('en'); + expect(enUSSubcommand?.altNames).toContain('english'); + }); + + it('zh-CN action should set Chinese', async () => { + if (!zhCNSubcommand?.action) { + throw new Error('zh-CN subcommand must have an action.'); + } + + const result = await zhCNSubcommand.action(mockContext, ''); + + expect(i18n.setLanguageAsync).toHaveBeenCalledWith('zh'); + expect(result).toEqual({ + type: 'message', + messageType: 'info', + content: expect.stringContaining('UI language changed'), + }); + }); + + it('en-US action should set English', async () => { + if (!enUSSubcommand?.action) { + throw new Error('en-US subcommand must have an action.'); + } + + const result = await enUSSubcommand.action(mockContext, ''); + + expect(i18n.setLanguageAsync).toHaveBeenCalledWith('en'); + expect(result).toEqual({ + type: 'message', + messageType: 'info', + content: expect.stringContaining('UI language changed'), + }); + }); + + it('should reject extra arguments', async () => { + if (!zhCNSubcommand?.action) { + throw new Error('zh-CN subcommand must have an action.'); + } + + const result = await zhCNSubcommand.action(mockContext, 'extra args'); + + expect(result).toEqual({ + type: 'message', + messageType: 'error', + content: expect.stringContaining('do not accept additional arguments'), + }); + }); + }); +}); diff --git a/packages/cli/src/ui/commands/languageCommand.ts b/packages/cli/src/ui/commands/languageCommand.ts index ba04920b7..455465abc 100644 --- a/packages/cli/src/ui/commands/languageCommand.ts +++ b/packages/cli/src/ui/commands/languageCommand.ts @@ -81,8 +81,9 @@ function getCurrentLlmOutputLanguage(): string | null { if (fs.existsSync(filePath)) { try { const content = fs.readFileSync(filePath, 'utf-8'); - // Extract language name from the first line (e.g., "# Chinese Response Rules" -> "Chinese") - const match = content.match(/^#\s+(.+?)\s+Response Rules/i); + // Extract language name from the first line + // Template format: "# CRITICAL: Chinese Output Language Rule - HIGHEST PRIORITY" + const match = content.match(/^#.*?(\w+)\s+Output Language Rule/i); if (match) { return match[1]; } @@ -127,16 +128,17 @@ async function setUiLanguage( context.ui.reloadCommands(); // Map language codes to friendly display names - const langDisplayNames: Record = { + const langDisplayNames: Partial> = { zh: '中文(zh-CN)', en: 'English(en-US)', + ru: 'Русский (ru-RU)', }; return { type: 'message', messageType: 'info', content: t('UI language changed to {{lang}}', { - lang: langDisplayNames[lang], + lang: langDisplayNames[lang] || lang, }), }; } @@ -216,7 +218,7 @@ export const languageCommand: SlashCommand = { : t('LLM output language not set'), '', t('Available subcommands:'), - ` /language ui [zh-CN|en-US] - ${t('Set UI language')}`, + ` /language ui [zh-CN|en-US|ru-RU] - ${t('Set UI language')}`, ` /language output - ${t('Set LLM output language')}`, ].join('\n'); @@ -232,7 +234,7 @@ export const languageCommand: SlashCommand = { const subcommand = parts[0].toLowerCase(); if (subcommand === 'ui') { - // Handle /language ui [zh-CN|en-US] + // Handle /language ui [zh-CN|en-US|ru-RU] if (parts.length === 1) { // Show UI language subcommand help return { @@ -241,11 +243,12 @@ export const languageCommand: SlashCommand = { content: [ t('Set UI language'), '', - t('Usage: /language ui [zh-CN|en-US]'), + t('Usage: /language ui [zh-CN|en-US|ru-RU]'), '', t('Available options:'), t(' - zh-CN: Simplified Chinese'), t(' - en-US: English'), + t(' - ru-RU: Russian'), '', t( 'To request additional UI language packs, please open an issue on GitHub.', @@ -266,11 +269,18 @@ export const languageCommand: SlashCommand = { langArg === 'zh-cn' ) { targetLang = 'zh'; + } else if ( + langArg === 'ru' || + langArg === 'ru-RU' || + langArg === 'russian' || + langArg === 'русский' + ) { + targetLang = 'ru'; } else { return { type: 'message', messageType: 'error', - content: t('Invalid language. Available: en-US, zh-CN'), + content: t('Invalid language. Available: en-US, zh-CN, ru-RU'), }; } @@ -307,13 +317,20 @@ export const languageCommand: SlashCommand = { langArg === 'zh-cn' ) { targetLang = 'zh'; + } else if ( + langArg === 'ru' || + langArg === 'ru-RU' || + langArg === 'russian' || + langArg === 'русский' + ) { + targetLang = 'ru'; } else { return { type: 'message', messageType: 'error', content: [ t('Invalid command. Available subcommands:'), - ' - /language ui [zh-CN|en-US] - ' + t('Set UI language'), + ' - /language ui [zh-CN|en-US|ru-RU] - ' + t('Set UI language'), ' - /language output - ' + t('Set LLM output language'), ].join('\n'), }; @@ -423,6 +440,29 @@ export const languageCommand: SlashCommand = { return setUiLanguage(context, 'en'); }, }, + { + name: 'ru-RU', + altNames: ['ru', 'russian', 'русский'], + get description() { + return t('Set UI language to Russian (ru-RU)'); + }, + kind: CommandKind.BUILT_IN, + action: async ( + context: CommandContext, + args: string, + ): Promise => { + if (args.trim().length > 0) { + return { + type: 'message', + messageType: 'error', + content: t( + 'Language subcommands do not accept additional arguments.', + ), + }; + } + return setUiLanguage(context, 'ru'); + }, + }, ], }, { diff --git a/packages/cli/src/ui/commands/restoreCommand.test.ts b/packages/cli/src/ui/commands/restoreCommand.test.ts index a60ee5028..a786c3a7a 100644 --- a/packages/cli/src/ui/commands/restoreCommand.test.ts +++ b/packages/cli/src/ui/commands/restoreCommand.test.ts @@ -89,7 +89,7 @@ describe('restoreCommand', () => { ).toEqual({ type: 'message', messageType: 'error', - content: 'Could not determine the .gemini directory path.', + content: 'Could not determine the .qwen directory path.', }); }); diff --git a/packages/cli/src/ui/commands/restoreCommand.ts b/packages/cli/src/ui/commands/restoreCommand.ts index 5076e5369..fce633275 100644 --- a/packages/cli/src/ui/commands/restoreCommand.ts +++ b/packages/cli/src/ui/commands/restoreCommand.ts @@ -28,7 +28,7 @@ async function restoreAction( return { type: 'message', messageType: 'error', - content: 'Could not determine the .gemini directory path.', + content: 'Could not determine the .qwen directory path.', }; } diff --git a/packages/cli/src/ui/commands/resumeCommand.test.ts b/packages/cli/src/ui/commands/resumeCommand.test.ts new file mode 100644 index 000000000..7fe14ab09 --- /dev/null +++ b/packages/cli/src/ui/commands/resumeCommand.test.ts @@ -0,0 +1,38 @@ +/** + * @license + * Copyright 2025 Qwen Code + * SPDX-License-Identifier: Apache-2.0 + */ + +import { describe, it, expect, beforeEach } from 'vitest'; +import { resumeCommand } from './resumeCommand.js'; +import { type CommandContext } from './types.js'; +import { createMockCommandContext } from '../../test-utils/mockCommandContext.js'; + +describe('resumeCommand', () => { + let mockContext: CommandContext; + + beforeEach(() => { + mockContext = createMockCommandContext(); + }); + + it('should return a dialog action to open the resume dialog', async () => { + // Ensure the command has an action to test. + if (!resumeCommand.action) { + throw new Error('The resume command must have an action.'); + } + + const result = await resumeCommand.action(mockContext, ''); + + // Assert that the action returns the correct object to trigger the resume dialog. + expect(result).toEqual({ + type: 'dialog', + dialog: 'resume', + }); + }); + + it('should have the correct name and description', () => { + expect(resumeCommand.name).toBe('resume'); + expect(resumeCommand.description).toBe('Resume a previous session'); + }); +}); diff --git a/packages/cli/src/ui/commands/resumeCommand.ts b/packages/cli/src/ui/commands/resumeCommand.ts new file mode 100644 index 000000000..20592bf39 --- /dev/null +++ b/packages/cli/src/ui/commands/resumeCommand.ts @@ -0,0 +1,21 @@ +/** + * @license + * Copyright 2025 Qwen Code + * SPDX-License-Identifier: Apache-2.0 + */ + +import type { SlashCommand, SlashCommandActionReturn } from './types.js'; +import { CommandKind } from './types.js'; +import { t } from '../../i18n/index.js'; + +export const resumeCommand: SlashCommand = { + name: 'resume', + kind: CommandKind.BUILT_IN, + get description() { + return t('Resume a previous session'); + }, + action: async (): Promise => ({ + type: 'dialog', + dialog: 'resume', + }), +}; diff --git a/packages/cli/src/ui/commands/setupGithubCommand.test.ts b/packages/cli/src/ui/commands/setupGithubCommand.test.ts index bac020704..6f0faae37 100644 --- a/packages/cli/src/ui/commands/setupGithubCommand.test.ts +++ b/packages/cli/src/ui/commands/setupGithubCommand.test.ts @@ -84,7 +84,7 @@ describe('setupGithubCommand', async () => { const expectedSubstrings = [ `set -eEuo pipefail`, - `fakeOpenCommand "https://github.com/google-github-actions/run-gemini-cli`, + `fakeOpenCommand "https://github.com/QwenLM/qwen-code-action`, ]; for (const substring of expectedSubstrings) { @@ -112,7 +112,7 @@ describe('setupGithubCommand', async () => { if (gitignoreExists) { const gitignoreContent = await fs.readFile(gitignorePath, 'utf8'); - expect(gitignoreContent).toContain('.gemini/'); + expect(gitignoreContent).toContain('.qwen/'); expect(gitignoreContent).toContain('gha-creds-*.json'); } }); @@ -135,7 +135,7 @@ describe('updateGitignore', () => { const gitignorePath = path.join(scratchDir, '.gitignore'); const content = await fs.readFile(gitignorePath, 'utf8'); - expect(content).toBe('.gemini/\ngha-creds-*.json\n'); + expect(content).toBe('.qwen/\ngha-creds-*.json\n'); }); it('appends entries to existing .gitignore file', async () => { @@ -148,13 +148,13 @@ describe('updateGitignore', () => { const content = await fs.readFile(gitignorePath, 'utf8'); expect(content).toBe( - '# Existing content\nnode_modules/\n\n.gemini/\ngha-creds-*.json\n', + '# Existing content\nnode_modules/\n\n.qwen/\ngha-creds-*.json\n', ); }); it('does not add duplicate entries', async () => { const gitignorePath = path.join(scratchDir, '.gitignore'); - const existingContent = '.gemini/\nsome-other-file\ngha-creds-*.json\n'; + const existingContent = '.qwen/\nsome-other-file\ngha-creds-*.json\n'; await fs.writeFile(gitignorePath, existingContent); await updateGitignore(scratchDir); @@ -166,7 +166,7 @@ describe('updateGitignore', () => { it('adds only missing entries when some already exist', async () => { const gitignorePath = path.join(scratchDir, '.gitignore'); - const existingContent = '.gemini/\nsome-other-file\n'; + const existingContent = '.qwen/\nsome-other-file\n'; await fs.writeFile(gitignorePath, existingContent); await updateGitignore(scratchDir); @@ -174,17 +174,17 @@ describe('updateGitignore', () => { const content = await fs.readFile(gitignorePath, 'utf8'); // Should add only the missing gha-creds-*.json entry - expect(content).toBe('.gemini/\nsome-other-file\n\ngha-creds-*.json\n'); + expect(content).toBe('.qwen/\nsome-other-file\n\ngha-creds-*.json\n'); expect(content).toContain('gha-creds-*.json'); - // Should not duplicate .gemini/ entry - expect((content.match(/\.gemini\//g) || []).length).toBe(1); + // Should not duplicate .qwen/ entry + expect((content.match(/\.qwen\//g) || []).length).toBe(1); }); it('does not get confused by entries in comments or as substrings', async () => { const gitignorePath = path.join(scratchDir, '.gitignore'); const existingContent = [ - '# This is a comment mentioning .gemini/ folder', - 'my-app.gemini/config', + '# This is a comment mentioning .qwen/ folder', + 'my-app.qwen/config', '# Another comment with gha-creds-*.json pattern', 'some-other-gha-creds-file.json', '', @@ -196,7 +196,7 @@ describe('updateGitignore', () => { const content = await fs.readFile(gitignorePath, 'utf8'); // Should add both entries since they don't actually exist as gitignore rules - expect(content).toContain('.gemini/'); + expect(content).toContain('.qwen/'); expect(content).toContain('gha-creds-*.json'); // Verify the entries were added (not just mentioned in comments) @@ -204,9 +204,9 @@ describe('updateGitignore', () => { .split('\n') .map((line) => line.split('#')[0].trim()) .filter((line) => line); - expect(lines).toContain('.gemini/'); + expect(lines).toContain('.qwen/'); expect(lines).toContain('gha-creds-*.json'); - expect(lines).toContain('my-app.gemini/config'); + expect(lines).toContain('my-app.qwen/config'); expect(lines).toContain('some-other-gha-creds-file.json'); }); diff --git a/packages/cli/src/ui/commands/setupGithubCommand.ts b/packages/cli/src/ui/commands/setupGithubCommand.ts index 378f1101a..b12268edd 100644 --- a/packages/cli/src/ui/commands/setupGithubCommand.ts +++ b/packages/cli/src/ui/commands/setupGithubCommand.ts @@ -23,11 +23,11 @@ import { getUrlOpenCommand } from '../../ui/utils/commandUtils.js'; import { t } from '../../i18n/index.js'; export const GITHUB_WORKFLOW_PATHS = [ - 'gemini-dispatch/gemini-dispatch.yml', - 'gemini-assistant/gemini-invoke.yml', - 'issue-triage/gemini-triage.yml', - 'issue-triage/gemini-scheduled-triage.yml', - 'pr-review/gemini-review.yml', + 'qwen-dispatch/qwen-dispatch.yml', + 'qwen-assistant/qwen-invoke.yml', + 'issue-triage/qwen-triage.yml', + 'issue-triage/qwen-scheduled-triage.yml', + 'pr-review/qwen-review.yml', ]; // Generate OS-specific commands to open the GitHub pages needed for setup. @@ -50,9 +50,9 @@ function getOpenUrlsCommands(readmeUrl: string): string[] { return commands; } -// Add Gemini CLI specific entries to .gitignore file +// Add Qwen Code specific entries to .gitignore file export async function updateGitignore(gitRepoRoot: string): Promise { - const gitignoreEntries = ['.gemini/', 'gha-creds-*.json']; + const gitignoreEntries = ['.qwen/', 'gha-creds-*.json']; const gitignorePath = path.join(gitRepoRoot, '.gitignore'); try { @@ -121,7 +121,7 @@ export const setupGithubCommand: SlashCommand = { // Get the latest release tag from GitHub const proxy = context?.services?.config?.getProxy(); const releaseTag = await getLatestGitHubRelease(proxy); - const readmeUrl = `https://github.com/google-github-actions/run-gemini-cli/blob/${releaseTag}/README.md#quick-start`; + const readmeUrl = `https://github.com/QwenLM/qwen-code-action/blob/${releaseTag}/README.md#quick-start`; // Create the .github/workflows directory to download the files into const githubWorkflowsDir = path.join(gitRepoRoot, '.github', 'workflows'); @@ -143,7 +143,7 @@ export const setupGithubCommand: SlashCommand = { for (const workflow of GITHUB_WORKFLOW_PATHS) { downloads.push( (async () => { - const endpoint = `https://raw.githubusercontent.com/google-github-actions/run-gemini-cli/refs/tags/${releaseTag}/examples/workflows/${workflow}`; + const endpoint = `https://raw.githubusercontent.com/QwenLM/qwen-code-action/refs/tags/${releaseTag}/examples/workflows/${workflow}`; const response = await fetch(endpoint, { method: 'GET', dispatcher: proxy ? new ProxyAgent(proxy) : undefined, @@ -204,8 +204,9 @@ export const setupGithubCommand: SlashCommand = { toolName: 'run_shell_command', toolArgs: { description: - 'Setting up GitHub Actions to triage issues and review PRs with Gemini.', + 'Setting up GitHub Actions to triage issues and review PRs with Qwen.', command, + is_background: false, }, }; }, diff --git a/packages/cli/src/ui/commands/types.ts b/packages/cli/src/ui/commands/types.ts index a2a352cb8..8bcc872f0 100644 --- a/packages/cli/src/ui/commands/types.ts +++ b/packages/cli/src/ui/commands/types.ts @@ -64,8 +64,6 @@ export interface CommandContext { * @param history The array of history items to load. */ loadHistory: UseHistoryManagerReturn['loadHistory']; - /** Toggles a special display mode. */ - toggleCorgiMode: () => void; toggleVimEnabled: () => Promise; setGeminiMdFileCount: (count: number) => void; reloadCommands: () => void; @@ -126,7 +124,8 @@ export interface OpenDialogActionReturn { | 'subagent_create' | 'subagent_list' | 'permissions' - | 'approval-mode'; + | 'approval-mode' + | 'resume'; } /** diff --git a/packages/cli/src/ui/components/Composer.test.tsx b/packages/cli/src/ui/components/Composer.test.tsx index 084cd7465..d660d7040 100644 --- a/packages/cli/src/ui/components/Composer.test.tsx +++ b/packages/cli/src/ui/components/Composer.test.tsx @@ -120,7 +120,6 @@ const createMockUIState = (overrides: Partial = {}): UIState => }, branchName: 'main', debugMessage: '', - corgiMode: false, errorCount: 0, nightly: false, isTrustedFolder: true, @@ -183,6 +182,7 @@ describe('Composer', () => { const { lastFrame } = renderComposer(uiState, settings); + // Smoke check that the Footer renders when enabled. expect(lastFrame()).toContain('Footer'); }); @@ -200,7 +200,6 @@ describe('Composer', () => { it('passes correct props to Footer including vim mode when enabled', async () => { const uiState = createMockUIState({ branchName: 'feature-branch', - corgiMode: true, errorCount: 2, sessionStats: { sessionId: 'test-session', diff --git a/packages/cli/src/ui/components/DialogManager.tsx b/packages/cli/src/ui/components/DialogManager.tsx index d696c87a0..c3e1a128c 100644 --- a/packages/cli/src/ui/components/DialogManager.tsx +++ b/packages/cli/src/ui/components/DialogManager.tsx @@ -17,7 +17,6 @@ import { AuthDialog } from '../auth/AuthDialog.js'; import { OpenAIKeyPrompt } from './OpenAIKeyPrompt.js'; import { EditorSettingsDialog } from './EditorSettingsDialog.js'; import { WorkspaceMigrationDialog } from './WorkspaceMigrationDialog.js'; -import { ProQuotaDialog } from './ProQuotaDialog.js'; import { PermissionsModifyTrustDialog } from './PermissionsModifyTrustDialog.js'; import { ModelDialog } from './ModelDialog.js'; import { ApprovalModeDialog } from './ApprovalModeDialog.js'; @@ -36,6 +35,7 @@ import { WelcomeBackDialog } from './WelcomeBackDialog.js'; import { ModelSwitchDialog } from './ModelSwitchDialog.js'; import { AgentCreationWizard } from './subagents/create/AgentCreationWizard.js'; import { AgentsManagerDialog } from './subagents/manage/AgentsManagerDialog.js'; +import { SessionPicker } from './SessionPicker.js'; interface DialogManagerProps { addItem: UseHistoryManagerReturn['addItem']; @@ -86,15 +86,6 @@ export const DialogManager = ({ /> ); } - if (uiState.proQuotaRequest) { - return ( - - ); - } if (uiState.shouldShowIdePrompt) { return ( + ); + } + return null; }; diff --git a/packages/cli/src/ui/components/Footer.tsx b/packages/cli/src/ui/components/Footer.tsx index 776817a6f..71f278df3 100644 --- a/packages/cli/src/ui/components/Footer.tsx +++ b/packages/cli/src/ui/components/Footer.tsx @@ -33,7 +33,6 @@ export const Footer: React.FC = () => { debugMode, branchName, debugMessage, - corgiMode, errorCount, showErrorDetails, promptTokenCount, @@ -45,7 +44,6 @@ export const Footer: React.FC = () => { debugMode: config.getDebugMode(), branchName: uiState.branchName, debugMessage: uiState.debugMessage, - corgiMode: uiState.corgiMode, errorCount: uiState.errorCount, showErrorDetails: uiState.showErrorDetails, promptTokenCount: uiState.sessionStats.lastPromptTokenCount, @@ -153,16 +151,6 @@ export const Footer: React.FC = () => { {showMemoryUsage && } - {corgiMode && ( - - | - - - - `) - - - )} {!showErrorDetails && errorCount > 0 && ( | diff --git a/packages/cli/src/ui/components/HistoryItemDisplay.test.tsx b/packages/cli/src/ui/components/HistoryItemDisplay.test.tsx index 7cca61ae0..2c92af578 100644 --- a/packages/cli/src/ui/components/HistoryItemDisplay.test.tsx +++ b/packages/cli/src/ui/components/HistoryItemDisplay.test.tsx @@ -15,6 +15,7 @@ import type { } from '@qwen-code/qwen-code-core'; import { ToolGroupMessage } from './messages/ToolGroupMessage.js'; import { renderWithProviders } from '../../test-utils/render.js'; +import { ConfigContext } from '../contexts/ConfigContext.js'; // Mock child components vi.mock('./messages/ToolGroupMessage.js', () => ({ @@ -22,7 +23,9 @@ vi.mock('./messages/ToolGroupMessage.js', () => ({ })); describe('', () => { - const mockConfig = {} as unknown as Config; + const mockConfig = { + getChatRecordingService: () => undefined, + } as unknown as Config; const baseItem = { id: 1, timestamp: 12345, @@ -133,9 +136,11 @@ describe('', () => { duration: '1s', }; const { lastFrame } = renderWithProviders( - - - , + + + + + , ); expect(lastFrame()).toContain('Agent powering down. Goodbye!'); }); diff --git a/packages/cli/src/ui/components/InputPrompt.test.tsx b/packages/cli/src/ui/components/InputPrompt.test.tsx index 5449db5ee..cf8b9685c 100644 --- a/packages/cli/src/ui/components/InputPrompt.test.tsx +++ b/packages/cli/src/ui/components/InputPrompt.test.tsx @@ -1307,7 +1307,7 @@ describe('InputPrompt', () => { mockBuffer.text = text; mockBuffer.lines = [text]; mockBuffer.viewportVisualLines = [text]; - mockBuffer.visualCursor = [0, 8]; // cursor after '👍' (length is 6 + 2 for emoji) + mockBuffer.visualCursor = [0, 7]; // cursor after '👍' (emoji is 1 code point, so total is 7) const { stdout, unmount } = renderWithProviders( , diff --git a/packages/cli/src/ui/components/InputPrompt.tsx b/packages/cli/src/ui/components/InputPrompt.tsx index 8af770596..7d1742505 100644 --- a/packages/cli/src/ui/components/InputPrompt.tsx +++ b/packages/cli/src/ui/components/InputPrompt.tsx @@ -707,15 +707,20 @@ export const InputPrompt: React.FC = ({ statusText = t('Accepting edits'); } + const borderColor = + isShellFocused && !isEmbeddedShellFocused + ? (statusColor ?? theme.border.focused) + : theme.border.default; + return ( <> = ({ isOnCursorLine && cursorVisualColAbsolute === cpLen(lineText) ) { + // Add zero-width space after cursor to prevent Ink from trimming trailing whitespace renderedLine.push( - {showCursor ? chalk.inverse(' ') : ' '} + {showCursor ? chalk.inverse(' ') + '\u200B' : ' \u200B'} , ); } diff --git a/packages/cli/src/ui/components/PermissionsModifyTrustDialog.tsx b/packages/cli/src/ui/components/PermissionsModifyTrustDialog.tsx index 94da20784..dfed5ba42 100644 --- a/packages/cli/src/ui/components/PermissionsModifyTrustDialog.tsx +++ b/packages/cli/src/ui/components/PermissionsModifyTrustDialog.tsx @@ -115,7 +115,7 @@ export function PermissionsModifyTrustDialog({ {needsRestart && ( - To apply the trust changes, Gemini CLI must be restarted. Press + To apply the trust changes, Qwen Code must be restarted. Press 'r' to restart CLI now. diff --git a/packages/cli/src/ui/components/ProQuotaDialog.test.tsx b/packages/cli/src/ui/components/ProQuotaDialog.test.tsx deleted file mode 100644 index 0670af5eb..000000000 --- a/packages/cli/src/ui/components/ProQuotaDialog.test.tsx +++ /dev/null @@ -1,91 +0,0 @@ -/** - * @license - * Copyright 2025 Google LLC - * SPDX-License-Identifier: Apache-2.0 - */ - -import { render } from 'ink-testing-library'; -import { describe, it, expect, vi, beforeEach, type Mock } from 'vitest'; -import { ProQuotaDialog } from './ProQuotaDialog.js'; -import { RadioButtonSelect } from './shared/RadioButtonSelect.js'; - -// Mock the child component to make it easier to test the parent -vi.mock('./shared/RadioButtonSelect.js', () => ({ - RadioButtonSelect: vi.fn(), -})); - -describe('ProQuotaDialog', () => { - beforeEach(() => { - vi.clearAllMocks(); - }); - - it('should render with correct title and options', () => { - const { lastFrame } = render( - {}} - />, - ); - - const output = lastFrame(); - expect(output).toContain('Pro quota limit reached for gemini-2.5-pro.'); - - // Check that RadioButtonSelect was called with the correct items - expect(RadioButtonSelect).toHaveBeenCalledWith( - expect.objectContaining({ - items: [ - { - label: 'Change auth (executes the /auth command)', - value: 'auth', - key: 'auth', - }, - { - label: `Continue with gemini-2.5-flash`, - value: 'continue', - key: 'continue', - }, - ], - }), - undefined, - ); - }); - - it('should call onChoice with "auth" when "Change auth" is selected', () => { - const mockOnChoice = vi.fn(); - render( - , - ); - - // Get the onSelect function passed to RadioButtonSelect - const onSelect = (RadioButtonSelect as Mock).mock.calls[0][0].onSelect; - - // Simulate the selection - onSelect('auth'); - - expect(mockOnChoice).toHaveBeenCalledWith('auth'); - }); - - it('should call onChoice with "continue" when "Continue with flash" is selected', () => { - const mockOnChoice = vi.fn(); - render( - , - ); - - // Get the onSelect function passed to RadioButtonSelect - const onSelect = (RadioButtonSelect as Mock).mock.calls[0][0].onSelect; - - // Simulate the selection - onSelect('continue'); - - expect(mockOnChoice).toHaveBeenCalledWith('continue'); - }); -}); diff --git a/packages/cli/src/ui/components/ProQuotaDialog.tsx b/packages/cli/src/ui/components/ProQuotaDialog.tsx deleted file mode 100644 index cc9bd5f81..000000000 --- a/packages/cli/src/ui/components/ProQuotaDialog.tsx +++ /dev/null @@ -1,55 +0,0 @@ -/** - * @license - * Copyright 2025 Google LLC - * SPDX-License-Identifier: Apache-2.0 - */ - -import type React from 'react'; -import { Box, Text } from 'ink'; -import { RadioButtonSelect } from './shared/RadioButtonSelect.js'; -import { theme } from '../semantic-colors.js'; -import { t } from '../../i18n/index.js'; - -interface ProQuotaDialogProps { - failedModel: string; - fallbackModel: string; - onChoice: (choice: 'auth' | 'continue') => void; -} - -export function ProQuotaDialog({ - failedModel, - fallbackModel, - onChoice, -}: ProQuotaDialogProps): React.JSX.Element { - const items = [ - { - label: t('Change auth (executes the /auth command)'), - value: 'auth' as const, - key: 'auth', - }, - { - label: t('Continue with {{model}}', { model: fallbackModel }), - value: 'continue' as const, - key: 'continue', - }, - ]; - - const handleSelect = (choice: 'auth' | 'continue') => { - onChoice(choice); - }; - - return ( - - - {t('Pro quota limit reached for {{model}}.', { model: failedModel })} - - - - - - ); -} diff --git a/packages/cli/src/ui/components/ResumeSessionPicker.tsx b/packages/cli/src/ui/components/ResumeSessionPicker.tsx deleted file mode 100644 index 0057d7000..000000000 --- a/packages/cli/src/ui/components/ResumeSessionPicker.tsx +++ /dev/null @@ -1,436 +0,0 @@ -/** - * @license - * Copyright 2025 Qwen Code - * SPDX-License-Identifier: Apache-2.0 - */ - -import { useState, useEffect, useCallback, useRef } from 'react'; -import { render, Box, Text, useInput, useApp } from 'ink'; -import { - SessionService, - type SessionListItem, - type ListSessionsResult, - getGitBranch, -} from '@qwen-code/qwen-code-core'; -import { theme } from '../semantic-colors.js'; -import { formatRelativeTime } from '../utils/formatters.js'; - -const PAGE_SIZE = 20; - -interface SessionPickerProps { - sessionService: SessionService; - currentBranch?: string; - onSelect: (sessionId: string) => void; - onCancel: () => void; -} - -/** - * Truncates text to fit within a given width, adding ellipsis if needed. - */ -function truncateText(text: string, maxWidth: number): string { - if (text.length <= maxWidth) return text; - if (maxWidth <= 3) return text.slice(0, maxWidth); - return text.slice(0, maxWidth - 3) + '...'; -} - -function SessionPicker({ - sessionService, - currentBranch, - onSelect, - onCancel, -}: SessionPickerProps): React.JSX.Element { - const { exit } = useApp(); - const [selectedIndex, setSelectedIndex] = useState(0); - const [sessionState, setSessionState] = useState<{ - sessions: SessionListItem[]; - hasMore: boolean; - nextCursor?: number; - }>({ - sessions: [], - hasMore: true, - nextCursor: undefined, - }); - const isLoadingMoreRef = useRef(false); - const [filterByBranch, setFilterByBranch] = useState(false); - const [isExiting, setIsExiting] = useState(false); - const [terminalSize, setTerminalSize] = useState({ - width: process.stdout.columns || 80, - height: process.stdout.rows || 24, - }); - - // Update terminal size on resize - useEffect(() => { - const handleResize = () => { - setTerminalSize({ - width: process.stdout.columns || 80, - height: process.stdout.rows || 24, - }); - }; - process.stdout.on('resize', handleResize); - return () => { - process.stdout.off('resize', handleResize); - }; - }, []); - - // Filter sessions by current branch if filter is enabled - const filteredSessions = - filterByBranch && currentBranch - ? sessionState.sessions.filter( - (session) => session.gitBranch === currentBranch, - ) - : sessionState.sessions; - - const hasSentinel = sessionState.hasMore; - - // Reset selection when filter changes - useEffect(() => { - setSelectedIndex(0); - }, [filterByBranch]); - - const loadMoreSessions = useCallback(async () => { - if (!sessionState.hasMore || isLoadingMoreRef.current) return; - isLoadingMoreRef.current = true; - try { - const result: ListSessionsResult = await sessionService.listSessions({ - size: PAGE_SIZE, - cursor: sessionState.nextCursor, - }); - - setSessionState((prev) => ({ - sessions: [...prev.sessions, ...result.items], - hasMore: result.hasMore && result.nextCursor !== undefined, - nextCursor: result.nextCursor, - })); - } finally { - isLoadingMoreRef.current = false; - } - }, [sessionService, sessionState.hasMore, sessionState.nextCursor]); - - // Calculate visible items - // Reserved space: header (1), footer (1), separators (2), borders (2) - const reservedLines = 6; - // Each item takes 2 lines (prompt + metadata) + 1 line margin between items - // On average, this is ~3 lines per item, but the last item has no margin - const itemHeight = 3; - const maxVisibleItems = Math.max( - 1, - Math.floor((terminalSize.height - reservedLines) / itemHeight), - ); - - // Calculate scroll offset - const scrollOffset = (() => { - if (filteredSessions.length <= maxVisibleItems) return 0; - const halfVisible = Math.floor(maxVisibleItems / 2); - let offset = selectedIndex - halfVisible; - offset = Math.max(0, offset); - offset = Math.min(filteredSessions.length - maxVisibleItems, offset); - return offset; - })(); - - const visibleSessions = filteredSessions.slice( - scrollOffset, - scrollOffset + maxVisibleItems, - ); - const showScrollUp = scrollOffset > 0; - const showScrollDown = - scrollOffset + maxVisibleItems < filteredSessions.length; - - // Sentinel (invisible) sits after the last session item; consider it visible - // once the viewport reaches the final real item. - const sentinelVisible = - hasSentinel && scrollOffset + maxVisibleItems >= filteredSessions.length; - - // Load more when sentinel enters view or when filtered list is empty. - useEffect(() => { - if (!sessionState.hasMore || isLoadingMoreRef.current) return; - - const shouldLoadMore = - filteredSessions.length === 0 || - sentinelVisible || - isLoadingMoreRef.current; - - if (shouldLoadMore) { - void loadMoreSessions(); - } - }, [ - filteredSessions.length, - loadMoreSessions, - sessionState.hasMore, - sentinelVisible, - ]); - - // Handle keyboard input - useInput((input, key) => { - // Ignore input if already exiting - if (isExiting) { - return; - } - - // Escape or Ctrl+C to cancel - if (key.escape || (key.ctrl && input === 'c')) { - setIsExiting(true); - onCancel(); - exit(); - return; - } - - if (key.return) { - const session = filteredSessions[selectedIndex]; - if (session) { - setIsExiting(true); - onSelect(session.sessionId); - exit(); - } - return; - } - - if (key.upArrow || input === 'k') { - setSelectedIndex((prev) => Math.max(0, prev - 1)); - return; - } - - if (key.downArrow || input === 'j') { - if (filteredSessions.length === 0) { - return; - } - setSelectedIndex((prev) => - Math.min(filteredSessions.length - 1, prev + 1), - ); - return; - } - - if (input === 'b' || input === 'B') { - if (currentBranch) { - setFilterByBranch((prev) => !prev); - } - return; - } - }); - - // Filtered sessions may have changed, ensure selectedIndex is valid - useEffect(() => { - if ( - selectedIndex >= filteredSessions.length && - filteredSessions.length > 0 - ) { - setSelectedIndex(filteredSessions.length - 1); - } - }, [filteredSessions.length, selectedIndex]); - - // Calculate content width (terminal width minus border padding) - const contentWidth = terminalSize.width - 4; - const promptMaxWidth = contentWidth - 4; // Account for "› " prefix - - // Return empty while exiting to prevent visual glitches - if (isExiting) { - return ; - } - - return ( - - {/* Main container with single border */} - - {/* Header row */} - - - Resume Session - - - - {/* Separator line */} - - - {'─'.repeat(terminalSize.width - 2)} - - - - {/* Session list with auto-scrolling */} - - {filteredSessions.length === 0 ? ( - - - {filterByBranch - ? `No sessions found for branch "${currentBranch}"` - : 'No sessions found'} - - - ) : ( - visibleSessions.map((session, visibleIndex) => { - const actualIndex = scrollOffset + visibleIndex; - const isSelected = actualIndex === selectedIndex; - const isFirst = visibleIndex === 0; - const isLast = visibleIndex === visibleSessions.length - 1; - const timeAgo = formatRelativeTime(session.mtime); - const messageText = - session.messageCount === 1 - ? '1 message' - : `${session.messageCount} messages`; - - // Show scroll indicator on first/last visible items - const showUpIndicator = isFirst && showScrollUp; - const showDownIndicator = isLast && showScrollDown; - - // Determine the prefix: selector takes priority over scroll indicator - const prefix = isSelected - ? '› ' - : showUpIndicator - ? '↑ ' - : showDownIndicator - ? '↓ ' - : ' '; - - return ( - - {/* First line: prefix (selector or scroll indicator) + prompt text */} - - - {prefix} - - - {truncateText( - session.prompt || '(empty prompt)', - promptMaxWidth, - )} - - - - {/* Second line: metadata (aligned with prompt text) */} - - {' '} - - {timeAgo} · {messageText} - {session.gitBranch && ` · ${session.gitBranch}`} - - - - ); - }) - )} - - - {/* Separator line */} - - - {'─'.repeat(terminalSize.width - 2)} - - - - {/* Footer with keyboard shortcuts */} - - - {currentBranch && ( - <> - - B - - {' to toggle branch · '} - - )} - {'↑↓ to navigate · Esc to cancel'} - - - - - ); -} - -/** - * Clears the terminal screen. - */ -function clearScreen(): void { - // Move cursor to home position and clear screen - process.stdout.write('\x1b[2J\x1b[H'); -} - -/** - * Shows an interactive session picker and returns the selected session ID. - * Returns undefined if the user cancels or no sessions are available. - */ -export async function showResumeSessionPicker( - cwd: string = process.cwd(), -): Promise { - const sessionService = new SessionService(cwd); - const hasSession = await sessionService.loadLastSession(); - if (!hasSession) { - console.log('No sessions found. Start a new session with `qwen`.'); - return undefined; - } - - const currentBranch = getGitBranch(cwd); - - // Clear the screen before showing the picker for a clean fullscreen experience - clearScreen(); - - // Enable raw mode for keyboard input if not already enabled - const wasRaw = process.stdin.isRaw; - if (process.stdin.isTTY && !wasRaw) { - process.stdin.setRawMode(true); - } - - return new Promise((resolve) => { - let selectedId: string | undefined; - - const { unmount, waitUntilExit } = render( - { - selectedId = id; - }} - onCancel={() => { - selectedId = undefined; - }} - />, - { - exitOnCtrlC: false, - }, - ); - - waitUntilExit().then(() => { - unmount(); - - // Clear the screen after the picker closes for a clean fullscreen experience - clearScreen(); - - // Restore raw mode state only if we changed it and user cancelled - // (if user selected a session, main app will handle raw mode) - if (process.stdin.isTTY && !wasRaw && !selectedId) { - process.stdin.setRawMode(false); - } - - resolve(selectedId); - }); - }); -} diff --git a/packages/cli/src/ui/components/SessionPicker.tsx b/packages/cli/src/ui/components/SessionPicker.tsx new file mode 100644 index 000000000..9729d4c67 --- /dev/null +++ b/packages/cli/src/ui/components/SessionPicker.tsx @@ -0,0 +1,251 @@ +/** + * @license + * Copyright 2025 Qwen Code + * SPDX-License-Identifier: Apache-2.0 + */ + +import { Box, Text } from 'ink'; +import type { + SessionListItem as SessionData, + SessionService, +} from '@qwen-code/qwen-code-core'; +import { theme } from '../semantic-colors.js'; +import { useSessionPicker } from '../hooks/useSessionPicker.js'; +import { formatRelativeTime } from '../utils/formatters.js'; +import { + formatMessageCount, + truncateText, +} from '../utils/sessionPickerUtils.js'; +import { useTerminalSize } from '../hooks/useTerminalSize.js'; +import { t } from '../../i18n/index.js'; + +export interface SessionPickerProps { + sessionService: SessionService | null; + onSelect: (sessionId: string) => void; + onCancel: () => void; + currentBranch?: string; + + /** + * Scroll mode. When true, keep selection centered (fullscreen-style). + * Defaults to true so dialog + standalone behave identically. + */ + centerSelection?: boolean; +} + +const PREFIX_CHARS = { + selected: '› ', + scrollUp: '↑ ', + scrollDown: '↓ ', + normal: ' ', +}; + +interface SessionListItemViewProps { + session: SessionData; + isSelected: boolean; + isFirst: boolean; + isLast: boolean; + showScrollUp: boolean; + showScrollDown: boolean; + maxPromptWidth: number; + prefixChars?: { + selected: string; + scrollUp: string; + scrollDown: string; + normal: string; + }; + boldSelectedPrefix?: boolean; +} + +function SessionListItemView({ + session, + isSelected, + isFirst, + isLast, + showScrollUp, + showScrollDown, + maxPromptWidth, + prefixChars = PREFIX_CHARS, + boldSelectedPrefix = true, +}: SessionListItemViewProps): React.JSX.Element { + const timeAgo = formatRelativeTime(session.mtime); + const messageText = formatMessageCount(session.messageCount); + + const showUpIndicator = isFirst && showScrollUp; + const showDownIndicator = isLast && showScrollDown; + + const prefix = isSelected + ? prefixChars.selected + : showUpIndicator + ? prefixChars.scrollUp + : showDownIndicator + ? prefixChars.scrollDown + : prefixChars.normal; + + const promptText = session.prompt || '(empty prompt)'; + const truncatedPrompt = truncateText(promptText, maxPromptWidth); + + return ( + + + + {prefix} + + + {truncatedPrompt} + + + + + {timeAgo} · {messageText} + {session.gitBranch && ` · ${session.gitBranch}`} + + + + ); +} + +export function SessionPicker(props: SessionPickerProps) { + const { + sessionService, + onSelect, + onCancel, + currentBranch, + centerSelection = true, + } = props; + + const { columns: width, rows: height } = useTerminalSize(); + + // Calculate box width (width + 6 for border padding) + const boxWidth = width + 6; + // Calculate visible items (same heuristic as before) + // Reserved space: header (1), footer (1), separators (2), borders (2) + const reservedLines = 6; + // Each item takes 2 lines (prompt + metadata) + 1 line margin between items + const itemHeight = 3; + const maxVisibleItems = Math.max( + 1, + Math.floor((height - reservedLines) / itemHeight), + ); + + const picker = useSessionPicker({ + sessionService, + currentBranch, + onSelect, + onCancel, + maxVisibleItems, + centerSelection, + isActive: true, + }); + + return ( + + + {/* Header row */} + + + {t('Resume Session')} + + {picker.filterByBranch && currentBranch && ( + + {' '} + {t('(branch: {{branch}})', { branch: currentBranch })} + + )} + + + {/* Separator */} + + {'─'.repeat(width - 2)} + + + {/* Session list */} + + {!sessionService || picker.isLoading ? ( + + + {t('Loading sessions...')} + + + ) : picker.filteredSessions.length === 0 ? ( + + + {picker.filterByBranch + ? t('No sessions found for branch "{{branch}}"', { + branch: currentBranch ?? '', + }) + : t('No sessions found')} + + + ) : ( + picker.visibleSessions.map((session, visibleIndex) => { + const actualIndex = picker.scrollOffset + visibleIndex; + return ( + + ); + }) + )} + + + {/* Separator */} + + {'─'.repeat(width - 2)} + + + {/* Footer */} + + + {currentBranch && ( + + + B + + {t(' to toggle branch')} · + + )} + + {t('↑↓ to navigate · Esc to cancel')} + + + + + + ); +} diff --git a/packages/cli/src/ui/components/SessionSummaryDisplay.test.tsx b/packages/cli/src/ui/components/SessionSummaryDisplay.test.tsx index 766e851a6..305b50b2c 100644 --- a/packages/cli/src/ui/components/SessionSummaryDisplay.test.tsx +++ b/packages/cli/src/ui/components/SessionSummaryDisplay.test.tsx @@ -9,6 +9,7 @@ import { describe, it, expect, vi } from 'vitest'; import { SessionSummaryDisplay } from './SessionSummaryDisplay.js'; import * as SessionContext from '../contexts/SessionContext.js'; import type { SessionMetrics } from '../contexts/SessionContext.js'; +import { ConfigContext } from '../contexts/ConfigContext.js'; vi.mock('../contexts/SessionContext.js', async (importOriginal) => { const actual = await importOriginal(); @@ -20,20 +21,36 @@ vi.mock('../contexts/SessionContext.js', async (importOriginal) => { const useSessionStatsMock = vi.mocked(SessionContext.useSessionStats); -const renderWithMockedStats = (metrics: SessionMetrics) => { +const renderWithMockedStats = ( + metrics: SessionMetrics, + sessionId: string = 'test-session-id-12345', + promptCount: number = 5, + chatRecordingEnabled: boolean = true, +) => { useSessionStatsMock.mockReturnValue({ stats: { + sessionId, sessionStartTime: new Date(), metrics, lastPromptTokenCount: 0, - promptCount: 5, + promptCount, }, - getPromptCount: () => 5, + getPromptCount: () => promptCount, startNewPrompt: vi.fn(), }); - return render(); + const mockConfig = { + getChatRecordingService: vi.fn(() => + chatRecordingEnabled ? ({} as never) : undefined, + ), + }; + + return render( + + + , + ); }; describe('', () => { @@ -70,6 +87,68 @@ describe('', () => { const output = lastFrame(); expect(output).toContain('Agent powering down. Goodbye!'); + expect(output).toContain('To continue this session, run'); + expect(output).toContain('qwen --resume test-session-id-12345'); expect(output).toMatchSnapshot(); }); + + it('does not show resume message when there are no messages', () => { + const metrics: SessionMetrics = { + models: {}, + tools: { + totalCalls: 0, + totalSuccess: 0, + totalFail: 0, + totalDurationMs: 0, + totalDecisions: { accept: 0, reject: 0, modify: 0 }, + byName: {}, + }, + files: { + totalLinesAdded: 0, + totalLinesRemoved: 0, + }, + }; + + // Pass promptCount = 0 to simulate no messages + const { lastFrame } = renderWithMockedStats( + metrics, + 'test-session-id-12345', + 0, + ); + const output = lastFrame(); + + expect(output).toContain('Agent powering down. Goodbye!'); + expect(output).not.toContain('To continue this session, run'); + expect(output).not.toContain('qwen --resume'); + }); + + it('does not show resume message when chat recording is disabled', () => { + const metrics: SessionMetrics = { + models: {}, + tools: { + totalCalls: 0, + totalSuccess: 0, + totalFail: 0, + totalDurationMs: 0, + totalDecisions: { accept: 0, reject: 0, modify: 0 }, + byName: {}, + }, + files: { + totalLinesAdded: 0, + totalLinesRemoved: 0, + }, + }; + + const { lastFrame } = renderWithMockedStats( + metrics, + 'test-session-id-12345', + 5, + false, + ); + const output = lastFrame(); + + expect(output).toContain('Agent powering down. Goodbye!'); + expect(output).not.toContain('To continue this session, run'); + expect(output).not.toContain('qwen --resume'); + }); }); diff --git a/packages/cli/src/ui/components/SessionSummaryDisplay.tsx b/packages/cli/src/ui/components/SessionSummaryDisplay.tsx index c8d79e0e7..b43f18bcf 100644 --- a/packages/cli/src/ui/components/SessionSummaryDisplay.tsx +++ b/packages/cli/src/ui/components/SessionSummaryDisplay.tsx @@ -5,7 +5,11 @@ */ import type React from 'react'; +import { Box, Text } from 'ink'; import { StatsDisplay } from './StatsDisplay.js'; +import { useSessionStats } from '../contexts/SessionContext.js'; +import { useConfig } from '../contexts/ConfigContext.js'; +import { theme } from '../semantic-colors.js'; import { t } from '../../i18n/index.js'; interface SessionSummaryDisplayProps { @@ -14,9 +18,31 @@ interface SessionSummaryDisplayProps { export const SessionSummaryDisplay: React.FC = ({ duration, -}) => ( - -); +}) => { + const config = useConfig(); + const { stats } = useSessionStats(); + + // Only show the resume message if there were messages in the session AND + // chat recording is enabled (otherwise there is nothing to resume). + const hasMessages = stats.promptCount > 0; + const canResume = !!config.getChatRecordingService(); + + return ( + <> + + {hasMessages && canResume && ( + + + {t('To continue this session, run')}{' '} + + qwen --resume {stats.sessionId} + + + + )} + + ); +}; diff --git a/packages/cli/src/ui/components/SettingsDialog.test.tsx b/packages/cli/src/ui/components/SettingsDialog.test.tsx index f96ec33c9..9e4d294e0 100644 --- a/packages/cli/src/ui/components/SettingsDialog.test.tsx +++ b/packages/cli/src/ui/components/SettingsDialog.test.tsx @@ -1461,7 +1461,7 @@ describe('SettingsDialog', () => { context: { fileFiltering: { respectGitIgnore: false, - respectQwemIgnore: true, + respectQwenIgnore: true, enableRecursiveFileSearch: false, disableFuzzySearch: true, }, @@ -1535,7 +1535,7 @@ describe('SettingsDialog', () => { loadMemoryFromIncludeDirectories: false, fileFiltering: { respectGitIgnore: false, - respectQwemIgnore: false, + respectQwenIgnore: false, enableRecursiveFileSearch: false, disableFuzzySearch: false, }, diff --git a/packages/cli/src/ui/components/StandaloneSessionPicker.test.tsx b/packages/cli/src/ui/components/StandaloneSessionPicker.test.tsx new file mode 100644 index 000000000..9a7c7b193 --- /dev/null +++ b/packages/cli/src/ui/components/StandaloneSessionPicker.test.tsx @@ -0,0 +1,624 @@ +/** + * @license + * Copyright 2025 Qwen Code + * SPDX-License-Identifier: Apache-2.0 + */ + +import { render } from 'ink-testing-library'; +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import { KeypressProvider } from '../contexts/KeypressContext.js'; +import { SessionPicker } from './SessionPicker.js'; +import type { + SessionListItem, + ListSessionsResult, +} from '@qwen-code/qwen-code-core'; + +vi.mock('@qwen-code/qwen-code-core', async () => { + const actual = await vi.importActual('@qwen-code/qwen-code-core'); + return { + ...actual, + getGitBranch: vi.fn().mockReturnValue('main'), + }; +}); + +// Mock terminal size +const mockTerminalSize = { columns: 80, rows: 24 }; + +beforeEach(() => { + Object.defineProperty(process.stdout, 'columns', { + value: mockTerminalSize.columns, + configurable: true, + }); + Object.defineProperty(process.stdout, 'rows', { + value: mockTerminalSize.rows, + configurable: true, + }); +}); + +// Helper to create mock sessions +function createMockSession( + overrides: Partial = {}, +): SessionListItem { + return { + sessionId: 'test-session-id', + cwd: '/test/path', + startTime: '2025-01-01T00:00:00.000Z', + mtime: Date.now(), + prompt: 'Test prompt', + gitBranch: 'main', + filePath: '/test/path/sessions/test-session-id.jsonl', + messageCount: 5, + ...overrides, + }; +} + +// Helper to create mock session service +function createMockSessionService( + sessions: SessionListItem[] = [], + hasMore = false, +) { + return { + listSessions: vi.fn().mockResolvedValue({ + items: sessions, + hasMore, + nextCursor: hasMore ? Date.now() : undefined, + } as ListSessionsResult), + loadSession: vi.fn(), + loadLastSession: vi + .fn() + .mockResolvedValue(sessions.length > 0 ? {} : undefined), + }; +} + +describe('SessionPicker', () => { + const wait = (ms = 50) => new Promise((resolve) => setTimeout(resolve, ms)); + + afterEach(() => { + vi.clearAllMocks(); + }); + + describe('Empty Sessions', () => { + it('should show sessions with 0 messages', async () => { + const sessions = [ + createMockSession({ + sessionId: 'empty-1', + messageCount: 0, + prompt: '', + }), + createMockSession({ + sessionId: 'with-messages', + messageCount: 5, + prompt: 'Hello', + }), + createMockSession({ + sessionId: 'empty-2', + messageCount: 0, + prompt: '(empty prompt)', + }), + ]; + const mockService = createMockSessionService(sessions); + const onSelect = vi.fn(); + const onCancel = vi.fn(); + + const { lastFrame } = render( + + + , + ); + + await wait(100); + + const output = lastFrame(); + expect(output).toContain('Hello'); + // Should show empty sessions too (rendered as "(empty prompt)" + "0 messages") + expect(output).toContain('0 messages'); + }); + + it('should show sessions even when all sessions are empty', async () => { + const sessions = [ + createMockSession({ sessionId: 'empty-1', messageCount: 0 }), + createMockSession({ sessionId: 'empty-2', messageCount: 0 }), + ]; + const mockService = createMockSessionService(sessions); + const onSelect = vi.fn(); + const onCancel = vi.fn(); + + const { lastFrame } = render( + + + , + ); + + await wait(100); + + const output = lastFrame(); + expect(output).toContain('0 messages'); + }); + + it('should show sessions with 1 or more messages', async () => { + const sessions = [ + createMockSession({ + sessionId: 'one-msg', + messageCount: 1, + prompt: 'Single message', + }), + createMockSession({ + sessionId: 'many-msg', + messageCount: 10, + prompt: 'Many messages', + }), + ]; + const mockService = createMockSessionService(sessions); + const onSelect = vi.fn(); + const onCancel = vi.fn(); + + const { lastFrame } = render( + + + , + ); + + await wait(100); + + const output = lastFrame(); + expect(output).toContain('Single message'); + expect(output).toContain('Many messages'); + expect(output).toContain('1 message'); + expect(output).toContain('10 messages'); + }); + }); + + describe('Branch Filtering', () => { + it('should filter by branch when B is pressed', async () => { + const sessions = [ + createMockSession({ + sessionId: 's1', + gitBranch: 'main', + prompt: 'Main branch', + messageCount: 1, + }), + createMockSession({ + sessionId: 's2', + gitBranch: 'feature', + prompt: 'Feature branch', + messageCount: 1, + }), + createMockSession({ + sessionId: 's3', + gitBranch: 'main', + prompt: 'Also main', + messageCount: 1, + }), + ]; + const mockService = createMockSessionService(sessions); + const onSelect = vi.fn(); + const onCancel = vi.fn(); + + const { lastFrame, stdin } = render( + + + , + ); + + await wait(100); + + // All sessions should be visible initially + let output = lastFrame(); + expect(output).toContain('Main branch'); + expect(output).toContain('Feature branch'); + + // Press B to filter by branch + stdin.write('B'); + await wait(50); + + output = lastFrame(); + // Only main branch sessions should be visible + expect(output).toContain('Main branch'); + expect(output).toContain('Also main'); + expect(output).not.toContain('Feature branch'); + }); + + it('should combine empty session filter with branch filter', async () => { + const sessions = [ + createMockSession({ + sessionId: 's1', + gitBranch: 'main', + messageCount: 0, + prompt: 'Empty main', + }), + createMockSession({ + sessionId: 's2', + gitBranch: 'main', + messageCount: 5, + prompt: 'Valid main', + }), + createMockSession({ + sessionId: 's3', + gitBranch: 'feature', + messageCount: 5, + prompt: 'Valid feature', + }), + ]; + const mockService = createMockSessionService(sessions); + const onSelect = vi.fn(); + const onCancel = vi.fn(); + + const { lastFrame, stdin } = render( + + + , + ); + + await wait(100); + + // Press B to filter by branch + stdin.write('B'); + await wait(50); + + const output = lastFrame(); + // Should only show sessions from main branch (including 0-message sessions) + expect(output).toContain('Valid main'); + expect(output).toContain('Empty main'); + expect(output).not.toContain('Valid feature'); + }); + }); + + describe('Keyboard Navigation', () => { + it('should navigate with arrow keys', async () => { + const sessions = [ + createMockSession({ + sessionId: 's1', + prompt: 'First session', + messageCount: 1, + }), + createMockSession({ + sessionId: 's2', + prompt: 'Second session', + messageCount: 1, + }), + createMockSession({ + sessionId: 's3', + prompt: 'Third session', + messageCount: 1, + }), + ]; + const mockService = createMockSessionService(sessions); + const onSelect = vi.fn(); + const onCancel = vi.fn(); + + const { lastFrame, stdin } = render( + + + , + ); + + await wait(100); + + // First session should be selected initially (indicated by >) + let output = lastFrame(); + expect(output).toContain('First session'); + + // Navigate down + stdin.write('\u001B[B'); // Down arrow + await wait(50); + + output = lastFrame(); + // Selection indicator should move + expect(output).toBeDefined(); + }); + + it('should navigate with vim keys (j/k)', async () => { + const sessions = [ + createMockSession({ + sessionId: 's1', + prompt: 'First', + messageCount: 1, + }), + createMockSession({ + sessionId: 's2', + prompt: 'Second', + messageCount: 1, + }), + ]; + const mockService = createMockSessionService(sessions); + const onSelect = vi.fn(); + const onCancel = vi.fn(); + + const { stdin, unmount } = render( + + + , + ); + + await wait(100); + + // Navigate with j (down) + stdin.write('j'); + await wait(50); + + // Navigate with k (up) + stdin.write('k'); + await wait(50); + + unmount(); + }); + + it('should select session on Enter', async () => { + const sessions = [ + createMockSession({ + sessionId: 'selected-session', + prompt: 'Select me', + messageCount: 1, + }), + ]; + const mockService = createMockSessionService(sessions); + const onSelect = vi.fn(); + const onCancel = vi.fn(); + + const { stdin } = render( + + + , + ); + + await wait(100); + + // Press Enter to select + stdin.write('\r'); + await wait(50); + + expect(onSelect).toHaveBeenCalledWith('selected-session'); + }); + + it('should cancel on Escape', async () => { + const sessions = [ + createMockSession({ sessionId: 's1', messageCount: 1 }), + ]; + const mockService = createMockSessionService(sessions); + const onSelect = vi.fn(); + const onCancel = vi.fn(); + + const { stdin } = render( + + + , + ); + + await wait(100); + + // Press Escape to cancel + stdin.write('\u001B'); + await wait(50); + + expect(onCancel).toHaveBeenCalled(); + expect(onSelect).not.toHaveBeenCalled(); + }); + }); + + describe('Display', () => { + it('should show session metadata', async () => { + const sessions = [ + createMockSession({ + sessionId: 's1', + prompt: 'Test prompt text', + messageCount: 5, + gitBranch: 'feature-branch', + }), + ]; + const mockService = createMockSessionService(sessions); + const onSelect = vi.fn(); + const onCancel = vi.fn(); + + const { lastFrame } = render( + + + , + ); + + await wait(100); + + const output = lastFrame(); + expect(output).toContain('Test prompt text'); + expect(output).toContain('5 messages'); + expect(output).toContain('feature-branch'); + }); + + it('should show header and footer', async () => { + const sessions = [createMockSession({ messageCount: 1 })]; + const mockService = createMockSessionService(sessions); + const onSelect = vi.fn(); + const onCancel = vi.fn(); + + const { lastFrame } = render( + + + , + ); + + await wait(100); + + const output = lastFrame(); + expect(output).toContain('Resume Session'); + expect(output).toContain('↑↓ to navigate'); + expect(output).toContain('Esc to cancel'); + }); + + it('should show branch toggle hint when currentBranch is provided', async () => { + const sessions = [createMockSession({ messageCount: 1 })]; + const mockService = createMockSessionService(sessions); + const onSelect = vi.fn(); + const onCancel = vi.fn(); + + const { lastFrame } = render( + + + , + ); + + await wait(100); + + const output = lastFrame(); + expect(output).toContain('B'); + expect(output).toContain('toggle branch'); + }); + + it('should truncate long prompts', async () => { + const longPrompt = 'A'.repeat(300); + const sessions = [ + createMockSession({ prompt: longPrompt, messageCount: 1 }), + ]; + const mockService = createMockSessionService(sessions); + const onSelect = vi.fn(); + const onCancel = vi.fn(); + + const { lastFrame } = render( + + + , + ); + + await wait(100); + + const output = lastFrame(); + // Should contain ellipsis for truncated text + expect(output).toContain('...'); + // Should NOT contain the full untruncated prompt (300 A's in a row) + expect(output).not.toContain(longPrompt); + }); + + it('should show "(empty prompt)" for sessions without prompt text', async () => { + const sessions = [createMockSession({ prompt: '', messageCount: 1 })]; + const mockService = createMockSessionService(sessions); + const onSelect = vi.fn(); + const onCancel = vi.fn(); + + const { lastFrame } = render( + + + , + ); + + await wait(100); + + const output = lastFrame(); + expect(output).toContain('(empty prompt)'); + }); + }); + + describe('Pagination', () => { + it('should load more sessions when scrolling to bottom', async () => { + const firstPage = Array.from({ length: 5 }, (_, i) => + createMockSession({ + sessionId: `session-${i}`, + prompt: `Session ${i}`, + messageCount: 1, + mtime: Date.now() - i * 1000, + }), + ); + const secondPage = Array.from({ length: 3 }, (_, i) => + createMockSession({ + sessionId: `session-${i + 5}`, + prompt: `Session ${i + 5}`, + messageCount: 1, + mtime: Date.now() - (i + 5) * 1000, + }), + ); + + const mockService = { + listSessions: vi + .fn() + .mockResolvedValueOnce({ + items: firstPage, + hasMore: true, + nextCursor: Date.now() - 5000, + }) + .mockResolvedValueOnce({ + items: secondPage, + hasMore: false, + nextCursor: undefined, + }), + loadSession: vi.fn(), + loadLastSession: vi.fn().mockResolvedValue({}), + }; + + const onSelect = vi.fn(); + const onCancel = vi.fn(); + + const { unmount } = render( + + + , + ); + + await wait(200); + + // First page should be loaded + expect(mockService.listSessions).toHaveBeenCalled(); + + unmount(); + }); + }); +}); diff --git a/packages/cli/src/ui/components/StandaloneSessionPicker.tsx b/packages/cli/src/ui/components/StandaloneSessionPicker.tsx new file mode 100644 index 000000000..bac7f23df --- /dev/null +++ b/packages/cli/src/ui/components/StandaloneSessionPicker.tsx @@ -0,0 +1,122 @@ +/** + * @license + * Copyright 2025 Qwen Code + * SPDX-License-Identifier: Apache-2.0 + */ + +import { useState } from 'react'; +import { render, Box, useApp } from 'ink'; +import { getGitBranch, SessionService } from '@qwen-code/qwen-code-core'; +import { KeypressProvider } from '../contexts/KeypressContext.js'; +import { SessionPicker } from './SessionPicker.js'; + +interface StandalonePickerScreenProps { + sessionService: SessionService; + onSelect: (sessionId: string) => void; + onCancel: () => void; + currentBranch?: string; +} + +function StandalonePickerScreen({ + sessionService, + onSelect, + onCancel, + currentBranch, +}: StandalonePickerScreenProps): React.JSX.Element { + const { exit } = useApp(); + const [isExiting, setIsExiting] = useState(false); + const handleExit = () => { + setIsExiting(true); + exit(); + }; + + // Return empty while exiting to prevent visual glitches + if (isExiting) { + return ; + } + + return ( + { + onSelect(id); + handleExit(); + }} + onCancel={() => { + onCancel(); + handleExit(); + }} + currentBranch={currentBranch} + centerSelection={true} + /> + ); +} + +/** + * Clears the terminal screen. + */ +function clearScreen(): void { + // Move cursor to home position and clear screen + process.stdout.write('\x1b[2J\x1b[H'); +} + +/** + * Shows an interactive session picker and returns the selected session ID. + * Returns undefined if the user cancels or no sessions are available. + */ +export async function showResumeSessionPicker( + cwd: string = process.cwd(), +): Promise { + const sessionService = new SessionService(cwd); + const hasSession = await sessionService.loadLastSession(); + if (!hasSession) { + console.log('No sessions found. Start a new session with `qwen`.'); + return undefined; + } + + // Clear the screen before showing the picker for a clean fullscreen experience + clearScreen(); + + // Enable raw mode for keyboard input if not already enabled + const wasRaw = process.stdin.isRaw; + if (process.stdin.isTTY && !wasRaw) { + process.stdin.setRawMode(true); + } + + return new Promise((resolve) => { + let selectedId: string | undefined; + + const { unmount, waitUntilExit } = render( + + { + selectedId = id; + }} + onCancel={() => { + selectedId = undefined; + }} + currentBranch={getGitBranch(cwd)} + /> + , + { + exitOnCtrlC: false, + }, + ); + + waitUntilExit().then(() => { + unmount(); + + // Clear the screen after the picker closes for a clean fullscreen experience + clearScreen(); + + // Restore raw mode state only if we changed it and user cancelled + // (if user selected a session, main app will handle raw mode) + if (process.stdin.isTTY && !wasRaw && !selectedId) { + process.stdin.setRawMode(false); + } + + resolve(selectedId); + }); + }); +} diff --git a/packages/cli/src/ui/components/__snapshots__/InputPrompt.test.tsx.snap b/packages/cli/src/ui/components/__snapshots__/InputPrompt.test.tsx.snap index 9ae6eab1c..efa14b8b6 100644 --- a/packages/cli/src/ui/components/__snapshots__/InputPrompt.test.tsx.snap +++ b/packages/cli/src/ui/components/__snapshots__/InputPrompt.test.tsx.snap @@ -19,39 +19,39 @@ exports[`InputPrompt > command search (Ctrl+R when not in shell) > expands and c `; exports[`InputPrompt > command search (Ctrl+R when not in shell) > renders match window and expanded view (snapshots) > command-search-collapsed-match 1`] = ` -"╭──────────────────────────────────────────────────────────────────────────────────────────────────╮ -│ (r:) commit │ -╰──────────────────────────────────────────────────────────────────────────────────────────────────╯ +"──────────────────────────────────────────────────────────────────────────────────────────────────── + (r:) commit +──────────────────────────────────────────────────────────────────────────────────────────────────── git commit -m "feat: add search" in src/app" `; exports[`InputPrompt > command search (Ctrl+R when not in shell) > renders match window and expanded view (snapshots) > command-search-expanded-match 1`] = ` -"╭──────────────────────────────────────────────────────────────────────────────────────────────────╮ -│ (r:) commit │ -╰──────────────────────────────────────────────────────────────────────────────────────────────────╯ +"──────────────────────────────────────────────────────────────────────────────────────────────────── + (r:) commit +──────────────────────────────────────────────────────────────────────────────────────────────────── git commit -m "feat: add search" in src/app" `; exports[`InputPrompt > snapshots > should not show inverted cursor when shell is focused 1`] = ` -"╭──────────────────────────────────────────────────────────────────────────────────────────────────╮ -│ > Type your message or @path/to/file │ -╰──────────────────────────────────────────────────────────────────────────────────────────────────╯" +"──────────────────────────────────────────────────────────────────────────────────────────────────── + > Type your message or @path/to/file +────────────────────────────────────────────────────────────────────────────────────────────────────" `; exports[`InputPrompt > snapshots > should render correctly in shell mode 1`] = ` -"╭──────────────────────────────────────────────────────────────────────────────────────────────────╮ -│ ! Type your message or @path/to/file │ -╰──────────────────────────────────────────────────────────────────────────────────────────────────╯" +"──────────────────────────────────────────────────────────────────────────────────────────────────── + ! Type your message or @path/to/file +────────────────────────────────────────────────────────────────────────────────────────────────────" `; exports[`InputPrompt > snapshots > should render correctly in yolo mode 1`] = ` -"╭──────────────────────────────────────────────────────────────────────────────────────────────────╮ -│ * Type your message or @path/to/file │ -╰──────────────────────────────────────────────────────────────────────────────────────────────────╯" +"──────────────────────────────────────────────────────────────────────────────────────────────────── + * Type your message or @path/to/file +────────────────────────────────────────────────────────────────────────────────────────────────────" `; exports[`InputPrompt > snapshots > should render correctly when accepting edits 1`] = ` -"╭──────────────────────────────────────────────────────────────────────────────────────────────────╮ -│ > Type your message or @path/to/file │ -╰──────────────────────────────────────────────────────────────────────────────────────────────────╯" +"──────────────────────────────────────────────────────────────────────────────────────────────────── + > Type your message or @path/to/file +────────────────────────────────────────────────────────────────────────────────────────────────────" `; diff --git a/packages/cli/src/ui/components/__snapshots__/SessionSummaryDisplay.test.tsx.snap b/packages/cli/src/ui/components/__snapshots__/SessionSummaryDisplay.test.tsx.snap index 7c925f721..dfa39ba81 100644 --- a/packages/cli/src/ui/components/__snapshots__/SessionSummaryDisplay.test.tsx.snap +++ b/packages/cli/src/ui/components/__snapshots__/SessionSummaryDisplay.test.tsx.snap @@ -6,7 +6,7 @@ exports[` > renders the summary display with a title 1` │ Agent powering down. Goodbye! │ │ │ │ Interaction Summary │ -│ Session ID: │ +│ Session ID: test-session-id-12345 │ │ Tool Calls: 0 ( ✓ 0 x 0 ) │ │ Success Rate: 0.0% │ │ Code Changes: +42 -15 │ @@ -26,5 +26,7 @@ exports[` > renders the summary display with a title 1` │ │ │ » Tip: For a full token breakdown, run \`/stats model\`. │ │ │ -╰──────────────────────────────────────────────────────────────────────────────────────────────────╯" +╰──────────────────────────────────────────────────────────────────────────────────────────────────╯ + +To continue this session, run qwen --resume test-session-id-12345" `; diff --git a/packages/cli/src/ui/components/__snapshots__/SettingsDialog.test.tsx.snap b/packages/cli/src/ui/components/__snapshots__/SettingsDialog.test.tsx.snap index 7c2c04f9a..fbc2244b7 100644 --- a/packages/cli/src/ui/components/__snapshots__/SettingsDialog.test.tsx.snap +++ b/packages/cli/src/ui/components/__snapshots__/SettingsDialog.test.tsx.snap @@ -14,14 +14,14 @@ exports[`SettingsDialog > Snapshot Tests > should render default state correctly │ │ │ Language Auto (detect from system) │ │ │ +│ Terminal Bell true │ +│ │ │ Output Format Text │ │ │ │ Hide Window Title false │ │ │ │ Show Status in Title false │ │ │ -│ Hide Tips false │ -│ │ │ ▼ │ │ │ │ │ @@ -48,14 +48,14 @@ exports[`SettingsDialog > Snapshot Tests > should render focused on scope select │ │ │ Language Auto (detect from system) │ │ │ +│ Terminal Bell true │ +│ │ │ Output Format Text │ │ │ │ Hide Window Title false │ │ │ │ Show Status in Title false │ │ │ -│ Hide Tips false │ -│ │ │ ▼ │ │ │ │ │ @@ -82,14 +82,14 @@ exports[`SettingsDialog > Snapshot Tests > should render with accessibility sett │ │ │ Language Auto (detect from system) │ │ │ +│ Terminal Bell true │ +│ │ │ Output Format Text │ │ │ │ Hide Window Title false │ │ │ │ Show Status in Title false │ │ │ -│ Hide Tips false │ -│ │ │ ▼ │ │ │ │ │ @@ -116,14 +116,14 @@ exports[`SettingsDialog > Snapshot Tests > should render with all boolean settin │ │ │ Language Auto (detect from system) │ │ │ +│ Terminal Bell true │ +│ │ │ Output Format Text │ │ │ │ Hide Window Title false* │ │ │ │ Show Status in Title false │ │ │ -│ Hide Tips false* │ -│ │ │ ▼ │ │ │ │ │ @@ -150,14 +150,14 @@ exports[`SettingsDialog > Snapshot Tests > should render with different scope se │ │ │ Language Auto (detect from system) │ │ │ +│ Terminal Bell true │ +│ │ │ Output Format Text │ │ │ │ Hide Window Title false │ │ │ │ Show Status in Title false │ │ │ -│ Hide Tips false │ -│ │ │ ▼ │ │ │ │ │ @@ -184,14 +184,14 @@ exports[`SettingsDialog > Snapshot Tests > should render with different scope se │ │ │ Language Auto (detect from system) │ │ │ +│ Terminal Bell true │ +│ │ │ Output Format Text │ │ │ │ Hide Window Title false │ │ │ │ Show Status in Title false │ │ │ -│ Hide Tips false │ -│ │ │ ▼ │ │ │ │ │ @@ -218,14 +218,14 @@ exports[`SettingsDialog > Snapshot Tests > should render with file filtering set │ │ │ Language Auto (detect from system) │ │ │ +│ Terminal Bell true │ +│ │ │ Output Format Text │ │ │ │ Hide Window Title false │ │ │ │ Show Status in Title false │ │ │ -│ Hide Tips false │ -│ │ │ ▼ │ │ │ │ │ @@ -252,14 +252,14 @@ exports[`SettingsDialog > Snapshot Tests > should render with mixed boolean and │ │ │ Language Auto (detect from system) │ │ │ +│ Terminal Bell true │ +│ │ │ Output Format Text │ │ │ │ Hide Window Title false* │ │ │ │ Show Status in Title false │ │ │ -│ Hide Tips false │ -│ │ │ ▼ │ │ │ │ │ @@ -286,14 +286,14 @@ exports[`SettingsDialog > Snapshot Tests > should render with tools and security │ │ │ Language Auto (detect from system) │ │ │ +│ Terminal Bell true │ +│ │ │ Output Format Text │ │ │ │ Hide Window Title false │ │ │ │ Show Status in Title false │ │ │ -│ Hide Tips false │ -│ │ │ ▼ │ │ │ │ │ @@ -320,14 +320,14 @@ exports[`SettingsDialog > Snapshot Tests > should render with various boolean se │ │ │ Language Auto (detect from system) │ │ │ +│ Terminal Bell true │ +│ │ │ Output Format Text │ │ │ │ Hide Window Title true* │ │ │ │ Show Status in Title false │ │ │ -│ Hide Tips true* │ -│ │ │ ▼ │ │ │ │ │ diff --git a/packages/cli/src/ui/contexts/UIActionsContext.tsx b/packages/cli/src/ui/contexts/UIActionsContext.tsx index 4788f7fac..b9842c811 100644 --- a/packages/cli/src/ui/contexts/UIActionsContext.tsx +++ b/packages/cli/src/ui/contexts/UIActionsContext.tsx @@ -55,7 +55,6 @@ export interface UIActions { handleClearScreen: () => void; onWorkspaceMigrationDialogOpen: () => void; onWorkspaceMigrationDialogClose: () => void; - handleProQuotaChoice: (choice: 'auth' | 'continue') => void; // Vision switch dialog handleVisionSwitchSelect: (outcome: VisionSwitchOutcome) => void; // Welcome back dialog @@ -64,6 +63,10 @@ export interface UIActions { // Subagent dialogs closeSubagentCreateDialog: () => void; closeAgentsManagerDialog: () => void; + // Resume session dialog + openResumeDialog: () => void; + closeResumeDialog: () => void; + handleResume: (sessionId: string) => void; } export const UIActionsContext = createContext(null); diff --git a/packages/cli/src/ui/contexts/UIStateContext.tsx b/packages/cli/src/ui/contexts/UIStateContext.tsx index ac2f5f109..806cf09ba 100644 --- a/packages/cli/src/ui/contexts/UIStateContext.tsx +++ b/packages/cli/src/ui/contexts/UIStateContext.tsx @@ -22,21 +22,13 @@ import type { AuthType, IdeContext, ApprovalMode, - UserTierId, IdeInfo, - FallbackIntent, } from '@qwen-code/qwen-code-core'; import type { DOMElement } from 'ink'; import type { SessionStatsState } from '../contexts/SessionContext.js'; import type { ExtensionUpdateState } from '../state/extensions.js'; import type { UpdateObject } from '../utils/updateCheck.js'; -export interface ProQuotaDialogRequest { - failedModel: string; - fallbackModel: string; - resolve: (intent: FallbackIntent) => void; -} - import { type UseHistoryManagerReturn } from '../hooks/useHistoryManager.js'; import { type RestartReason } from '../hooks/useIdeTrustListener.js'; @@ -54,13 +46,13 @@ export interface UIState { qwenAuthState: QwenAuthState; editorError: string | null; isEditorDialogOpen: boolean; - corgiMode: boolean; debugMessage: string; quittingMessages: HistoryItem[] | null; isSettingsDialogOpen: boolean; isModelDialogOpen: boolean; isPermissionsDialogOpen: boolean; isApprovalModeDialogOpen: boolean; + isResumeDialogOpen: boolean; slashCommands: readonly SlashCommand[]; pendingSlashCommandHistoryItems: HistoryItemWithoutId[]; commandContext: CommandContext; @@ -99,8 +91,6 @@ export interface UIState { // eslint-disable-next-line @typescript-eslint/no-explicit-any workspaceExtensions: any[]; // Extension[] // Quota-related state - userTier: UserTierId | undefined; - proQuotaRequest: ProQuotaDialogRequest | null; currentModel: string; contextFileNames: string[]; errorCount: number; diff --git a/packages/cli/src/ui/hooks/slashCommandProcessor.test.ts b/packages/cli/src/ui/hooks/slashCommandProcessor.test.ts index 55fec0c39..42ce40993 100644 --- a/packages/cli/src/ui/hooks/slashCommandProcessor.test.ts +++ b/packages/cli/src/ui/hooks/slashCommandProcessor.test.ts @@ -153,7 +153,6 @@ describe('useSlashCommandProcessor', () => { openModelDialog: mockOpenModelDialog, quit: mockSetQuittingMessages, setDebugMessage: vi.fn(), - toggleCorgiMode: vi.fn(), }, ), ); @@ -909,7 +908,6 @@ describe('useSlashCommandProcessor', () => { vi.fn(), // openThemeDialog mockOpenAuthDialog, vi.fn(), // openEditorDialog - vi.fn(), // toggleCorgiMode mockSetQuittingMessages, vi.fn(), // openSettingsDialog vi.fn(), // openModelSelectionDialog diff --git a/packages/cli/src/ui/hooks/slashCommandProcessor.ts b/packages/cli/src/ui/hooks/slashCommandProcessor.ts index 553accb71..ac762904e 100644 --- a/packages/cli/src/ui/hooks/slashCommandProcessor.ts +++ b/packages/cli/src/ui/hooks/slashCommandProcessor.ts @@ -56,6 +56,7 @@ const SLASH_COMMANDS_SKIP_RECORDING = new Set([ 'clear', 'reset', 'new', + 'resume', ]); interface SlashCommandProcessorActions { @@ -66,9 +67,9 @@ interface SlashCommandProcessorActions { openModelDialog: () => void; openPermissionsDialog: () => void; openApprovalModeDialog: () => void; + openResumeDialog: () => void; quit: (messages: HistoryItem[]) => void; setDebugMessage: (message: string) => void; - toggleCorgiMode: () => void; dispatchExtensionStateUpdate: (action: ExtensionUpdateAction) => void; addConfirmUpdateExtensionRequest: (request: ConfirmationRequest) => void; openSubagentCreateDialog: () => void; @@ -206,7 +207,6 @@ export const useSlashCommandProcessor = ( setDebugMessage: actions.setDebugMessage, pendingItem, setPendingItem, - toggleCorgiMode: actions.toggleCorgiMode, toggleVimEnabled, setGeminiMdFileCount, reloadCommands, @@ -419,6 +419,9 @@ export const useSlashCommandProcessor = ( case 'approval-mode': actions.openApprovalModeDialog(); return { type: 'handled' }; + case 'resume': + actions.openResumeDialog(); + return { type: 'handled' }; case 'help': return { type: 'handled' }; default: { diff --git a/packages/cli/src/ui/hooks/useAttentionNotifications.test.ts b/packages/cli/src/ui/hooks/useAttentionNotifications.test.ts index 1475aa525..e8beb86fd 100644 --- a/packages/cli/src/ui/hooks/useAttentionNotifications.test.ts +++ b/packages/cli/src/ui/hooks/useAttentionNotifications.test.ts @@ -15,6 +15,23 @@ import { LONG_TASK_NOTIFICATION_THRESHOLD_SECONDS, useAttentionNotifications, } from './useAttentionNotifications.js'; +import type { LoadedSettings } from '../../config/settings.js'; + +const mockSettings: LoadedSettings = { + merged: { + general: { + terminalBell: true, + }, + }, +} as LoadedSettings; + +const mockSettingsDisabled: LoadedSettings = { + merged: { + general: { + terminalBell: false, + }, + }, +} as LoadedSettings; vi.mock('../../utils/attentionNotification.js', () => ({ notifyTerminalAttention: vi.fn(), @@ -40,6 +57,7 @@ describe('useAttentionNotifications', () => { isFocused: true, streamingState: StreamingState.Idle, elapsedTime: 0, + settings: mockSettings, ...props, }, }, @@ -53,11 +71,13 @@ describe('useAttentionNotifications', () => { isFocused: false, streamingState: StreamingState.WaitingForConfirmation, elapsedTime: 0, + settings: mockSettings, }, }); expect(mockedNotify).toHaveBeenCalledWith( AttentionNotificationReason.ToolApproval, + { enabled: true }, ); }); @@ -72,6 +92,7 @@ describe('useAttentionNotifications', () => { isFocused: false, streamingState: StreamingState.WaitingForConfirmation, elapsedTime: 0, + settings: mockSettings, }, }); @@ -86,6 +107,7 @@ describe('useAttentionNotifications', () => { isFocused: false, streamingState: StreamingState.Responding, elapsedTime: LONG_TASK_NOTIFICATION_THRESHOLD_SECONDS + 5, + settings: mockSettings, }, }); @@ -94,11 +116,13 @@ describe('useAttentionNotifications', () => { isFocused: false, streamingState: StreamingState.Idle, elapsedTime: 0, + settings: mockSettings, }, }); expect(mockedNotify).toHaveBeenCalledWith( AttentionNotificationReason.LongTaskComplete, + { enabled: true }, ); }); @@ -110,6 +134,7 @@ describe('useAttentionNotifications', () => { isFocused: true, streamingState: StreamingState.Responding, elapsedTime: LONG_TASK_NOTIFICATION_THRESHOLD_SECONDS + 2, + settings: mockSettings, }, }); @@ -118,6 +143,7 @@ describe('useAttentionNotifications', () => { isFocused: true, streamingState: StreamingState.Idle, elapsedTime: 0, + settings: mockSettings, }, }); @@ -135,6 +161,7 @@ describe('useAttentionNotifications', () => { isFocused: false, streamingState: StreamingState.Responding, elapsedTime: 5, + settings: mockSettings, }, }); @@ -143,9 +170,30 @@ describe('useAttentionNotifications', () => { isFocused: false, streamingState: StreamingState.Idle, elapsedTime: 0, + settings: mockSettings, }, }); expect(mockedNotify).not.toHaveBeenCalled(); }); + + it('does not notify when terminalBell setting is disabled', () => { + const { rerender } = render({ + settings: mockSettingsDisabled, + }); + + rerender({ + hookProps: { + isFocused: false, + streamingState: StreamingState.WaitingForConfirmation, + elapsedTime: 0, + settings: mockSettingsDisabled, + }, + }); + + expect(mockedNotify).toHaveBeenCalledWith( + AttentionNotificationReason.ToolApproval, + { enabled: false }, + ); + }); }); diff --git a/packages/cli/src/ui/hooks/useAttentionNotifications.ts b/packages/cli/src/ui/hooks/useAttentionNotifications.ts index e632c827a..7c5cd043a 100644 --- a/packages/cli/src/ui/hooks/useAttentionNotifications.ts +++ b/packages/cli/src/ui/hooks/useAttentionNotifications.ts @@ -10,6 +10,7 @@ import { notifyTerminalAttention, AttentionNotificationReason, } from '../../utils/attentionNotification.js'; +import type { LoadedSettings } from '../../config/settings.js'; export const LONG_TASK_NOTIFICATION_THRESHOLD_SECONDS = 20; @@ -17,13 +18,16 @@ interface UseAttentionNotificationsOptions { isFocused: boolean; streamingState: StreamingState; elapsedTime: number; + settings: LoadedSettings; } export const useAttentionNotifications = ({ isFocused, streamingState, elapsedTime, + settings, }: UseAttentionNotificationsOptions) => { + const terminalBellEnabled = settings?.merged?.general?.terminalBell ?? true; const awaitingNotificationSentRef = useRef(false); const respondingElapsedRef = useRef(0); @@ -33,14 +37,16 @@ export const useAttentionNotifications = ({ !isFocused && !awaitingNotificationSentRef.current ) { - notifyTerminalAttention(AttentionNotificationReason.ToolApproval); + notifyTerminalAttention(AttentionNotificationReason.ToolApproval, { + enabled: terminalBellEnabled, + }); awaitingNotificationSentRef.current = true; } if (streamingState !== StreamingState.WaitingForConfirmation || isFocused) { awaitingNotificationSentRef.current = false; } - }, [isFocused, streamingState]); + }, [isFocused, streamingState, terminalBellEnabled]); useEffect(() => { if (streamingState === StreamingState.Responding) { @@ -53,11 +59,13 @@ export const useAttentionNotifications = ({ respondingElapsedRef.current >= LONG_TASK_NOTIFICATION_THRESHOLD_SECONDS; if (wasLongTask && !isFocused) { - notifyTerminalAttention(AttentionNotificationReason.LongTaskComplete); + notifyTerminalAttention(AttentionNotificationReason.LongTaskComplete, { + enabled: terminalBellEnabled, + }); } // Reset tracking for next task respondingElapsedRef.current = 0; return; } - }, [streamingState, elapsedTime, isFocused]); + }, [streamingState, elapsedTime, isFocused, terminalBellEnabled]); }; diff --git a/packages/cli/src/ui/hooks/useGeminiStream.test.tsx b/packages/cli/src/ui/hooks/useGeminiStream.test.tsx index f82caa80d..332b6afe8 100644 --- a/packages/cli/src/ui/hooks/useGeminiStream.test.tsx +++ b/packages/cli/src/ui/hooks/useGeminiStream.test.tsx @@ -1323,7 +1323,7 @@ describe('useGeminiStream', () => { it('should call parseAndFormatApiError with the correct authType on stream initialization failure', async () => { // 1. Setup const mockError = new Error('Rate limit exceeded'); - const mockAuthType = AuthType.LOGIN_WITH_GOOGLE; + const mockAuthType = AuthType.USE_VERTEX_AI; mockParseAndFormatApiError.mockClear(); mockSendMessageStream.mockReturnValue( (async function* () { @@ -1374,9 +1374,6 @@ describe('useGeminiStream', () => { expect(mockParseAndFormatApiError).toHaveBeenCalledWith( 'Rate limit exceeded', mockAuthType, - undefined, - 'gemini-2.5-pro', - 'gemini-2.5-flash', ); }); }); @@ -2493,9 +2490,6 @@ describe('useGeminiStream', () => { expect(mockParseAndFormatApiError).toHaveBeenCalledWith( { message: 'Test error' }, expect.any(String), - undefined, - 'gemini-2.5-pro', - 'gemini-2.5-flash', ); }); }); diff --git a/packages/cli/src/ui/hooks/useGeminiStream.ts b/packages/cli/src/ui/hooks/useGeminiStream.ts index b4df01b01..e70ea0538 100644 --- a/packages/cli/src/ui/hooks/useGeminiStream.ts +++ b/packages/cli/src/ui/hooks/useGeminiStream.ts @@ -26,7 +26,6 @@ import { GitService, UnauthorizedError, UserPromptEvent, - DEFAULT_GEMINI_FLASH_MODEL, logConversationFinishedEvent, ConversationFinishedEvent, ApprovalMode, @@ -527,10 +526,15 @@ export const useGeminiStream = ( return currentThoughtBuffer; } - const newThoughtBuffer = currentThoughtBuffer + thoughtText; + let newThoughtBuffer = currentThoughtBuffer + thoughtText; + + const pendingType = pendingHistoryItemRef.current?.type; + const isPendingThought = + pendingType === 'gemini_thought' || + pendingType === 'gemini_thought_content'; // If we're not already showing a thought, start a new one - if (pendingHistoryItemRef.current?.type !== 'gemini_thought') { + if (!isPendingThought) { // If there's a pending non-thought item, finalize it first if (pendingHistoryItemRef.current) { addItem(pendingHistoryItemRef.current, userMessageTimestamp); @@ -538,11 +542,37 @@ export const useGeminiStream = ( setPendingHistoryItem({ type: 'gemini_thought', text: '' }); } - // Update the existing thought message with accumulated content - setPendingHistoryItem({ - type: 'gemini_thought', - text: newThoughtBuffer, - }); + // Split large thought messages for better rendering performance (same rationale + // as regular content streaming). This helps avoid terminal flicker caused by + // constantly re-rendering an ever-growing "pending" block. + const splitPoint = findLastSafeSplitPoint(newThoughtBuffer); + const nextPendingType: 'gemini_thought' | 'gemini_thought_content' = + isPendingThought && pendingType === 'gemini_thought_content' + ? 'gemini_thought_content' + : 'gemini_thought'; + + if (splitPoint === newThoughtBuffer.length) { + // Update the existing thought message with accumulated content + setPendingHistoryItem({ + type: nextPendingType, + text: newThoughtBuffer, + }); + } else { + const beforeText = newThoughtBuffer.substring(0, splitPoint); + const afterText = newThoughtBuffer.substring(splitPoint); + addItem( + { + type: nextPendingType, + text: beforeText, + }, + userMessageTimestamp, + ); + setPendingHistoryItem({ + type: 'gemini_thought_content', + text: afterText, + }); + newThoughtBuffer = afterText; + } // Also update the thought state for the loading indicator mergeThought(eventValue); @@ -600,9 +630,6 @@ export const useGeminiStream = ( text: parseAndFormatApiError( eventValue.error, config.getContentGeneratorConfig()?.authType, - undefined, - config.getModel(), - DEFAULT_GEMINI_FLASH_MODEL, ), }, userMessageTimestamp, @@ -654,6 +681,9 @@ export const useGeminiStream = ( 'Response stopped due to image safety violations.', [FinishReason.UNEXPECTED_TOOL_CALL]: 'Response stopped due to unexpected tool call.', + [FinishReason.IMAGE_PROHIBITED_CONTENT]: + 'Response stopped due to image prohibited content.', + [FinishReason.NO_IMAGE]: 'Response stopped due to no image.', }; const message = finishReasonMessages[finishReason]; @@ -770,11 +800,17 @@ export const useGeminiStream = ( for await (const event of stream) { switch (event.type) { case ServerGeminiEventType.Thought: - thoughtBuffer = handleThoughtEvent( - event.value, - thoughtBuffer, - userMessageTimestamp, - ); + // If the thought has a subject, it's a discrete status update rather than + // a streamed textual thought, so we update the thought state directly. + if (event.value.subject) { + setThought(event.value); + } else { + thoughtBuffer = handleThoughtEvent( + event.value, + thoughtBuffer, + userMessageTimestamp, + ); + } break; case ServerGeminiEventType.Content: geminiMessageBuffer = handleContentEvent( @@ -845,6 +881,7 @@ export const useGeminiStream = ( handleMaxSessionTurnsEvent, handleSessionTokenLimitExceededEvent, handleCitationEvent, + setThought, ], ); @@ -987,9 +1024,6 @@ export const useGeminiStream = ( text: parseAndFormatApiError( getErrorMessage(error) || 'Unknown error', config.getContentGeneratorConfig()?.authType, - undefined, - config.getModel(), - DEFAULT_GEMINI_FLASH_MODEL, ), }, userMessageTimestamp, diff --git a/packages/cli/src/ui/hooks/useQuotaAndFallback.test.ts b/packages/cli/src/ui/hooks/useQuotaAndFallback.test.ts deleted file mode 100644 index 1bd06895c..000000000 --- a/packages/cli/src/ui/hooks/useQuotaAndFallback.test.ts +++ /dev/null @@ -1,391 +0,0 @@ -/** - * @license - * Copyright 2025 Google LLC - * SPDX-License-Identifier: Apache-2.0 - */ - -import { - vi, - describe, - it, - expect, - beforeEach, - afterEach, - type Mock, -} from 'vitest'; -import { act, renderHook } from '@testing-library/react'; -import { - type Config, - type FallbackModelHandler, - UserTierId, - AuthType, - isGenericQuotaExceededError, - isProQuotaExceededError, - makeFakeConfig, -} from '@qwen-code/qwen-code-core'; -import { useQuotaAndFallback } from './useQuotaAndFallback.js'; -import type { UseHistoryManagerReturn } from './useHistoryManager.js'; -import { AuthState, MessageType } from '../types.js'; - -// Mock the error checking functions from the core package to control test scenarios -vi.mock('@qwen-code/qwen-code-core', async (importOriginal) => { - const original = - await importOriginal(); - return { - ...original, - isGenericQuotaExceededError: vi.fn(), - isProQuotaExceededError: vi.fn(), - }; -}); - -// Use a type alias for SpyInstance as it's not directly exported -type SpyInstance = ReturnType; - -describe('useQuotaAndFallback', () => { - let mockConfig: Config; - let mockHistoryManager: UseHistoryManagerReturn; - let mockSetAuthState: Mock; - let mockSetModelSwitchedFromQuotaError: Mock; - let setFallbackHandlerSpy: SpyInstance; - - const mockedIsGenericQuotaExceededError = isGenericQuotaExceededError as Mock; - const mockedIsProQuotaExceededError = isProQuotaExceededError as Mock; - - beforeEach(() => { - mockConfig = makeFakeConfig(); - - // Spy on the method that requires the private field and mock its return. - // This is cleaner than modifying the config class for tests. - vi.spyOn(mockConfig, 'getContentGeneratorConfig').mockReturnValue({ - model: 'test-model', - authType: AuthType.LOGIN_WITH_GOOGLE, - }); - - mockHistoryManager = { - addItem: vi.fn(), - history: [], - updateItem: vi.fn(), - clearItems: vi.fn(), - loadHistory: vi.fn(), - }; - mockSetAuthState = vi.fn(); - mockSetModelSwitchedFromQuotaError = vi.fn(); - - setFallbackHandlerSpy = vi.spyOn(mockConfig, 'setFallbackModelHandler'); - vi.spyOn(mockConfig, 'setQuotaErrorOccurred'); - - mockedIsGenericQuotaExceededError.mockReturnValue(false); - mockedIsProQuotaExceededError.mockReturnValue(false); - }); - - afterEach(() => { - vi.clearAllMocks(); - }); - - it('should register a fallback handler on initialization', () => { - renderHook(() => - useQuotaAndFallback({ - config: mockConfig, - historyManager: mockHistoryManager, - userTier: UserTierId.FREE, - setAuthState: mockSetAuthState, - setModelSwitchedFromQuotaError: mockSetModelSwitchedFromQuotaError, - }), - ); - - expect(setFallbackHandlerSpy).toHaveBeenCalledTimes(1); - expect(setFallbackHandlerSpy.mock.calls[0][0]).toBeInstanceOf(Function); - }); - - describe('Fallback Handler Logic', () => { - // Helper function to render the hook and extract the registered handler - const getRegisteredHandler = ( - userTier: UserTierId = UserTierId.FREE, - ): FallbackModelHandler => { - renderHook( - (props) => - useQuotaAndFallback({ - config: mockConfig, - historyManager: mockHistoryManager, - userTier: props.userTier, - setAuthState: mockSetAuthState, - setModelSwitchedFromQuotaError: mockSetModelSwitchedFromQuotaError, - }), - { initialProps: { userTier } }, - ); - return setFallbackHandlerSpy.mock.calls[0][0] as FallbackModelHandler; - }; - - it('should return null and take no action if already in fallback mode', async () => { - vi.spyOn(mockConfig, 'isInFallbackMode').mockReturnValue(true); - const handler = getRegisteredHandler(); - const result = await handler('gemini-pro', 'gemini-flash', new Error()); - - expect(result).toBeNull(); - expect(mockHistoryManager.addItem).not.toHaveBeenCalled(); - }); - - it('should return null and take no action if authType is not LOGIN_WITH_GOOGLE', async () => { - // Override the default mock from beforeEach for this specific test - vi.spyOn(mockConfig, 'getContentGeneratorConfig').mockReturnValue({ - model: 'test-model', - authType: AuthType.USE_GEMINI, - }); - - const handler = getRegisteredHandler(); - const result = await handler('gemini-pro', 'gemini-flash', new Error()); - - expect(result).toBeNull(); - expect(mockHistoryManager.addItem).not.toHaveBeenCalled(); - }); - - describe('Automatic Fallback Scenarios', () => { - const testCases = [ - { - errorType: 'generic', - tier: UserTierId.FREE, - expectedMessageSnippets: [ - 'Automatically switching from model-A to model-B', - 'upgrade to a Gemini Code Assist Standard or Enterprise plan', - ], - }, - { - errorType: 'generic', - tier: UserTierId.STANDARD, // Paid tier - expectedMessageSnippets: [ - 'Automatically switching from model-A to model-B', - 'switch to using a paid API key from AI Studio', - ], - }, - { - errorType: 'other', - tier: UserTierId.FREE, - expectedMessageSnippets: [ - 'Automatically switching from model-A to model-B for faster responses', - 'upgrade to a Gemini Code Assist Standard or Enterprise plan', - ], - }, - { - errorType: 'other', - tier: UserTierId.LEGACY, // Paid tier - expectedMessageSnippets: [ - 'Automatically switching from model-A to model-B for faster responses', - 'switch to using a paid API key from AI Studio', - ], - }, - ]; - - for (const { errorType, tier, expectedMessageSnippets } of testCases) { - it(`should handle ${errorType} error for ${tier} tier correctly`, async () => { - mockedIsGenericQuotaExceededError.mockReturnValue( - errorType === 'generic', - ); - - const handler = getRegisteredHandler(tier); - const result = await handler( - 'model-A', - 'model-B', - new Error('quota exceeded'), - ); - - // Automatic fallbacks should return 'stop' - expect(result).toBe('stop'); - - expect(mockHistoryManager.addItem).toHaveBeenCalledWith( - expect.objectContaining({ type: MessageType.INFO }), - expect.any(Number), - ); - - const message = (mockHistoryManager.addItem as Mock).mock.calls[0][0] - .text; - for (const snippet of expectedMessageSnippets) { - expect(message).toContain(snippet); - } - - expect(mockSetModelSwitchedFromQuotaError).toHaveBeenCalledWith(true); - expect(mockConfig.setQuotaErrorOccurred).toHaveBeenCalledWith(true); - }); - } - }); - - describe('Interactive Fallback (Pro Quota Error)', () => { - beforeEach(() => { - mockedIsProQuotaExceededError.mockReturnValue(true); - }); - - it('should set an interactive request and wait for user choice', async () => { - const { result } = renderHook(() => - useQuotaAndFallback({ - config: mockConfig, - historyManager: mockHistoryManager, - userTier: UserTierId.FREE, - setAuthState: mockSetAuthState, - setModelSwitchedFromQuotaError: mockSetModelSwitchedFromQuotaError, - }), - ); - - const handler = setFallbackHandlerSpy.mock - .calls[0][0] as FallbackModelHandler; - - // Call the handler but do not await it, to check the intermediate state - const promise = handler( - 'gemini-pro', - 'gemini-flash', - new Error('pro quota'), - ); - - await act(async () => {}); - - // The hook should now have a pending request for the UI to handle - expect(result.current.proQuotaRequest).not.toBeNull(); - expect(result.current.proQuotaRequest?.failedModel).toBe('gemini-pro'); - - // Simulate the user choosing to continue with the fallback model - act(() => { - result.current.handleProQuotaChoice('continue'); - }); - - // The original promise from the handler should now resolve - const intent = await promise; - expect(intent).toBe('retry'); - - // The pending request should be cleared from the state - expect(result.current.proQuotaRequest).toBeNull(); - }); - - it('should handle race conditions by stopping subsequent requests', async () => { - const { result } = renderHook(() => - useQuotaAndFallback({ - config: mockConfig, - historyManager: mockHistoryManager, - userTier: UserTierId.FREE, - setAuthState: mockSetAuthState, - setModelSwitchedFromQuotaError: mockSetModelSwitchedFromQuotaError, - }), - ); - - const handler = setFallbackHandlerSpy.mock - .calls[0][0] as FallbackModelHandler; - - const promise1 = handler( - 'gemini-pro', - 'gemini-flash', - new Error('pro quota 1'), - ); - await act(async () => {}); - - const firstRequest = result.current.proQuotaRequest; - expect(firstRequest).not.toBeNull(); - - const result2 = await handler( - 'gemini-pro', - 'gemini-flash', - new Error('pro quota 2'), - ); - - // The lock should have stopped the second request - expect(result2).toBe('stop'); - expect(result.current.proQuotaRequest).toBe(firstRequest); - - act(() => { - result.current.handleProQuotaChoice('continue'); - }); - - const intent1 = await promise1; - expect(intent1).toBe('retry'); - expect(result.current.proQuotaRequest).toBeNull(); - }); - }); - }); - - describe('handleProQuotaChoice', () => { - beforeEach(() => { - mockedIsProQuotaExceededError.mockReturnValue(true); - }); - - it('should do nothing if there is no pending pro quota request', () => { - const { result } = renderHook(() => - useQuotaAndFallback({ - config: mockConfig, - historyManager: mockHistoryManager, - userTier: UserTierId.FREE, - setAuthState: mockSetAuthState, - setModelSwitchedFromQuotaError: mockSetModelSwitchedFromQuotaError, - }), - ); - - act(() => { - result.current.handleProQuotaChoice('auth'); - }); - - expect(mockSetAuthState).not.toHaveBeenCalled(); - expect(mockHistoryManager.addItem).not.toHaveBeenCalled(); - }); - - it('should resolve intent to "auth" and trigger auth state update', async () => { - const { result } = renderHook(() => - useQuotaAndFallback({ - config: mockConfig, - historyManager: mockHistoryManager, - userTier: UserTierId.FREE, - setAuthState: mockSetAuthState, - setModelSwitchedFromQuotaError: mockSetModelSwitchedFromQuotaError, - }), - ); - - const handler = setFallbackHandlerSpy.mock - .calls[0][0] as FallbackModelHandler; - const promise = handler( - 'gemini-pro', - 'gemini-flash', - new Error('pro quota'), - ); - await act(async () => {}); // Allow state to update - - act(() => { - result.current.handleProQuotaChoice('auth'); - }); - - const intent = await promise; - expect(intent).toBe('auth'); - expect(mockSetAuthState).toHaveBeenCalledWith(AuthState.Updating); - expect(result.current.proQuotaRequest).toBeNull(); - }); - - it('should resolve intent to "retry" and add info message on continue', async () => { - const { result } = renderHook(() => - useQuotaAndFallback({ - config: mockConfig, - historyManager: mockHistoryManager, - userTier: UserTierId.FREE, - setAuthState: mockSetAuthState, - setModelSwitchedFromQuotaError: mockSetModelSwitchedFromQuotaError, - }), - ); - - const handler = setFallbackHandlerSpy.mock - .calls[0][0] as FallbackModelHandler; - // The first `addItem` call is for the initial quota error message - const promise = handler( - 'gemini-pro', - 'gemini-flash', - new Error('pro quota'), - ); - await act(async () => {}); // Allow state to update - - act(() => { - result.current.handleProQuotaChoice('continue'); - }); - - const intent = await promise; - expect(intent).toBe('retry'); - expect(result.current.proQuotaRequest).toBeNull(); - - // Check for the second "Switched to fallback model" message - expect(mockHistoryManager.addItem).toHaveBeenCalledTimes(2); - const lastCall = (mockHistoryManager.addItem as Mock).mock.calls[1][0]; - expect(lastCall.type).toBe(MessageType.INFO); - expect(lastCall.text).toContain('Switched to fallback model.'); - }); - }); -}); diff --git a/packages/cli/src/ui/hooks/useQuotaAndFallback.ts b/packages/cli/src/ui/hooks/useQuotaAndFallback.ts deleted file mode 100644 index 75319ae78..000000000 --- a/packages/cli/src/ui/hooks/useQuotaAndFallback.ts +++ /dev/null @@ -1,175 +0,0 @@ -/** - * @license - * Copyright 2025 Google LLC - * SPDX-License-Identifier: Apache-2.0 - */ - -import { - AuthType, - type Config, - type FallbackModelHandler, - type FallbackIntent, - isGenericQuotaExceededError, - isProQuotaExceededError, - UserTierId, -} from '@qwen-code/qwen-code-core'; -import { useCallback, useEffect, useRef, useState } from 'react'; -import { type UseHistoryManagerReturn } from './useHistoryManager.js'; -import { AuthState, MessageType } from '../types.js'; -import { type ProQuotaDialogRequest } from '../contexts/UIStateContext.js'; - -interface UseQuotaAndFallbackArgs { - config: Config; - historyManager: UseHistoryManagerReturn; - userTier: UserTierId | undefined; - setAuthState: (state: AuthState) => void; - setModelSwitchedFromQuotaError: (value: boolean) => void; -} - -export function useQuotaAndFallback({ - config, - historyManager, - userTier, - setAuthState, - setModelSwitchedFromQuotaError, -}: UseQuotaAndFallbackArgs) { - const [proQuotaRequest, setProQuotaRequest] = - useState(null); - const isDialogPending = useRef(false); - - // Set up Flash fallback handler - useEffect(() => { - const fallbackHandler: FallbackModelHandler = async ( - failedModel, - fallbackModel, - error, - ): Promise => { - if (config.isInFallbackMode()) { - return null; - } - - // Fallbacks are currently only handled for OAuth users. - const contentGeneratorConfig = config.getContentGeneratorConfig(); - if ( - !contentGeneratorConfig || - contentGeneratorConfig.authType !== AuthType.LOGIN_WITH_GOOGLE - ) { - return null; - } - - // Use actual user tier if available; otherwise, default to FREE tier behavior (safe default) - const isPaidTier = - userTier === UserTierId.LEGACY || userTier === UserTierId.STANDARD; - - let message: string; - - if (error && isProQuotaExceededError(error)) { - // Pro Quota specific messages (Interactive) - if (isPaidTier) { - message = `⚡ You have reached your daily ${failedModel} quota limit. -⚡ You can choose to authenticate with a paid API key or continue with the fallback model. -⚡ To continue accessing the ${failedModel} model today, consider using /auth to switch to using a paid API key from AI Studio at https://aistudio.google.com/apikey`; - } else { - message = `⚡ You have reached your daily ${failedModel} quota limit. -⚡ You can choose to authenticate with a paid API key or continue with the fallback model. -⚡ To increase your limits, upgrade to a Gemini Code Assist Standard or Enterprise plan with higher limits at https://goo.gle/set-up-gemini-code-assist -⚡ Or you can utilize a Gemini API Key. See: https://goo.gle/gemini-cli-docs-auth#gemini-api-key -⚡ You can switch authentication methods by typing /auth`; - } - } else if (error && isGenericQuotaExceededError(error)) { - // Generic Quota (Automatic fallback) - const actionMessage = `⚡ You have reached your daily quota limit.\n⚡ Automatically switching from ${failedModel} to ${fallbackModel} for the remainder of this session.`; - - if (isPaidTier) { - message = `${actionMessage} -⚡ To continue accessing the ${failedModel} model today, consider using /auth to switch to using a paid API key from AI Studio at https://aistudio.google.com/apikey`; - } else { - message = `${actionMessage} -⚡ To increase your limits, upgrade to a Gemini Code Assist Standard or Enterprise plan with higher limits at https://goo.gle/set-up-gemini-code-assist -⚡ Or you can utilize a Gemini API Key. See: https://goo.gle/gemini-cli-docs-auth#gemini-api-key -⚡ You can switch authentication methods by typing /auth`; - } - } else { - // Consecutive 429s or other errors (Automatic fallback) - const actionMessage = `⚡ Automatically switching from ${failedModel} to ${fallbackModel} for faster responses for the remainder of this session.`; - - if (isPaidTier) { - message = `${actionMessage} -⚡ Possible reasons for this are that you have received multiple consecutive capacity errors or you have reached your daily ${failedModel} quota limit -⚡ To continue accessing the ${failedModel} model today, consider using /auth to switch to using a paid API key from AI Studio at https://aistudio.google.com/apikey`; - } else { - message = `${actionMessage} -⚡ Possible reasons for this are that you have received multiple consecutive capacity errors or you have reached your daily ${failedModel} quota limit -⚡ To increase your limits, upgrade to a Gemini Code Assist Standard or Enterprise plan with higher limits at https://goo.gle/set-up-gemini-code-assist -⚡ Or you can utilize a Gemini API Key. See: https://goo.gle/gemini-cli-docs-auth#gemini-api-key -⚡ You can switch authentication methods by typing /auth`; - } - } - - // Add message to UI history - historyManager.addItem( - { - type: MessageType.INFO, - text: message, - }, - Date.now(), - ); - - setModelSwitchedFromQuotaError(true); - config.setQuotaErrorOccurred(true); - - // Interactive Fallback for Pro quota - if (error && isProQuotaExceededError(error)) { - if (isDialogPending.current) { - return 'stop'; // A dialog is already active, so just stop this request. - } - isDialogPending.current = true; - - const intent: FallbackIntent = await new Promise( - (resolve) => { - setProQuotaRequest({ - failedModel, - fallbackModel, - resolve, - }); - }, - ); - - return intent; - } - - return 'stop'; - }; - - config.setFallbackModelHandler(fallbackHandler); - }, [config, historyManager, userTier, setModelSwitchedFromQuotaError]); - - const handleProQuotaChoice = useCallback( - (choice: 'auth' | 'continue') => { - if (!proQuotaRequest) return; - - const intent: FallbackIntent = choice === 'auth' ? 'auth' : 'retry'; - proQuotaRequest.resolve(intent); - setProQuotaRequest(null); - isDialogPending.current = false; // Reset the flag here - - if (choice === 'auth') { - setAuthState(AuthState.Updating); - } else { - historyManager.addItem( - { - type: MessageType.INFO, - text: 'Switched to fallback model. Tip: Press Ctrl+P (or Up Arrow) to recall your previous prompt and submit it again if you wish.', - }, - Date.now(), - ); - } - }, - [proQuotaRequest, setAuthState, historyManager], - ); - - return { - proQuotaRequest, - handleProQuotaChoice, - }; -} diff --git a/packages/cli/src/ui/hooks/useQwenAuth.test.ts b/packages/cli/src/ui/hooks/useQwenAuth.test.ts index 06644a00e..43611afe5 100644 --- a/packages/cli/src/ui/hooks/useQwenAuth.test.ts +++ b/packages/cli/src/ui/hooks/useQwenAuth.test.ts @@ -411,7 +411,7 @@ describe('useQwenAuth', () => { expect(geminiResult.current.qwenAuthState.authStatus).toBe('idle'); const { result: oauthResult } = renderHook(() => - useQwenAuth(AuthType.LOGIN_WITH_GOOGLE, true), + useQwenAuth(AuthType.USE_OPENAI, true), ); expect(oauthResult.current.qwenAuthState.authStatus).toBe('idle'); }); diff --git a/packages/cli/src/ui/hooks/useResumeCommand.test.ts b/packages/cli/src/ui/hooks/useResumeCommand.test.ts new file mode 100644 index 000000000..daaedfcce --- /dev/null +++ b/packages/cli/src/ui/hooks/useResumeCommand.test.ts @@ -0,0 +1,190 @@ +/** + * @license + * Copyright 2025 Qwen Code + * SPDX-License-Identifier: Apache-2.0 + */ + +import { act, renderHook } from '@testing-library/react'; +import { describe, it, expect, vi } from 'vitest'; +import { useResumeCommand } from './useResumeCommand.js'; + +const resumeMocks = vi.hoisted(() => { + let resolveLoadSession: + | ((value: { conversation: unknown } | undefined) => void) + | undefined; + let pendingLoadSession: + | Promise<{ conversation: unknown } | undefined> + | undefined; + + return { + createPendingLoadSession() { + pendingLoadSession = new Promise((resolve) => { + resolveLoadSession = resolve; + }); + return pendingLoadSession; + }, + resolvePendingLoadSession(value: { conversation: unknown } | undefined) { + resolveLoadSession?.(value); + }, + getPendingLoadSession() { + return pendingLoadSession; + }, + reset() { + resolveLoadSession = undefined; + pendingLoadSession = undefined; + }, + }; +}); + +vi.mock('../utils/resumeHistoryUtils.js', () => ({ + buildResumedHistoryItems: vi.fn(() => [{ id: 1, type: 'user', text: 'hi' }]), +})); + +vi.mock('@qwen-code/qwen-code-core', () => { + class SessionService { + constructor(_cwd: string) {} + async loadSession(_sessionId: string) { + return ( + resumeMocks.getPendingLoadSession() ?? + Promise.resolve({ + conversation: [{ role: 'user', parts: [{ text: 'hello' }] }], + }) + ); + } + } + + return { + SessionService, + }; +}); + +describe('useResumeCommand', () => { + it('should initialize with dialog closed', () => { + const { result } = renderHook(() => useResumeCommand()); + + expect(result.current.isResumeDialogOpen).toBe(false); + }); + + it('should open the dialog when openResumeDialog is called', () => { + const { result } = renderHook(() => useResumeCommand()); + + act(() => { + result.current.openResumeDialog(); + }); + + expect(result.current.isResumeDialogOpen).toBe(true); + }); + + it('should close the dialog when closeResumeDialog is called', () => { + const { result } = renderHook(() => useResumeCommand()); + + // Open the dialog first + act(() => { + result.current.openResumeDialog(); + }); + + expect(result.current.isResumeDialogOpen).toBe(true); + + // Close the dialog + act(() => { + result.current.closeResumeDialog(); + }); + + expect(result.current.isResumeDialogOpen).toBe(false); + }); + + it('should maintain stable function references across renders', () => { + const { result, rerender } = renderHook(() => useResumeCommand()); + + const initialOpenFn = result.current.openResumeDialog; + const initialCloseFn = result.current.closeResumeDialog; + const initialHandleResume = result.current.handleResume; + + rerender(); + + expect(result.current.openResumeDialog).toBe(initialOpenFn); + expect(result.current.closeResumeDialog).toBe(initialCloseFn); + expect(result.current.handleResume).toBe(initialHandleResume); + }); + + it('handleResume no-ops when config is null', async () => { + const historyManager = { clearItems: vi.fn(), loadHistory: vi.fn() }; + const startNewSession = vi.fn(); + + const { result } = renderHook(() => + useResumeCommand({ + config: null, + historyManager, + startNewSession, + }), + ); + + await act(async () => { + await result.current.handleResume('session-1'); + }); + + expect(startNewSession).not.toHaveBeenCalled(); + expect(historyManager.clearItems).not.toHaveBeenCalled(); + expect(historyManager.loadHistory).not.toHaveBeenCalled(); + }); + + it('handleResume closes the dialog immediately and restores session state', async () => { + resumeMocks.reset(); + resumeMocks.createPendingLoadSession(); + + const historyManager = { clearItems: vi.fn(), loadHistory: vi.fn() }; + const startNewSession = vi.fn(); + const geminiClient = { + initialize: vi.fn(), + }; + + const config = { + getTargetDir: () => '/tmp', + getGeminiClient: () => geminiClient, + startNewSession: vi.fn(), + } as unknown as import('@qwen-code/qwen-code-core').Config; + + const { result } = renderHook(() => + useResumeCommand({ + config, + historyManager, + startNewSession, + }), + ); + + // Open first so we can verify the dialog closes immediately. + act(() => { + result.current.openResumeDialog(); + }); + expect(result.current.isResumeDialogOpen).toBe(true); + + let resumePromise: Promise | undefined; + act(() => { + // Start resume but do not await it yet — we want to assert the dialog + // closes immediately before the async session load completes. + resumePromise = result.current.handleResume('session-2') as unknown as + | Promise + | undefined; + }); + expect(result.current.isResumeDialogOpen).toBe(false); + + // Now finish the async load and let the handler complete. + resumeMocks.resolvePendingLoadSession({ + conversation: [{ role: 'user', parts: [{ text: 'hello' }] }], + }); + await act(async () => { + await resumePromise; + }); + + expect(config.startNewSession).toHaveBeenCalledWith( + 'session-2', + expect.objectContaining({ + conversation: expect.anything(), + }), + ); + expect(startNewSession).toHaveBeenCalledWith('session-2'); + expect(geminiClient.initialize).toHaveBeenCalledTimes(1); + expect(historyManager.clearItems).toHaveBeenCalledTimes(1); + expect(historyManager.loadHistory).toHaveBeenCalledTimes(1); + }); +}); diff --git a/packages/cli/src/ui/hooks/useResumeCommand.ts b/packages/cli/src/ui/hooks/useResumeCommand.ts new file mode 100644 index 000000000..8fc3d4ddf --- /dev/null +++ b/packages/cli/src/ui/hooks/useResumeCommand.ts @@ -0,0 +1,82 @@ +/** + * @license + * Copyright 2025 Qwen Code + * SPDX-License-Identifier: Apache-2.0 + */ + +import { useState, useCallback } from 'react'; +import { SessionService, type Config } from '@qwen-code/qwen-code-core'; +import { buildResumedHistoryItems } from '../utils/resumeHistoryUtils.js'; +import type { UseHistoryManagerReturn } from './useHistoryManager.js'; + +export interface UseResumeCommandOptions { + config: Config | null; + historyManager: Pick; + startNewSession: (sessionId: string) => void; + remount?: () => void; +} + +export interface UseResumeCommandResult { + isResumeDialogOpen: boolean; + openResumeDialog: () => void; + closeResumeDialog: () => void; + handleResume: (sessionId: string) => void; +} + +export function useResumeCommand( + options?: UseResumeCommandOptions, +): UseResumeCommandResult { + const [isResumeDialogOpen, setIsResumeDialogOpen] = useState(false); + + const openResumeDialog = useCallback(() => { + setIsResumeDialogOpen(true); + }, []); + + const closeResumeDialog = useCallback(() => { + setIsResumeDialogOpen(false); + }, []); + + const { config, historyManager, startNewSession, remount } = options ?? {}; + + const handleResume = useCallback( + async (sessionId: string) => { + if (!config || !historyManager || !startNewSession) { + return; + } + + // Close dialog immediately to prevent input capture during async operations. + closeResumeDialog(); + + const cwd = config.getTargetDir(); + const sessionService = new SessionService(cwd); + const sessionData = await sessionService.loadSession(sessionId); + + if (!sessionData) { + return; + } + + // Start new session in UI context. + startNewSession(sessionId); + + // Reset UI history. + const uiHistoryItems = buildResumedHistoryItems(sessionData, config); + historyManager.clearItems(); + historyManager.loadHistory(uiHistoryItems); + + // Update session history core. + config.startNewSession(sessionId, sessionData); + await config.getGeminiClient()?.initialize?.(); + + // Refresh terminal UI. + remount?.(); + }, + [closeResumeDialog, config, historyManager, startNewSession, remount], + ); + + return { + isResumeDialogOpen, + openResumeDialog, + closeResumeDialog, + handleResume, + }; +} diff --git a/packages/cli/src/ui/hooks/useSessionPicker.ts b/packages/cli/src/ui/hooks/useSessionPicker.ts new file mode 100644 index 000000000..7d451466a --- /dev/null +++ b/packages/cli/src/ui/hooks/useSessionPicker.ts @@ -0,0 +1,279 @@ +/** + * @license + * Copyright 2025 Qwen Code + * SPDX-License-Identifier: Apache-2.0 + */ + +/** + * Unified session picker hook for both dialog and standalone modes. + * + * IMPORTANT: + * - Uses KeypressContext (`useKeypress`) so it behaves correctly inside the main app. + * - Standalone mode should wrap the picker in `` when rendered + * outside the main app. + */ + +import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; +import type { + ListSessionsResult, + SessionListItem, + SessionService, +} from '@qwen-code/qwen-code-core'; +import { + filterSessions, + SESSION_PAGE_SIZE, + type SessionState, +} from '../utils/sessionPickerUtils.js'; +import { useKeypress } from './useKeypress.js'; + +export interface UseSessionPickerOptions { + sessionService: SessionService | null; + currentBranch?: string; + onSelect: (sessionId: string) => void; + onCancel: () => void; + maxVisibleItems: number; + /** + * If true, computes centered scroll offset (keeps selection near middle). + * If false, uses follow mode (scrolls when selection reaches edge). + */ + centerSelection?: boolean; + /** + * Enable/disable input handling. + */ + isActive?: boolean; +} + +export interface UseSessionPickerResult { + selectedIndex: number; + sessionState: SessionState; + filteredSessions: SessionListItem[]; + filterByBranch: boolean; + isLoading: boolean; + scrollOffset: number; + visibleSessions: SessionListItem[]; + showScrollUp: boolean; + showScrollDown: boolean; + loadMoreSessions: () => Promise; +} + +export function useSessionPicker({ + sessionService, + currentBranch, + onSelect, + onCancel, + maxVisibleItems, + centerSelection = false, + isActive = true, +}: UseSessionPickerOptions): UseSessionPickerResult { + const [selectedIndex, setSelectedIndex] = useState(0); + const [sessionState, setSessionState] = useState({ + sessions: [], + hasMore: true, + nextCursor: undefined, + }); + const [filterByBranch, setFilterByBranch] = useState(false); + const [isLoading, setIsLoading] = useState(true); + + // For follow mode (non-centered) + const [followScrollOffset, setFollowScrollOffset] = useState(0); + + const isLoadingMoreRef = useRef(false); + + const filteredSessions = useMemo( + () => filterSessions(sessionState.sessions, filterByBranch, currentBranch), + [sessionState.sessions, filterByBranch, currentBranch], + ); + + const scrollOffset = useMemo(() => { + if (centerSelection) { + if (filteredSessions.length <= maxVisibleItems) { + return 0; + } + const halfVisible = Math.floor(maxVisibleItems / 2); + let offset = selectedIndex - halfVisible; + offset = Math.max(0, offset); + offset = Math.min(filteredSessions.length - maxVisibleItems, offset); + return offset; + } + return followScrollOffset; + }, [ + centerSelection, + filteredSessions.length, + followScrollOffset, + maxVisibleItems, + selectedIndex, + ]); + + const visibleSessions = useMemo( + () => filteredSessions.slice(scrollOffset, scrollOffset + maxVisibleItems), + [filteredSessions, maxVisibleItems, scrollOffset], + ); + const showScrollUp = scrollOffset > 0; + const showScrollDown = + scrollOffset + maxVisibleItems < filteredSessions.length; + + // Initial load + useEffect(() => { + if (!sessionService) { + return; + } + + const loadInitialSessions = async () => { + try { + const result: ListSessionsResult = await sessionService.listSessions({ + size: SESSION_PAGE_SIZE, + }); + setSessionState({ + sessions: result.items, + hasMore: result.hasMore, + nextCursor: result.nextCursor, + }); + } finally { + setIsLoading(false); + } + }; + + void loadInitialSessions(); + }, [sessionService]); + + const loadMoreSessions = useCallback(async () => { + if (!sessionService || !sessionState.hasMore || isLoadingMoreRef.current) { + return; + } + + isLoadingMoreRef.current = true; + try { + const result: ListSessionsResult = await sessionService.listSessions({ + size: SESSION_PAGE_SIZE, + cursor: sessionState.nextCursor, + }); + setSessionState((prev) => ({ + sessions: [...prev.sessions, ...result.items], + hasMore: result.hasMore && result.nextCursor !== undefined, + nextCursor: result.nextCursor, + })); + } finally { + isLoadingMoreRef.current = false; + } + }, [sessionService, sessionState.hasMore, sessionState.nextCursor]); + + // Reset selection when filter changes + useEffect(() => { + setSelectedIndex(0); + setFollowScrollOffset(0); + }, [filterByBranch]); + + // Ensure selectedIndex is valid when filtered sessions change + useEffect(() => { + if ( + selectedIndex >= filteredSessions.length && + filteredSessions.length > 0 + ) { + setSelectedIndex(filteredSessions.length - 1); + } + }, [filteredSessions.length, selectedIndex]); + + // Auto-load more when centered mode hits the sentinel or list is empty. + useEffect(() => { + if ( + isLoading || + !sessionState.hasMore || + isLoadingMoreRef.current || + !centerSelection + ) { + return; + } + + const sentinelVisible = + scrollOffset + maxVisibleItems >= filteredSessions.length; + const shouldLoadMore = filteredSessions.length === 0 || sentinelVisible; + + if (shouldLoadMore) { + void loadMoreSessions(); + } + }, [ + centerSelection, + filteredSessions.length, + isLoading, + loadMoreSessions, + maxVisibleItems, + scrollOffset, + sessionState.hasMore, + ]); + + // Key handling (KeypressContext) + useKeypress( + (key) => { + const { name, sequence, ctrl } = key; + + if (name === 'escape' || (ctrl && name === 'c')) { + onCancel(); + return; + } + + if (name === 'return') { + const session = filteredSessions[selectedIndex]; + if (session) { + onSelect(session.sessionId); + } + return; + } + + if (name === 'up' || name === 'k') { + setSelectedIndex((prev) => { + const newIndex = Math.max(0, prev - 1); + if (!centerSelection && newIndex < followScrollOffset) { + setFollowScrollOffset(newIndex); + } + return newIndex; + }); + return; + } + + if (name === 'down' || name === 'j') { + if (filteredSessions.length === 0) { + return; + } + + setSelectedIndex((prev) => { + const newIndex = Math.min(filteredSessions.length - 1, prev + 1); + + if ( + !centerSelection && + newIndex >= followScrollOffset + maxVisibleItems + ) { + setFollowScrollOffset(newIndex - maxVisibleItems + 1); + } + + // Follow mode: load more when near the end. + if (!centerSelection && newIndex >= filteredSessions.length - 3) { + void loadMoreSessions(); + } + + return newIndex; + }); + return; + } + + if (sequence === 'b' || sequence === 'B') { + if (currentBranch) { + setFilterByBranch((prev) => !prev); + } + } + }, + { isActive }, + ); + + return { + selectedIndex, + sessionState, + filteredSessions, + filterByBranch, + isLoading, + scrollOffset, + visibleSessions, + showScrollUp, + showScrollDown, + loadMoreSessions, + }; +} diff --git a/packages/cli/src/ui/hooks/useToolScheduler.test.ts b/packages/cli/src/ui/hooks/useToolScheduler.test.ts index cab0b5eeb..d7b2b8109 100644 --- a/packages/cli/src/ui/hooks/useToolScheduler.test.ts +++ b/packages/cli/src/ui/hooks/useToolScheduler.test.ts @@ -62,7 +62,7 @@ const mockConfig = { getAllowedTools: vi.fn(() => []), getContentGeneratorConfig: () => ({ model: 'test-model', - authType: 'oauth-personal', + authType: 'gemini-api-key', }), getUseSmartEdit: () => false, getUseModelRouter: () => false, diff --git a/packages/cli/src/ui/models/availableModels.ts b/packages/cli/src/ui/models/availableModels.ts index 9a04101f0..d9c9eb725 100644 --- a/packages/cli/src/ui/models/availableModels.ts +++ b/packages/cli/src/ui/models/availableModels.ts @@ -60,6 +60,11 @@ export function getOpenAIAvailableModelFromEnv(): AvailableModel | null { return id ? { id, label: id } : null; } +export function getAnthropicAvailableModelFromEnv(): AvailableModel | null { + const id = process.env['ANTHROPIC_MODEL']?.trim(); + return id ? { id, label: id } : null; +} + export function getAvailableModelsForAuthType( authType: AuthType, ): AvailableModel[] { @@ -70,6 +75,10 @@ export function getAvailableModelsForAuthType( const openAIModel = getOpenAIAvailableModelFromEnv(); return openAIModel ? [openAIModel] : []; } + case AuthType.USE_ANTHROPIC: { + const anthropicModel = getAnthropicAvailableModelFromEnv(); + return anthropicModel ? [anthropicModel] : []; + } default: // For other auth types, return empty array for now // This can be expanded later according to the design doc diff --git a/packages/cli/src/ui/noninteractive/nonInteractiveUi.ts b/packages/cli/src/ui/noninteractive/nonInteractiveUi.ts index fc75924a6..779293330 100644 --- a/packages/cli/src/ui/noninteractive/nonInteractiveUi.ts +++ b/packages/cli/src/ui/noninteractive/nonInteractiveUi.ts @@ -20,7 +20,6 @@ export function createNonInteractiveUI(): CommandContext['ui'] { loadHistory: (_newHistory) => {}, pendingItem: null, setPendingItem: (_item) => {}, - toggleCorgiMode: () => {}, toggleVimEnabled: async () => false, setGeminiMdFileCount: (_count) => {}, reloadCommands: () => {}, diff --git a/packages/cli/src/ui/utils/resumeHistoryUtils.test.ts b/packages/cli/src/ui/utils/resumeHistoryUtils.test.ts index 29d602725..f6b7ee392 100644 --- a/packages/cli/src/ui/utils/resumeHistoryUtils.test.ts +++ b/packages/cli/src/ui/utils/resumeHistoryUtils.test.ts @@ -20,6 +20,11 @@ const makeConfig = (tools: Record) => getToolRegistry: () => ({ getTool: (name: string) => tools[name], }), + getContentGenerator: () => ({ + // Default to showing full thinking content during resume unless explicitly + // summarized; tests don't care about summarized thinking behavior. + useSummarizedThinking: () => false, + }), }) as unknown as Config; describe('resumeHistoryUtils', () => { diff --git a/packages/cli/src/ui/utils/resumeHistoryUtils.ts b/packages/cli/src/ui/utils/resumeHistoryUtils.ts index 3c69bfd47..686e87e2d 100644 --- a/packages/cli/src/ui/utils/resumeHistoryUtils.ts +++ b/packages/cli/src/ui/utils/resumeHistoryUtils.ts @@ -204,7 +204,11 @@ function convertToHistoryItems( const parts = record.message?.parts as Part[] | undefined; // Extract thought content - const thoughtText = extractThoughtTextFromParts(parts); + const thoughtText = !config + .getContentGenerator() + .useSummarizedThinking() + ? extractThoughtTextFromParts(parts) + : ''; // Extract text content (non-function-call, non-thought) const text = extractTextFromParts(parts); diff --git a/packages/cli/src/ui/utils/sessionPickerUtils.test.ts b/packages/cli/src/ui/utils/sessionPickerUtils.test.ts new file mode 100644 index 000000000..e561199e1 --- /dev/null +++ b/packages/cli/src/ui/utils/sessionPickerUtils.test.ts @@ -0,0 +1,45 @@ +/** + * @license + * Copyright 2025 Qwen Code + * SPDX-License-Identifier: Apache-2.0 + */ + +import { describe, it, expect } from 'vitest'; +import { truncateText } from './sessionPickerUtils.js'; + +describe('sessionPickerUtils', () => { + describe('truncateText', () => { + it('returns the original text when it fits and has no newline', () => { + expect(truncateText('hello', 10)).toBe('hello'); + }); + + it('truncates long text with ellipsis', () => { + expect(truncateText('hello world', 5)).toBe('he...'); + }); + + it('truncates without ellipsis when maxWidth <= 3', () => { + expect(truncateText('hello', 3)).toBe('hel'); + expect(truncateText('hello', 2)).toBe('he'); + }); + + it('breaks at newline and returns only the first line', () => { + expect(truncateText('hello\nworld', 20)).toBe('hello'); + expect(truncateText('hello\r\nworld', 20)).toBe('hello'); + }); + + it('breaks at newline and still truncates the first line when needed', () => { + expect(truncateText('hello\nworld', 2)).toBe('he'); + expect(truncateText('hello\nworld', 3)).toBe('hel'); + expect(truncateText('hello\nworld', 4)).toBe('h...'); + }); + + it('does not add ellipsis when the string ends at a newline', () => { + expect(truncateText('hello\n', 20)).toBe('hello'); + expect(truncateText('hello\r\n', 20)).toBe('hello'); + }); + + it('returns only the first line even if there are multiple line breaks', () => { + expect(truncateText('hello\n\nworld', 20)).toBe('hello'); + }); + }); +}); diff --git a/packages/cli/src/ui/utils/sessionPickerUtils.ts b/packages/cli/src/ui/utils/sessionPickerUtils.ts new file mode 100644 index 000000000..74560c5b4 --- /dev/null +++ b/packages/cli/src/ui/utils/sessionPickerUtils.ts @@ -0,0 +1,59 @@ +/** + * @license + * Copyright 2025 Qwen Code + * SPDX-License-Identifier: Apache-2.0 + */ + +import type { SessionListItem } from '@qwen-code/qwen-code-core'; + +/** + * State for managing loaded sessions in the session picker. + */ +export interface SessionState { + sessions: SessionListItem[]; + hasMore: boolean; + nextCursor?: number; +} + +/** + * Page size for loading sessions. + */ +export const SESSION_PAGE_SIZE = 20; + +/** + * Truncates text to fit within a given width, adding ellipsis if needed. + */ +export function truncateText(text: string, maxWidth: number): string { + const firstLine = text.split(/\r?\n/, 1)[0]; + if (firstLine.length <= maxWidth) { + return firstLine; + } + if (maxWidth <= 3) { + return firstLine.slice(0, maxWidth); + } + return firstLine.slice(0, maxWidth - 3) + '...'; +} + +/** + * Filters sessions optionally by branch. + */ +export function filterSessions( + sessions: SessionListItem[], + filterByBranch: boolean, + currentBranch?: string, +): SessionListItem[] { + return sessions.filter((session) => { + // Apply branch filter if enabled + if (filterByBranch && currentBranch) { + return session.gitBranch === currentBranch; + } + return true; + }); +} + +/** + * Formats message count for display with proper pluralization. + */ +export function formatMessageCount(count: number): string { + return count === 1 ? '1 message' : `${count} messages`; +} diff --git a/packages/cli/src/utils/attentionNotification.ts b/packages/cli/src/utils/attentionNotification.ts index 26dc2a257..e166444ff 100644 --- a/packages/cli/src/utils/attentionNotification.ts +++ b/packages/cli/src/utils/attentionNotification.ts @@ -13,6 +13,7 @@ export enum AttentionNotificationReason { export interface TerminalNotificationOptions { stream?: Pick; + enabled?: boolean; } const TERMINAL_BELL = '\u0007'; @@ -28,6 +29,11 @@ export function notifyTerminalAttention( _reason: AttentionNotificationReason, options: TerminalNotificationOptions = {}, ): boolean { + // Check if terminal bell is enabled (default true for backwards compatibility) + if (options.enabled === false) { + return false; + } + const stream = options.stream ?? process.stdout; if (!stream?.write || stream.isTTY === false) { return false; diff --git a/packages/cli/src/utils/gitUtils.test.ts b/packages/cli/src/utils/gitUtils.test.ts index 45b777fcb..71c80d6cf 100644 --- a/packages/cli/src/utils/gitUtils.test.ts +++ b/packages/cli/src/utils/gitUtils.test.ts @@ -76,6 +76,105 @@ describe('getGitHubRepoInfo', async () => { ); expect(getGitHubRepoInfo()).toEqual({ owner: 'owner', repo: 'repo' }); }); + + // Tests for credential formats + + it('returns the owner and repo for URL with classic PAT token (ghp_)', async () => { + vi.mocked(child_process.execSync).mockReturnValueOnce( + 'https://ghp_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx@github.com/owner/repo.git', + ); + expect(getGitHubRepoInfo()).toEqual({ owner: 'owner', repo: 'repo' }); + }); + + it('returns the owner and repo for URL with fine-grained PAT token (github_pat_)', async () => { + vi.mocked(child_process.execSync).mockReturnValueOnce( + 'https://github_pat_xxxxxxxxxxxxxxxxxxxxxx_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx@github.com/owner/repo.git', + ); + expect(getGitHubRepoInfo()).toEqual({ owner: 'owner', repo: 'repo' }); + }); + + it('returns the owner and repo for URL with username:password format', async () => { + vi.mocked(child_process.execSync).mockReturnValueOnce( + 'https://username:password@github.com/owner/repo.git', + ); + expect(getGitHubRepoInfo()).toEqual({ owner: 'owner', repo: 'repo' }); + }); + + it('returns the owner and repo for URL with OAuth token (oauth2:token)', async () => { + vi.mocked(child_process.execSync).mockReturnValueOnce( + 'https://oauth2:gho_xxxxxxxxxxxx@github.com/owner/repo.git', + ); + expect(getGitHubRepoInfo()).toEqual({ owner: 'owner', repo: 'repo' }); + }); + + it('returns the owner and repo for URL with GitHub Actions token (x-access-token)', async () => { + vi.mocked(child_process.execSync).mockReturnValueOnce( + 'https://x-access-token:ghs_xxxxxxxxxxxx@github.com/owner/repo.git', + ); + expect(getGitHubRepoInfo()).toEqual({ owner: 'owner', repo: 'repo' }); + }); + + // Tests for case insensitivity + + it('returns the owner and repo for URL with uppercase GITHUB.COM', async () => { + vi.mocked(child_process.execSync).mockReturnValueOnce( + 'https://GITHUB.COM/owner/repo.git', + ); + expect(getGitHubRepoInfo()).toEqual({ owner: 'owner', repo: 'repo' }); + }); + + it('returns the owner and repo for URL with mixed case GitHub.Com', async () => { + vi.mocked(child_process.execSync).mockReturnValueOnce( + 'https://GitHub.Com/owner/repo.git', + ); + expect(getGitHubRepoInfo()).toEqual({ owner: 'owner', repo: 'repo' }); + }); + + // Tests for SSH format + + it('returns the owner and repo for SSH URL', async () => { + vi.mocked(child_process.execSync).mockReturnValueOnce( + 'git@github.com:owner/repo.git', + ); + expect(getGitHubRepoInfo()).toEqual({ owner: 'owner', repo: 'repo' }); + }); + + it('throws for non-GitHub SSH URL', async () => { + vi.mocked(child_process.execSync).mockReturnValueOnce( + 'git@gitlab.com:owner/repo.git', + ); + expect(() => { + getGitHubRepoInfo(); + }).toThrowError(/Owner & repo could not be extracted from remote URL/); + }); + + // Tests for edge cases + + it('returns the owner and repo for URL without .git suffix', async () => { + vi.mocked(child_process.execSync).mockReturnValueOnce( + 'https://github.com/owner/repo', + ); + expect(getGitHubRepoInfo()).toEqual({ owner: 'owner', repo: 'repo' }); + }); + + it('throws for non-GitHub HTTPS URL', async () => { + vi.mocked(child_process.execSync).mockReturnValueOnce( + 'https://gitlab.com/owner/repo.git', + ); + expect(() => { + getGitHubRepoInfo(); + }).toThrowError(/Owner & repo could not be extracted from remote URL/); + }); + + it('handles repo names containing .git substring', async () => { + vi.mocked(child_process.execSync).mockReturnValueOnce( + 'https://github.com/owner/my.git.repo.git', + ); + expect(getGitHubRepoInfo()).toEqual({ + owner: 'owner', + repo: 'my.git.repo', + }); + }); }); describe('getGitRepoRoot', async () => { diff --git a/packages/cli/src/utils/gitUtils.ts b/packages/cli/src/utils/gitUtils.ts index 11cf729b6..fcef4bf3e 100644 --- a/packages/cli/src/utils/gitUtils.ts +++ b/packages/cli/src/utils/gitUtils.ts @@ -58,7 +58,7 @@ export const getLatestGitHubRelease = async ( try { const controller = new AbortController(); - const endpoint = `https://api.github.com/repos/google-github-actions/run-gemini-cli/releases/latest`; + const endpoint = `https://api.github.com/repos/QwenLM/qwen-code-action/releases/latest`; const response = await fetch(endpoint, { method: 'GET', @@ -83,9 +83,12 @@ export const getLatestGitHubRelease = async ( } return releaseTag; } catch (_error) { - console.debug(`Failed to determine latest run-gemini-cli release:`, _error); + console.debug( + `Failed to determine latest qwen-code-action release:`, + _error, + ); throw new Error( - `Unable to determine the latest run-gemini-cli release on GitHub.`, + `Unable to determine the latest qwen-code-action release on GitHub.`, ); } }; @@ -100,17 +103,38 @@ export function getGitHubRepoInfo(): { owner: string; repo: string } { encoding: 'utf-8', }).trim(); - // Matches either https://github.com/owner/repo.git or git@github.com:owner/repo.git - const match = remoteUrl.match( - /(?:https?:\/\/|git@)github\.com(?::|\/)([^/]+)\/([^/]+?)(?:\.git)?$/, - ); - - // If the regex fails match, throw an error. - if (!match || !match[1] || !match[2]) { + // Handle SCP-style SSH URLs (git@github.com:owner/repo.git) + let urlToParse = remoteUrl; + if (remoteUrl.startsWith('git@github.com:')) { + urlToParse = remoteUrl.replace('git@github.com:', ''); + } else if (remoteUrl.startsWith('git@')) { + // SSH URL for a different provider (GitLab, Bitbucket, etc.) throw new Error( `Owner & repo could not be extracted from remote URL: ${remoteUrl}`, ); } - return { owner: match[1], repo: match[2] }; + let parsedUrl: URL; + try { + parsedUrl = new URL(urlToParse, 'https://github.com'); + } catch { + throw new Error( + `Owner & repo could not be extracted from remote URL: ${remoteUrl}`, + ); + } + + if (parsedUrl.host !== 'github.com') { + throw new Error( + `Owner & repo could not be extracted from remote URL: ${remoteUrl}`, + ); + } + + const parts = parsedUrl.pathname.split('/').filter((part) => part !== ''); + if (parts.length !== 2 || !parts[0] || !parts[1]) { + throw new Error( + `Owner & repo could not be extracted from remote URL: ${remoteUrl}`, + ); + } + + return { owner: parts[0], repo: parts[1].replace(/\.git$/, '') }; } diff --git a/packages/cli/src/utils/relaunch.test.ts b/packages/cli/src/utils/relaunch.test.ts index e627a07a4..0ed8c4856 100644 --- a/packages/cli/src/utils/relaunch.test.ts +++ b/packages/cli/src/utils/relaunch.test.ts @@ -115,7 +115,7 @@ describe('relaunchAppInChildProcess', () => { vi.clearAllMocks(); process.env = { ...originalEnv }; - delete process.env['GEMINI_CLI_NO_RELAUNCH']; + delete process.env['QWEN_CODE_NO_RELAUNCH']; process.execArgv = [...originalExecArgv]; process.argv = [...originalArgv]; @@ -145,9 +145,9 @@ describe('relaunchAppInChildProcess', () => { stdinResumeSpy.mockRestore(); }); - describe('when GEMINI_CLI_NO_RELAUNCH is set', () => { + describe('when QWEN_CODE_NO_RELAUNCH is set', () => { it('should return early without spawning a child process', async () => { - process.env['GEMINI_CLI_NO_RELAUNCH'] = 'true'; + process.env['QWEN_CODE_NO_RELAUNCH'] = 'true'; await relaunchAppInChildProcess(['--test'], ['--verbose']); @@ -156,9 +156,9 @@ describe('relaunchAppInChildProcess', () => { }); }); - describe('when GEMINI_CLI_NO_RELAUNCH is not set', () => { + describe('when QWEN_CODE_NO_RELAUNCH is not set', () => { beforeEach(() => { - delete process.env['GEMINI_CLI_NO_RELAUNCH']; + delete process.env['QWEN_CODE_NO_RELAUNCH']; }); it('should construct correct node arguments from execArgv, additionalNodeArgs, script, additionalScriptArgs, and argv', () => { diff --git a/packages/cli/src/utils/relaunch.ts b/packages/cli/src/utils/relaunch.ts index 1142efc71..80d243c70 100644 --- a/packages/cli/src/utils/relaunch.ts +++ b/packages/cli/src/utils/relaunch.ts @@ -27,7 +27,7 @@ export async function relaunchAppInChildProcess( additionalNodeArgs: string[], additionalScriptArgs: string[], ) { - if (process.env['GEMINI_CLI_NO_RELAUNCH']) { + if (process.env['QWEN_CODE_NO_RELAUNCH']) { return; } @@ -44,7 +44,7 @@ export async function relaunchAppInChildProcess( ...additionalScriptArgs, ...scriptArgs, ]; - const newEnv = { ...process.env, GEMINI_CLI_NO_RELAUNCH: 'true' }; + const newEnv = { ...process.env, QWEN_CODE_NO_RELAUNCH: 'true' }; // The parent process should not be reading from stdin while the child is running. process.stdin.pause(); diff --git a/packages/cli/src/utils/systemInfo.ts b/packages/cli/src/utils/systemInfo.ts index 84927a951..5f067b3ae 100644 --- a/packages/cli/src/utils/systemInfo.ts +++ b/packages/cli/src/utils/systemInfo.ts @@ -153,7 +153,8 @@ export async function getExtendedSystemInfo( // Get base URL if using OpenAI auth const baseUrl = - baseInfo.selectedAuthType === AuthType.USE_OPENAI + baseInfo.selectedAuthType === AuthType.USE_OPENAI || + baseInfo.selectedAuthType === AuthType.USE_ANTHROPIC ? context.services.config?.getContentGeneratorConfig()?.baseUrl : undefined; diff --git a/packages/cli/src/validateNonInterActiveAuth.test.ts b/packages/cli/src/validateNonInterActiveAuth.test.ts index 867777b34..2997847d3 100644 --- a/packages/cli/src/validateNonInterActiveAuth.test.ts +++ b/packages/cli/src/validateNonInterActiveAuth.test.ts @@ -19,6 +19,9 @@ describe('validateNonInterActiveAuth', () => { let originalEnvVertexAi: string | undefined; let originalEnvGcp: string | undefined; let originalEnvOpenAiApiKey: string | undefined; + let originalEnvQwenOauth: string | undefined; + let originalEnvGoogleApiKey: string | undefined; + let originalEnvAnthropicApiKey: string | undefined; let consoleErrorSpy: ReturnType; let processExitSpy: ReturnType>; let refreshAuthMock: ReturnType; @@ -29,10 +32,16 @@ describe('validateNonInterActiveAuth', () => { originalEnvVertexAi = process.env['GOOGLE_GENAI_USE_VERTEXAI']; originalEnvGcp = process.env['GOOGLE_GENAI_USE_GCA']; originalEnvOpenAiApiKey = process.env['OPENAI_API_KEY']; + originalEnvQwenOauth = process.env['QWEN_OAUTH']; + originalEnvGoogleApiKey = process.env['GOOGLE_API_KEY']; + originalEnvAnthropicApiKey = process.env['ANTHROPIC_API_KEY']; delete process.env['GEMINI_API_KEY']; delete process.env['GOOGLE_GENAI_USE_VERTEXAI']; delete process.env['GOOGLE_GENAI_USE_GCA']; delete process.env['OPENAI_API_KEY']; + delete process.env['QWEN_OAUTH']; + delete process.env['GOOGLE_API_KEY']; + delete process.env['ANTHROPIC_API_KEY']; consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation(() => {}); processExitSpy = vi.spyOn(process, 'exit').mockImplementation((code) => { throw new Error(`process.exit(${code}) called`); @@ -80,6 +89,21 @@ describe('validateNonInterActiveAuth', () => { } else { delete process.env['OPENAI_API_KEY']; } + if (originalEnvQwenOauth !== undefined) { + process.env['QWEN_OAUTH'] = originalEnvQwenOauth; + } else { + delete process.env['QWEN_OAUTH']; + } + if (originalEnvGoogleApiKey !== undefined) { + process.env['GOOGLE_API_KEY'] = originalEnvGoogleApiKey; + } else { + delete process.env['GOOGLE_API_KEY']; + } + if (originalEnvAnthropicApiKey !== undefined) { + process.env['ANTHROPIC_API_KEY'] = originalEnvAnthropicApiKey; + } else { + delete process.env['ANTHROPIC_API_KEY']; + } vi.restoreAllMocks(); }); diff --git a/packages/cli/src/validateNonInterActiveAuth.ts b/packages/cli/src/validateNonInterActiveAuth.ts index 1590c0740..be5425a97 100644 --- a/packages/cli/src/validateNonInterActiveAuth.ts +++ b/packages/cli/src/validateNonInterActiveAuth.ts @@ -21,6 +21,16 @@ function getAuthTypeFromEnv(): AuthType | undefined { return AuthType.QWEN_OAUTH; } + if (process.env['GEMINI_API_KEY']) { + return AuthType.USE_GEMINI; + } + if (process.env['GOOGLE_API_KEY']) { + return AuthType.USE_VERTEX_AI; + } + if (process.env['ANTHROPIC_API_KEY']) { + return AuthType.USE_ANTHROPIC; + } + return undefined; } diff --git a/packages/cli/tsconfig.json b/packages/cli/tsconfig.json index b507c9c51..073f2aa1b 100644 --- a/packages/cli/tsconfig.json +++ b/packages/cli/tsconfig.json @@ -38,7 +38,6 @@ "src/ui/commands/clearCommand.test.ts", "src/ui/commands/compressCommand.test.ts", "src/ui/commands/copyCommand.test.ts", - "src/ui/commands/corgiCommand.test.ts", "src/ui/commands/docsCommand.test.ts", "src/ui/commands/editorCommand.test.ts", "src/ui/commands/extensionsCommand.test.ts", diff --git a/packages/core/package.json b/packages/core/package.json index 0cd64ea84..de06c78bc 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -1,6 +1,6 @@ { "name": "@qwen-code/qwen-code-core", - "version": "0.4.1", + "version": "0.6.0", "description": "Qwen Code Core", "repository": { "type": "git", @@ -23,8 +23,9 @@ "scripts/postinstall.js" ], "dependencies": { - "@google/genai": "1.16.0", - "@modelcontextprotocol/sdk": "^1.11.0", + "@anthropic-ai/sdk": "^0.36.1", + "@google/genai": "1.30.0", + "@modelcontextprotocol/sdk": "^1.25.1", "@opentelemetry/api": "^1.9.0", "async-mutex": "^0.5.0", "@opentelemetry/exporter-logs-otlp-grpc": "^0.203.0", @@ -34,7 +35,6 @@ "@opentelemetry/exporter-trace-otlp-grpc": "^0.203.0", "@opentelemetry/exporter-trace-otlp-http": "^0.203.0", "@opentelemetry/instrumentation-http": "^0.203.0", - "@opentelemetry/resource-detector-gcp": "^0.40.0", "@opentelemetry/sdk-node": "^0.203.0", "@types/html-to-text": "^9.0.4", "@xterm/headless": "5.5.0", @@ -47,8 +47,8 @@ "fast-uri": "^3.0.6", "fdir": "^6.4.6", "fzf": "^0.5.2", - "glob": "^10.4.5", - "google-auth-library": "^9.11.0", + "glob": "^10.5.0", + "google-auth-library": "^10.5.0", "html-to-text": "^9.0.5", "https-proxy-agent": "^7.0.6", "ignore": "^7.0.0", @@ -63,7 +63,7 @@ "simple-git": "^3.28.0", "strip-ansi": "^7.1.0", "tiktoken": "^1.0.21", - "undici": "^7.10.0", + "undici": "^6.22.0", "uuid": "^9.0.1", "ws": "^8.18.0" }, diff --git a/packages/core/src/code_assist/codeAssist.ts b/packages/core/src/code_assist/codeAssist.ts deleted file mode 100644 index c8ade92ed..000000000 --- a/packages/core/src/code_assist/codeAssist.ts +++ /dev/null @@ -1,54 +0,0 @@ -/** - * @license - * Copyright 2025 Google LLC - * SPDX-License-Identifier: Apache-2.0 - */ - -import type { ContentGenerator } from '../core/contentGenerator.js'; -import { AuthType } from '../core/contentGenerator.js'; -import { getOauthClient } from './oauth2.js'; -import { setupUser } from './setup.js'; -import type { HttpOptions } from './server.js'; -import { CodeAssistServer } from './server.js'; -import type { Config } from '../config/config.js'; -import { LoggingContentGenerator } from '../core/loggingContentGenerator.js'; - -export async function createCodeAssistContentGenerator( - httpOptions: HttpOptions, - authType: AuthType, - config: Config, - sessionId?: string, -): Promise { - if ( - authType === AuthType.LOGIN_WITH_GOOGLE || - authType === AuthType.CLOUD_SHELL - ) { - const authClient = await getOauthClient(authType, config); - const userData = await setupUser(authClient); - return new CodeAssistServer( - authClient, - userData.projectId, - httpOptions, - sessionId, - userData.userTier, - ); - } - - throw new Error(`Unsupported authType: ${authType}`); -} - -export function getCodeAssistServer( - config: Config, -): CodeAssistServer | undefined { - let server = config.getContentGenerator(); - - // Unwrap LoggingContentGenerator if present - if (server instanceof LoggingContentGenerator) { - server = server.getWrapped(); - } - - if (!(server instanceof CodeAssistServer)) { - return undefined; - } - return server; -} diff --git a/packages/core/src/code_assist/converter.test.ts b/packages/core/src/code_assist/converter.test.ts deleted file mode 100644 index 501651f37..000000000 --- a/packages/core/src/code_assist/converter.test.ts +++ /dev/null @@ -1,456 +0,0 @@ -/** - * @license - * Copyright 2025 Google LLC - * SPDX-License-Identifier: Apache-2.0 - */ - -import { describe, it, expect } from 'vitest'; -import type { CaGenerateContentResponse } from './converter.js'; -import { - toGenerateContentRequest, - fromGenerateContentResponse, - toContents, -} from './converter.js'; -import type { - ContentListUnion, - GenerateContentParameters, -} from '@google/genai'; -import { - GenerateContentResponse, - FinishReason, - BlockedReason, - type Part, -} from '@google/genai'; - -describe('converter', () => { - describe('toCodeAssistRequest', () => { - it('should convert a simple request with project', () => { - const genaiReq: GenerateContentParameters = { - model: 'gemini-pro', - contents: [{ role: 'user', parts: [{ text: 'Hello' }] }], - }; - const codeAssistReq = toGenerateContentRequest( - genaiReq, - 'my-prompt', - 'my-project', - 'my-session', - ); - expect(codeAssistReq).toEqual({ - model: 'gemini-pro', - project: 'my-project', - request: { - contents: [{ role: 'user', parts: [{ text: 'Hello' }] }], - systemInstruction: undefined, - cachedContent: undefined, - tools: undefined, - toolConfig: undefined, - labels: undefined, - safetySettings: undefined, - generationConfig: undefined, - session_id: 'my-session', - }, - user_prompt_id: 'my-prompt', - }); - }); - - it('should convert a request without a project', () => { - const genaiReq: GenerateContentParameters = { - model: 'gemini-pro', - contents: [{ role: 'user', parts: [{ text: 'Hello' }] }], - }; - const codeAssistReq = toGenerateContentRequest( - genaiReq, - 'my-prompt', - undefined, - 'my-session', - ); - expect(codeAssistReq).toEqual({ - model: 'gemini-pro', - project: undefined, - request: { - contents: [{ role: 'user', parts: [{ text: 'Hello' }] }], - systemInstruction: undefined, - cachedContent: undefined, - tools: undefined, - toolConfig: undefined, - labels: undefined, - safetySettings: undefined, - generationConfig: undefined, - session_id: 'my-session', - }, - user_prompt_id: 'my-prompt', - }); - }); - - it('should convert a request with sessionId', () => { - const genaiReq: GenerateContentParameters = { - model: 'gemini-pro', - contents: [{ role: 'user', parts: [{ text: 'Hello' }] }], - }; - const codeAssistReq = toGenerateContentRequest( - genaiReq, - 'my-prompt', - 'my-project', - 'session-123', - ); - expect(codeAssistReq).toEqual({ - model: 'gemini-pro', - project: 'my-project', - request: { - contents: [{ role: 'user', parts: [{ text: 'Hello' }] }], - systemInstruction: undefined, - cachedContent: undefined, - tools: undefined, - toolConfig: undefined, - labels: undefined, - safetySettings: undefined, - generationConfig: undefined, - session_id: 'session-123', - }, - user_prompt_id: 'my-prompt', - }); - }); - - it('should handle string content', () => { - const genaiReq: GenerateContentParameters = { - model: 'gemini-pro', - contents: 'Hello', - }; - const codeAssistReq = toGenerateContentRequest( - genaiReq, - 'my-prompt', - 'my-project', - 'my-session', - ); - expect(codeAssistReq.request.contents).toEqual([ - { role: 'user', parts: [{ text: 'Hello' }] }, - ]); - }); - - it('should handle Part[] content', () => { - const genaiReq: GenerateContentParameters = { - model: 'gemini-pro', - contents: [{ text: 'Hello' }, { text: 'World' }], - }; - const codeAssistReq = toGenerateContentRequest( - genaiReq, - 'my-prompt', - 'my-project', - 'my-session', - ); - expect(codeAssistReq.request.contents).toEqual([ - { role: 'user', parts: [{ text: 'Hello' }] }, - { role: 'user', parts: [{ text: 'World' }] }, - ]); - }); - - it('should handle system instructions', () => { - const genaiReq: GenerateContentParameters = { - model: 'gemini-pro', - contents: 'Hello', - config: { - systemInstruction: 'You are a helpful assistant.', - }, - }; - const codeAssistReq = toGenerateContentRequest( - genaiReq, - 'my-prompt', - 'my-project', - 'my-session', - ); - expect(codeAssistReq.request.systemInstruction).toEqual({ - role: 'user', - parts: [{ text: 'You are a helpful assistant.' }], - }); - }); - - it('should handle generation config', () => { - const genaiReq: GenerateContentParameters = { - model: 'gemini-pro', - contents: 'Hello', - config: { - temperature: 0.8, - topK: 40, - }, - }; - const codeAssistReq = toGenerateContentRequest( - genaiReq, - 'my-prompt', - 'my-project', - 'my-session', - ); - expect(codeAssistReq.request.generationConfig).toEqual({ - temperature: 0.8, - topK: 40, - }); - }); - - it('should handle all generation config fields', () => { - const genaiReq: GenerateContentParameters = { - model: 'gemini-pro', - contents: 'Hello', - config: { - temperature: 0.1, - topP: 0.2, - topK: 3, - candidateCount: 4, - maxOutputTokens: 5, - stopSequences: ['a'], - responseLogprobs: true, - logprobs: 6, - presencePenalty: 0.7, - frequencyPenalty: 0.8, - seed: 9, - responseMimeType: 'application/json', - }, - }; - const codeAssistReq = toGenerateContentRequest( - genaiReq, - 'my-prompt', - 'my-project', - 'my-session', - ); - expect(codeAssistReq.request.generationConfig).toEqual({ - temperature: 0.1, - topP: 0.2, - topK: 3, - candidateCount: 4, - maxOutputTokens: 5, - stopSequences: ['a'], - responseLogprobs: true, - logprobs: 6, - presencePenalty: 0.7, - frequencyPenalty: 0.8, - seed: 9, - responseMimeType: 'application/json', - }); - }); - }); - - describe('fromCodeAssistResponse', () => { - it('should convert a simple response', () => { - const codeAssistRes: CaGenerateContentResponse = { - response: { - candidates: [ - { - index: 0, - content: { - role: 'model', - parts: [{ text: 'Hi there!' }], - }, - finishReason: FinishReason.STOP, - safetyRatings: [], - }, - ], - }, - }; - const genaiRes = fromGenerateContentResponse(codeAssistRes); - expect(genaiRes).toBeInstanceOf(GenerateContentResponse); - expect(genaiRes.candidates).toEqual(codeAssistRes.response.candidates); - }); - - it('should handle prompt feedback and usage metadata', () => { - const codeAssistRes: CaGenerateContentResponse = { - response: { - candidates: [], - promptFeedback: { - blockReason: BlockedReason.SAFETY, - safetyRatings: [], - }, - usageMetadata: { - promptTokenCount: 10, - candidatesTokenCount: 20, - totalTokenCount: 30, - }, - }, - }; - const genaiRes = fromGenerateContentResponse(codeAssistRes); - expect(genaiRes.promptFeedback).toEqual( - codeAssistRes.response.promptFeedback, - ); - expect(genaiRes.usageMetadata).toEqual( - codeAssistRes.response.usageMetadata, - ); - }); - - it('should handle automatic function calling history', () => { - const codeAssistRes: CaGenerateContentResponse = { - response: { - candidates: [], - automaticFunctionCallingHistory: [ - { - role: 'model', - parts: [ - { - functionCall: { - name: 'test_function', - args: { - foo: 'bar', - }, - }, - }, - ], - }, - ], - }, - }; - const genaiRes = fromGenerateContentResponse(codeAssistRes); - expect(genaiRes.automaticFunctionCallingHistory).toEqual( - codeAssistRes.response.automaticFunctionCallingHistory, - ); - }); - - it('should handle modelVersion', () => { - const codeAssistRes: CaGenerateContentResponse = { - response: { - candidates: [], - modelVersion: 'qwen3-coder-plus', - }, - }; - const genaiRes = fromGenerateContentResponse(codeAssistRes); - expect(genaiRes.modelVersion).toEqual('qwen3-coder-plus'); - }); - }); - - describe('toContents', () => { - it('should handle Content', () => { - const content: ContentListUnion = { - role: 'user', - parts: [{ text: 'hello' }], - }; - expect(toContents(content)).toEqual([ - { role: 'user', parts: [{ text: 'hello' }] }, - ]); - }); - - it('should handle array of Contents', () => { - const contents: ContentListUnion = [ - { role: 'user', parts: [{ text: 'hello' }] }, - { role: 'model', parts: [{ text: 'hi' }] }, - ]; - expect(toContents(contents)).toEqual([ - { role: 'user', parts: [{ text: 'hello' }] }, - { role: 'model', parts: [{ text: 'hi' }] }, - ]); - }); - - it('should handle Part', () => { - const part: ContentListUnion = { text: 'a part' }; - expect(toContents(part)).toEqual([ - { role: 'user', parts: [{ text: 'a part' }] }, - ]); - }); - - it('should handle array of Parts', () => { - const parts = [{ text: 'part 1' }, 'part 2']; - expect(toContents(parts)).toEqual([ - { role: 'user', parts: [{ text: 'part 1' }] }, - { role: 'user', parts: [{ text: 'part 2' }] }, - ]); - }); - - it('should handle string', () => { - const str: ContentListUnion = 'a string'; - expect(toContents(str)).toEqual([ - { role: 'user', parts: [{ text: 'a string' }] }, - ]); - }); - - it('should handle array of strings', () => { - const strings: ContentListUnion = ['string 1', 'string 2']; - expect(toContents(strings)).toEqual([ - { role: 'user', parts: [{ text: 'string 1' }] }, - { role: 'user', parts: [{ text: 'string 2' }] }, - ]); - }); - - it('should convert thought parts to text parts for API compatibility', () => { - const contentWithThought: ContentListUnion = { - role: 'model', - parts: [ - { text: 'regular text' }, - { thought: 'thinking about the problem' } as Part & { - thought: string; - }, - { text: 'more text' }, - ], - }; - expect(toContents(contentWithThought)).toEqual([ - { - role: 'model', - parts: [ - { text: 'regular text' }, - { text: '[Thought: thinking about the problem]' }, - { text: 'more text' }, - ], - }, - ]); - }); - - it('should combine text and thought for text parts with thoughts', () => { - const contentWithTextAndThought: ContentListUnion = { - role: 'model', - parts: [ - { - text: 'Here is my response', - thought: 'I need to be careful here', - } as Part & { thought: string }, - ], - }; - expect(toContents(contentWithTextAndThought)).toEqual([ - { - role: 'model', - parts: [ - { - text: 'Here is my response\n[Thought: I need to be careful here]', - }, - ], - }, - ]); - }); - - it('should preserve non-thought properties while removing thought', () => { - const contentWithComplexPart: ContentListUnion = { - role: 'model', - parts: [ - { - functionCall: { name: 'calculate', args: { x: 5, y: 10 } }, - thought: 'Performing calculation', - } as Part & { thought: string }, - ], - }; - expect(toContents(contentWithComplexPart)).toEqual([ - { - role: 'model', - parts: [ - { - functionCall: { name: 'calculate', args: { x: 5, y: 10 } }, - }, - ], - }, - ]); - }); - - it('should convert invalid text content to valid text part with thought', () => { - const contentWithInvalidText: ContentListUnion = { - role: 'model', - parts: [ - { - text: 123, // Invalid - should be string - thought: 'Processing number', - } as Part & { thought: string; text: number }, - ], - }; - expect(toContents(contentWithInvalidText)).toEqual([ - { - role: 'model', - parts: [ - { - text: '123\n[Thought: Processing number]', - }, - ], - }, - ]); - }); - }); -}); diff --git a/packages/core/src/code_assist/converter.ts b/packages/core/src/code_assist/converter.ts deleted file mode 100644 index 78e743136..000000000 --- a/packages/core/src/code_assist/converter.ts +++ /dev/null @@ -1,285 +0,0 @@ -/** - * @license - * Copyright 2025 Google LLC - * SPDX-License-Identifier: Apache-2.0 - */ - -import type { - Content, - ContentListUnion, - ContentUnion, - GenerateContentConfig, - GenerateContentParameters, - CountTokensParameters, - CountTokensResponse, - GenerationConfigRoutingConfig, - MediaResolution, - Candidate, - ModelSelectionConfig, - GenerateContentResponsePromptFeedback, - GenerateContentResponseUsageMetadata, - Part, - SafetySetting, - PartUnion, - SpeechConfigUnion, - ThinkingConfig, - ToolListUnion, - ToolConfig, -} from '@google/genai'; -import { GenerateContentResponse } from '@google/genai'; - -export interface CAGenerateContentRequest { - model: string; - project?: string; - user_prompt_id?: string; - request: VertexGenerateContentRequest; -} - -interface VertexGenerateContentRequest { - contents: Content[]; - systemInstruction?: Content; - cachedContent?: string; - tools?: ToolListUnion; - toolConfig?: ToolConfig; - labels?: Record; - safetySettings?: SafetySetting[]; - generationConfig?: VertexGenerationConfig; - session_id?: string; -} - -interface VertexGenerationConfig { - temperature?: number; - topP?: number; - topK?: number; - candidateCount?: number; - maxOutputTokens?: number; - stopSequences?: string[]; - responseLogprobs?: boolean; - logprobs?: number; - presencePenalty?: number; - frequencyPenalty?: number; - seed?: number; - responseMimeType?: string; - responseJsonSchema?: unknown; - responseSchema?: unknown; - routingConfig?: GenerationConfigRoutingConfig; - modelSelectionConfig?: ModelSelectionConfig; - responseModalities?: string[]; - mediaResolution?: MediaResolution; - speechConfig?: SpeechConfigUnion; - audioTimestamp?: boolean; - thinkingConfig?: ThinkingConfig; -} - -export interface CaGenerateContentResponse { - response: VertexGenerateContentResponse; -} - -interface VertexGenerateContentResponse { - candidates: Candidate[]; - automaticFunctionCallingHistory?: Content[]; - promptFeedback?: GenerateContentResponsePromptFeedback; - usageMetadata?: GenerateContentResponseUsageMetadata; - modelVersion?: string; -} - -export interface CaCountTokenRequest { - request: VertexCountTokenRequest; -} - -interface VertexCountTokenRequest { - model: string; - contents: Content[]; -} - -export interface CaCountTokenResponse { - totalTokens: number; -} - -export function toCountTokenRequest( - req: CountTokensParameters, -): CaCountTokenRequest { - return { - request: { - model: 'models/' + req.model, - contents: toContents(req.contents), - }, - }; -} - -export function fromCountTokenResponse( - res: CaCountTokenResponse, -): CountTokensResponse { - return { - totalTokens: res.totalTokens, - }; -} - -export function toGenerateContentRequest( - req: GenerateContentParameters, - userPromptId: string, - project?: string, - sessionId?: string, -): CAGenerateContentRequest { - return { - model: req.model, - project, - user_prompt_id: userPromptId, - request: toVertexGenerateContentRequest(req, sessionId), - }; -} - -export function fromGenerateContentResponse( - res: CaGenerateContentResponse, -): GenerateContentResponse { - const inres = res.response; - const out = new GenerateContentResponse(); - out.candidates = inres.candidates; - out.automaticFunctionCallingHistory = inres.automaticFunctionCallingHistory; - out.promptFeedback = inres.promptFeedback; - out.usageMetadata = inres.usageMetadata; - out.modelVersion = inres.modelVersion; - return out; -} - -function toVertexGenerateContentRequest( - req: GenerateContentParameters, - sessionId?: string, -): VertexGenerateContentRequest { - return { - contents: toContents(req.contents), - systemInstruction: maybeToContent(req.config?.systemInstruction), - cachedContent: req.config?.cachedContent, - tools: req.config?.tools, - toolConfig: req.config?.toolConfig, - labels: req.config?.labels, - safetySettings: req.config?.safetySettings, - generationConfig: toVertexGenerationConfig(req.config), - session_id: sessionId, - }; -} - -export function toContents(contents: ContentListUnion): Content[] { - if (Array.isArray(contents)) { - // it's a Content[] or a PartsUnion[] - return contents.map(toContent); - } - // it's a Content or a PartsUnion - return [toContent(contents)]; -} - -function maybeToContent(content?: ContentUnion): Content | undefined { - if (!content) { - return undefined; - } - return toContent(content); -} - -function toContent(content: ContentUnion): Content { - if (Array.isArray(content)) { - // it's a PartsUnion[] - return { - role: 'user', - parts: toParts(content), - }; - } - if (typeof content === 'string') { - // it's a string - return { - role: 'user', - parts: [{ text: content }], - }; - } - if ('parts' in content) { - // it's a Content - process parts to handle thought filtering - return { - ...content, - parts: content.parts - ? toParts(content.parts.filter((p) => p != null)) - : [], - }; - } - // it's a Part - return { - role: 'user', - parts: [toPart(content as Part)], - }; -} - -export function toParts(parts: PartUnion[]): Part[] { - return parts.map(toPart); -} - -function toPart(part: PartUnion): Part { - if (typeof part === 'string') { - // it's a string - return { text: part }; - } - - // Handle thought parts for CountToken API compatibility - // The CountToken API expects parts to have certain required "oneof" fields initialized, - // but thought parts don't conform to this schema and cause API failures - if ('thought' in part && part.thought) { - const thoughtText = `[Thought: ${part.thought}]`; - - const newPart = { ...part }; - delete (newPart as Record)['thought']; - - const hasApiContent = - 'functionCall' in newPart || - 'functionResponse' in newPart || - 'inlineData' in newPart || - 'fileData' in newPart; - - if (hasApiContent) { - // It's a functionCall or other non-text part. Just strip the thought. - return newPart; - } - - // If no other valid API content, this must be a text part. - // Combine existing text (if any) with the thought, preserving other properties. - const text = (newPart as { text?: unknown }).text; - const existingText = text ? String(text) : ''; - const combinedText = existingText - ? `${existingText}\n${thoughtText}` - : thoughtText; - - return { - ...newPart, - text: combinedText, - }; - } - - return part; -} - -function toVertexGenerationConfig( - config?: GenerateContentConfig, -): VertexGenerationConfig | undefined { - if (!config) { - return undefined; - } - return { - temperature: config.temperature, - topP: config.topP, - topK: config.topK, - candidateCount: config.candidateCount, - maxOutputTokens: config.maxOutputTokens, - stopSequences: config.stopSequences, - responseLogprobs: config.responseLogprobs, - logprobs: config.logprobs, - presencePenalty: config.presencePenalty, - frequencyPenalty: config.frequencyPenalty, - seed: config.seed, - responseMimeType: config.responseMimeType, - responseSchema: config.responseSchema, - responseJsonSchema: config.responseJsonSchema, - routingConfig: config.routingConfig, - modelSelectionConfig: config.modelSelectionConfig, - responseModalities: config.responseModalities, - mediaResolution: config.mediaResolution, - speechConfig: config.speechConfig, - audioTimestamp: config.audioTimestamp, - thinkingConfig: config.thinkingConfig, - }; -} diff --git a/packages/core/src/code_assist/oauth-credential-storage.test.ts b/packages/core/src/code_assist/oauth-credential-storage.test.ts deleted file mode 100644 index f044546eb..000000000 --- a/packages/core/src/code_assist/oauth-credential-storage.test.ts +++ /dev/null @@ -1,217 +0,0 @@ -/** - * @license - * Copyright 2025 Google LLC - * SPDX-License-Identifier: Apache-2.0 - */ - -import { type Credentials } from 'google-auth-library'; -import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; -import { OAuthCredentialStorage } from './oauth-credential-storage.js'; -import type { OAuthCredentials } from '../mcp/token-storage/types.js'; - -import * as path from 'node:path'; -import * as os from 'node:os'; -import { promises as fs } from 'node:fs'; - -// Mock external dependencies -const mockHybridTokenStorage = vi.hoisted(() => ({ - getCredentials: vi.fn(), - setCredentials: vi.fn(), - deleteCredentials: vi.fn(), -})); -vi.mock('../mcp/token-storage/hybrid-token-storage.js', () => ({ - HybridTokenStorage: vi.fn(() => mockHybridTokenStorage), -})); -vi.mock('node:fs', () => ({ - promises: { - readFile: vi.fn(), - rm: vi.fn(), - }, -})); -vi.mock('node:os'); -vi.mock('node:path'); - -describe('OAuthCredentialStorage', () => { - const mockCredentials: Credentials = { - access_token: 'mock_access_token', - refresh_token: 'mock_refresh_token', - expiry_date: Date.now() + 3600 * 1000, - token_type: 'Bearer', - scope: 'email profile', - }; - - const mockMcpCredentials: OAuthCredentials = { - serverName: 'main-account', - token: { - accessToken: 'mock_access_token', - refreshToken: 'mock_refresh_token', - tokenType: 'Bearer', - scope: 'email profile', - expiresAt: mockCredentials.expiry_date!, - }, - updatedAt: expect.any(Number), - }; - - const oldFilePath = '/mock/home/.qwen/oauth.json'; - - beforeEach(() => { - vi.spyOn(mockHybridTokenStorage, 'getCredentials').mockResolvedValue(null); - vi.spyOn(mockHybridTokenStorage, 'setCredentials').mockResolvedValue( - undefined, - ); - vi.spyOn(mockHybridTokenStorage, 'deleteCredentials').mockResolvedValue( - undefined, - ); - - vi.spyOn(fs, 'readFile').mockRejectedValue(new Error('File not found')); - vi.spyOn(fs, 'rm').mockResolvedValue(undefined); - - vi.spyOn(os, 'homedir').mockReturnValue('/mock/home'); - vi.spyOn(path, 'join').mockReturnValue(oldFilePath); - }); - - afterEach(() => { - vi.restoreAllMocks(); - }); - - describe('loadCredentials', () => { - it('should load credentials from HybridTokenStorage if available', async () => { - vi.spyOn(mockHybridTokenStorage, 'getCredentials').mockResolvedValue( - mockMcpCredentials, - ); - - const result = await OAuthCredentialStorage.loadCredentials(); - - expect(mockHybridTokenStorage.getCredentials).toHaveBeenCalledWith( - 'main-account', - ); - expect(result).toEqual(mockCredentials); - }); - - it('should fallback to migrateFromFileStorage if no credentials in HybridTokenStorage', async () => { - vi.spyOn(mockHybridTokenStorage, 'getCredentials').mockResolvedValue( - null, - ); - vi.spyOn(fs, 'readFile').mockResolvedValue( - JSON.stringify(mockCredentials), - ); - - const result = await OAuthCredentialStorage.loadCredentials(); - - expect(mockHybridTokenStorage.getCredentials).toHaveBeenCalledWith( - 'main-account', - ); - expect(fs.readFile).toHaveBeenCalledWith(oldFilePath, 'utf-8'); - expect(mockHybridTokenStorage.setCredentials).toHaveBeenCalled(); // Verify credentials were saved - expect(fs.rm).toHaveBeenCalledWith(oldFilePath, { force: true }); // Verify old file was removed - expect(result).toEqual(mockCredentials); - }); - - it('should return null if no credentials found and no old file to migrate', async () => { - vi.spyOn(fs, 'readFile').mockRejectedValue({ - message: 'File not found', - code: 'ENOENT', - }); - - const result = await OAuthCredentialStorage.loadCredentials(); - - expect(result).toBeNull(); - }); - - it('should throw an error if loading fails', async () => { - vi.spyOn(mockHybridTokenStorage, 'getCredentials').mockRejectedValue( - new Error('Loading error'), - ); - - await expect(OAuthCredentialStorage.loadCredentials()).rejects.toThrow( - 'Failed to load OAuth credentials', - ); - }); - - it('should throw an error if read file fails', async () => { - vi.spyOn(mockHybridTokenStorage, 'getCredentials').mockResolvedValue( - null, - ); - vi.spyOn(fs, 'readFile').mockRejectedValue( - new Error('Permission denied'), - ); - - await expect(OAuthCredentialStorage.loadCredentials()).rejects.toThrow( - 'Failed to load OAuth credentials', - ); - }); - - it('should not throw error if migration file removal failed', async () => { - vi.spyOn(mockHybridTokenStorage, 'getCredentials').mockResolvedValue( - null, - ); - vi.spyOn(fs, 'readFile').mockResolvedValue( - JSON.stringify(mockCredentials), - ); - vi.spyOn(OAuthCredentialStorage, 'saveCredentials').mockResolvedValue( - undefined, - ); - vi.spyOn(fs, 'rm').mockRejectedValue(new Error('Deletion failed')); - - const result = await OAuthCredentialStorage.loadCredentials(); - - expect(result).toEqual(mockCredentials); - }); - }); - - describe('saveCredentials', () => { - it('should save credentials to HybridTokenStorage', async () => { - await OAuthCredentialStorage.saveCredentials(mockCredentials); - - expect(mockHybridTokenStorage.setCredentials).toHaveBeenCalledWith( - mockMcpCredentials, - ); - }); - - it('should throw an error if access_token is missing', async () => { - const invalidCredentials: Credentials = { - ...mockCredentials, - access_token: undefined, - }; - await expect( - OAuthCredentialStorage.saveCredentials(invalidCredentials), - ).rejects.toThrow( - 'Attempted to save credentials without an access token.', - ); - }); - }); - - describe('clearCredentials', () => { - it('should delete credentials from HybridTokenStorage', async () => { - await OAuthCredentialStorage.clearCredentials(); - - expect(mockHybridTokenStorage.deleteCredentials).toHaveBeenCalledWith( - 'main-account', - ); - }); - - it('should attempt to remove the old file-based storage', async () => { - await OAuthCredentialStorage.clearCredentials(); - - expect(fs.rm).toHaveBeenCalledWith(oldFilePath, { force: true }); - }); - - it('should not throw an error if deleting old file fails', async () => { - vi.spyOn(fs, 'rm').mockRejectedValue(new Error('File deletion failed')); - - await expect( - OAuthCredentialStorage.clearCredentials(), - ).resolves.toBeUndefined(); - }); - - it('should throw an error if clearing from HybridTokenStorage fails', async () => { - vi.spyOn(mockHybridTokenStorage, 'deleteCredentials').mockRejectedValue( - new Error('Deletion error'), - ); - - await expect(OAuthCredentialStorage.clearCredentials()).rejects.toThrow( - 'Failed to clear OAuth credentials', - ); - }); - }); -}); diff --git a/packages/core/src/code_assist/oauth-credential-storage.ts b/packages/core/src/code_assist/oauth-credential-storage.ts deleted file mode 100644 index 622fa1d83..000000000 --- a/packages/core/src/code_assist/oauth-credential-storage.ts +++ /dev/null @@ -1,130 +0,0 @@ -/** - * @license - * Copyright 2025 Google LLC - * SPDX-License-Identifier: Apache-2.0 - */ - -import { type Credentials } from 'google-auth-library'; -import { HybridTokenStorage } from '../mcp/token-storage/hybrid-token-storage.js'; -import { OAUTH_FILE } from '../config/storage.js'; -import type { OAuthCredentials } from '../mcp/token-storage/types.js'; -import * as path from 'node:path'; -import * as os from 'node:os'; -import { promises as fs } from 'node:fs'; - -const QWEN_DIR = '.qwen'; -const KEYCHAIN_SERVICE_NAME = 'qwen-code-oauth'; -const MAIN_ACCOUNT_KEY = 'main-account'; - -export class OAuthCredentialStorage { - private static storage: HybridTokenStorage = new HybridTokenStorage( - KEYCHAIN_SERVICE_NAME, - ); - - /** - * Load cached OAuth credentials - */ - static async loadCredentials(): Promise { - try { - const credentials = await this.storage.getCredentials(MAIN_ACCOUNT_KEY); - - if (credentials?.token) { - const { accessToken, refreshToken, expiresAt, tokenType, scope } = - credentials.token; - // Convert from OAuthCredentials format to Google Credentials format - const googleCreds: Credentials = { - access_token: accessToken, - refresh_token: refreshToken || undefined, - token_type: tokenType || undefined, - scope: scope || undefined, - }; - - if (expiresAt) { - googleCreds.expiry_date = expiresAt; - } - - return googleCreds; - } - - // Fallback: Try to migrate from old file-based storage - return await this.migrateFromFileStorage(); - } catch (error: unknown) { - console.error(error); - throw new Error('Failed to load OAuth credentials'); - } - } - - /** - * Save OAuth credentials - */ - static async saveCredentials(credentials: Credentials): Promise { - if (!credentials.access_token) { - throw new Error('Attempted to save credentials without an access token.'); - } - - // Convert Google Credentials to OAuthCredentials format - const mcpCredentials: OAuthCredentials = { - serverName: MAIN_ACCOUNT_KEY, - token: { - accessToken: credentials.access_token, - refreshToken: credentials.refresh_token || undefined, - tokenType: credentials.token_type || 'Bearer', - scope: credentials.scope || undefined, - expiresAt: credentials.expiry_date || undefined, - }, - updatedAt: Date.now(), - }; - - await this.storage.setCredentials(mcpCredentials); - } - - /** - * Clear cached OAuth credentials - */ - static async clearCredentials(): Promise { - try { - await this.storage.deleteCredentials(MAIN_ACCOUNT_KEY); - - // Also try to remove the old file if it exists - const oldFilePath = path.join(os.homedir(), QWEN_DIR, OAUTH_FILE); - await fs.rm(oldFilePath, { force: true }).catch(() => {}); - } catch (error: unknown) { - console.error(error); - throw new Error('Failed to clear OAuth credentials'); - } - } - - /** - * Migrate credentials from old file-based storage to keychain - */ - private static async migrateFromFileStorage(): Promise { - const oldFilePath = path.join(os.homedir(), QWEN_DIR, OAUTH_FILE); - - let credsJson: string; - try { - credsJson = await fs.readFile(oldFilePath, 'utf-8'); - } catch (error: unknown) { - if ( - typeof error === 'object' && - error !== null && - 'code' in error && - error.code === 'ENOENT' - ) { - // File doesn't exist, so no migration. - return null; - } - // Other read errors should propagate. - throw error; - } - - const credentials = JSON.parse(credsJson) as Credentials; - - // Save to new storage - await this.saveCredentials(credentials); - - // Remove old file after successful migration - await fs.rm(oldFilePath, { force: true }).catch(() => {}); - - return credentials; - } -} diff --git a/packages/core/src/code_assist/oauth2.test.ts b/packages/core/src/code_assist/oauth2.test.ts deleted file mode 100644 index 56ae40ecb..000000000 --- a/packages/core/src/code_assist/oauth2.test.ts +++ /dev/null @@ -1,1166 +0,0 @@ -/** - * @license - * Copyright 2025 Google LLC - * SPDX-License-Identifier: Apache-2.0 - */ - -import type { Credentials } from 'google-auth-library'; -import type { Mock } from 'vitest'; -import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; -import { - clearCachedCredentialFile, - clearOauthClientCache, - getOauthClient, - resetOauthClientForTesting, -} from './oauth2.js'; -import { UserAccountManager } from '../utils/userAccountManager.js'; -import { OAuth2Client, Compute } from 'google-auth-library'; -import * as fs from 'node:fs'; -import * as path from 'node:path'; -import http from 'node:http'; -import open from 'open'; -import crypto from 'node:crypto'; -import * as os from 'node:os'; -import { AuthType } from '../core/contentGenerator.js'; -import type { Config } from '../config/config.js'; -import readline from 'node:readline'; -import { FORCE_ENCRYPTED_FILE_ENV_VAR } from '../mcp/token-storage/index.js'; -import { QWEN_DIR } from '../utils/paths.js'; - -vi.mock('os', async (importOriginal) => { - const os = await importOriginal(); - return { - ...os, - homedir: vi.fn(), - }; -}); - -vi.mock('google-auth-library'); -vi.mock('http'); -vi.mock('open'); -vi.mock('crypto'); -vi.mock('node:readline'); -vi.mock('../utils/browser.js', () => ({ - shouldAttemptBrowserLaunch: () => true, -})); - -vi.mock('./oauth-credential-storage.js', () => ({ - OAuthCredentialStorage: { - saveCredentials: vi.fn(), - loadCredentials: vi.fn(), - clearCredentials: vi.fn(), - }, -})); - -const mockConfig = { - getNoBrowser: () => false, - getProxy: () => 'http://test.proxy.com:8080', - isBrowserLaunchSuppressed: () => false, -} as unknown as Config; - -// Mock fetch globally -global.fetch = vi.fn(); - -describe('oauth2', () => { - describe('with encrypted flag false', () => { - let tempHomeDir: string; - - beforeEach(() => { - process.env[FORCE_ENCRYPTED_FILE_ENV_VAR] = 'false'; - tempHomeDir = fs.mkdtempSync( - path.join(os.tmpdir(), 'qwen-code-test-home-'), - ); - (os.homedir as Mock).mockReturnValue(tempHomeDir); - }); - afterEach(() => { - fs.rmSync(tempHomeDir, { recursive: true, force: true }); - vi.clearAllMocks(); - resetOauthClientForTesting(); - vi.unstubAllEnvs(); - }); - - it('should perform a web login', async () => { - const mockAuthUrl = 'https://example.com/auth'; - const mockCode = 'test-code'; - const mockState = 'test-state'; - const mockTokens = { - access_token: 'test-access-token', - refresh_token: 'test-refresh-token', - }; - - const mockGenerateAuthUrl = vi.fn().mockReturnValue(mockAuthUrl); - const mockGetToken = vi.fn().mockResolvedValue({ tokens: mockTokens }); - const mockSetCredentials = vi.fn(); - const mockGetAccessToken = vi - .fn() - .mockResolvedValue({ token: 'mock-access-token' }); - const mockOAuth2Client = { - generateAuthUrl: mockGenerateAuthUrl, - getToken: mockGetToken, - setCredentials: mockSetCredentials, - getAccessToken: mockGetAccessToken, - credentials: mockTokens, - on: vi.fn(), - } as unknown as OAuth2Client; - (OAuth2Client as unknown as Mock).mockImplementation( - () => mockOAuth2Client, - ); - - vi.spyOn(crypto, 'randomBytes').mockReturnValue(mockState as never); - (open as Mock).mockImplementation(async () => ({ on: vi.fn() }) as never); - - // Mock the UserInfo API response - (global.fetch as Mock).mockResolvedValue({ - ok: true, - json: vi - .fn() - .mockResolvedValue({ email: 'test-google-account@gmail.com' }), - } as unknown as Response); - - let requestCallback!: http.RequestListener< - typeof http.IncomingMessage, - typeof http.ServerResponse - >; - - let serverListeningCallback: (value: unknown) => void; - const serverListeningPromise = new Promise( - (resolve) => (serverListeningCallback = resolve), - ); - - let capturedPort = 0; - const mockHttpServer = { - listen: vi.fn((port: number, _host: string, callback?: () => void) => { - capturedPort = port; - if (callback) { - callback(); - } - serverListeningCallback(undefined); - }), - close: vi.fn((callback?: () => void) => { - if (callback) { - callback(); - } - }), - on: vi.fn(), - address: () => ({ port: capturedPort }), - }; - (http.createServer as Mock).mockImplementation((cb) => { - requestCallback = cb as http.RequestListener< - typeof http.IncomingMessage, - typeof http.ServerResponse - >; - return mockHttpServer as unknown as http.Server; - }); - - const clientPromise = getOauthClient( - AuthType.LOGIN_WITH_GOOGLE, - mockConfig, - ); - - // wait for server to start listening. - await serverListeningPromise; - - const mockReq = { - url: `/oauth2callback?code=${mockCode}&state=${mockState}`, - } as http.IncomingMessage; - const mockRes = { - writeHead: vi.fn(), - end: vi.fn(), - } as unknown as http.ServerResponse; - - await requestCallback(mockReq, mockRes); - - const client = await clientPromise; - expect(client).toBe(mockOAuth2Client); - - expect(open).toHaveBeenCalledWith(mockAuthUrl); - expect(mockGetToken).toHaveBeenCalledWith({ - code: mockCode, - redirect_uri: `http://localhost:${capturedPort}/oauth2callback`, - }); - expect(mockSetCredentials).toHaveBeenCalledWith(mockTokens); - - // Verify Google Account was cached - const googleAccountPath = path.join( - tempHomeDir, - QWEN_DIR, - 'google_accounts.json', - ); - expect(fs.existsSync(googleAccountPath)).toBe(true); - const cachedGoogleAccount = fs.readFileSync(googleAccountPath, 'utf-8'); - expect(JSON.parse(cachedGoogleAccount)).toEqual({ - active: 'test-google-account@gmail.com', - old: [], - }); - - // Verify the getCachedGoogleAccount function works - const userAccountManager = new UserAccountManager(); - expect(userAccountManager.getCachedGoogleAccount()).toBe( - 'test-google-account@gmail.com', - ); - }); - - it('should perform login with user code', async () => { - const mockConfigWithNoBrowser = { - getNoBrowser: () => true, - getProxy: () => 'http://test.proxy.com:8080', - isBrowserLaunchSuppressed: () => true, - } as unknown as Config; - - const mockCodeVerifier = { - codeChallenge: 'test-challenge', - codeVerifier: 'test-verifier', - }; - const mockAuthUrl = 'https://example.com/auth-user-code'; - const mockCode = 'test-user-code'; - const mockTokens = { - access_token: 'test-access-token-user-code', - refresh_token: 'test-refresh-token-user-code', - }; - - const mockGenerateAuthUrl = vi.fn().mockReturnValue(mockAuthUrl); - const mockGetToken = vi.fn().mockResolvedValue({ tokens: mockTokens }); - const mockSetCredentials = vi.fn(); - const mockGenerateCodeVerifierAsync = vi - .fn() - .mockResolvedValue(mockCodeVerifier); - - const mockOAuth2Client = { - generateAuthUrl: mockGenerateAuthUrl, - getToken: mockGetToken, - setCredentials: mockSetCredentials, - generateCodeVerifierAsync: mockGenerateCodeVerifierAsync, - on: vi.fn(), - } as unknown as OAuth2Client; - (OAuth2Client as unknown as Mock).mockImplementation( - () => mockOAuth2Client, - ); - - const mockReadline = { - question: vi.fn((_query, callback) => callback(mockCode)), - close: vi.fn(), - }; - (readline.createInterface as Mock).mockReturnValue(mockReadline); - - const consoleLogSpy = vi - .spyOn(console, 'log') - .mockImplementation(() => {}); - - const client = await getOauthClient( - AuthType.LOGIN_WITH_GOOGLE, - mockConfigWithNoBrowser, - ); - - expect(client).toBe(mockOAuth2Client); - - // Verify the auth flow - expect(mockGenerateCodeVerifierAsync).toHaveBeenCalled(); - expect(mockGenerateAuthUrl).toHaveBeenCalled(); - expect(consoleLogSpy).toHaveBeenCalledWith( - expect.stringContaining(mockAuthUrl), - ); - expect(mockReadline.question).toHaveBeenCalledWith( - 'Enter the authorization code: ', - expect.any(Function), - ); - expect(mockGetToken).toHaveBeenCalledWith({ - code: mockCode, - codeVerifier: mockCodeVerifier.codeVerifier, - redirect_uri: 'https://codeassist.google.com/authcode', - }); - expect(mockSetCredentials).toHaveBeenCalledWith(mockTokens); - - consoleLogSpy.mockRestore(); - }); - - describe('in Cloud Shell', () => { - const mockGetAccessToken = vi.fn(); - let mockComputeClient: Compute; - - beforeEach(() => { - mockGetAccessToken.mockResolvedValue({ token: 'test-access-token' }); - mockComputeClient = { - credentials: { refresh_token: 'test-refresh-token' }, - getAccessToken: mockGetAccessToken, - } as unknown as Compute; - - (Compute as unknown as Mock).mockImplementation( - () => mockComputeClient, - ); - }); - - it('should attempt to load cached credentials first', async () => { - const cachedCreds = { refresh_token: 'cached-token' }; - const credsPath = path.join(tempHomeDir, '.qwen', 'oauth_creds.json'); - await fs.promises.mkdir(path.dirname(credsPath), { recursive: true }); - await fs.promises.writeFile(credsPath, JSON.stringify(cachedCreds)); - - const mockClient = { - setCredentials: vi.fn(), - getAccessToken: vi.fn().mockResolvedValue({ token: 'test-token' }), - getTokenInfo: vi.fn().mockResolvedValue({}), - on: vi.fn(), - }; - - // To mock the new OAuth2Client() inside the function - (OAuth2Client as unknown as Mock).mockImplementation( - () => mockClient as unknown as OAuth2Client, - ); - - await getOauthClient(AuthType.LOGIN_WITH_GOOGLE, mockConfig); - - expect(mockClient.setCredentials).toHaveBeenCalledWith(cachedCreds); - expect(mockClient.getAccessToken).toHaveBeenCalled(); - expect(mockClient.getTokenInfo).toHaveBeenCalled(); - expect(Compute).not.toHaveBeenCalled(); // Should not fetch new client if cache is valid - }); - - it('should use Compute to get a client if no cached credentials exist', async () => { - await getOauthClient(AuthType.CLOUD_SHELL, mockConfig); - - expect(Compute).toHaveBeenCalledWith({}); - expect(mockGetAccessToken).toHaveBeenCalled(); - }); - - it('should not cache the credentials after fetching them via ADC', async () => { - const newCredentials = { refresh_token: 'new-adc-token' }; - mockComputeClient.credentials = newCredentials; - mockGetAccessToken.mockResolvedValue({ token: 'new-adc-token' }); - - await getOauthClient(AuthType.CLOUD_SHELL, mockConfig); - - const credsPath = path.join(tempHomeDir, '.qwen', 'oauth_creds.json'); - expect(fs.existsSync(credsPath)).toBe(false); - }); - - it('should return the Compute client on successful ADC authentication', async () => { - const client = await getOauthClient(AuthType.CLOUD_SHELL, mockConfig); - expect(client).toBe(mockComputeClient); - }); - - it('should throw an error if ADC fails', async () => { - const testError = new Error('ADC Failed'); - mockGetAccessToken.mockRejectedValue(testError); - - await expect( - getOauthClient(AuthType.CLOUD_SHELL, mockConfig), - ).rejects.toThrow( - 'Could not authenticate using Cloud Shell credentials. Please select a different authentication method or ensure you are in a properly configured environment. Error: ADC Failed', - ); - }); - }); - - describe('credential loading order', () => { - it('should prioritize default cached credentials over GOOGLE_APPLICATION_CREDENTIALS', async () => { - // Setup default cached credentials - const defaultCreds = { refresh_token: 'default-cached-token' }; - const defaultCredsPath = path.join( - tempHomeDir, - '.qwen', - 'oauth_creds.json', - ); - await fs.promises.mkdir(path.dirname(defaultCredsPath), { - recursive: true, - }); - await fs.promises.writeFile( - defaultCredsPath, - JSON.stringify(defaultCreds), - ); - - // Setup credentials via environment variable - const envCreds = { refresh_token: 'env-var-token' }; - const envCredsPath = path.join(tempHomeDir, 'env_creds.json'); - await fs.promises.writeFile(envCredsPath, JSON.stringify(envCreds)); - vi.stubEnv('GOOGLE_APPLICATION_CREDENTIALS', envCredsPath); - - const mockClient = { - setCredentials: vi.fn(), - getAccessToken: vi.fn().mockResolvedValue({ token: 'test-token' }), - getTokenInfo: vi.fn().mockResolvedValue({}), - on: vi.fn(), - }; - (OAuth2Client as unknown as Mock).mockImplementation( - () => mockClient as unknown as OAuth2Client, - ); - - await getOauthClient(AuthType.LOGIN_WITH_GOOGLE, mockConfig); - - // Assert the correct credentials were used - expect(mockClient.setCredentials).toHaveBeenCalledWith(defaultCreds); - expect(mockClient.setCredentials).not.toHaveBeenCalledWith(envCreds); - }); - - it('should fall back to GOOGLE_APPLICATION_CREDENTIALS if default cache is missing', async () => { - // Setup credentials via environment variable - const envCreds = { refresh_token: 'env-var-token' }; - const envCredsPath = path.join(tempHomeDir, 'env_creds.json'); - await fs.promises.writeFile(envCredsPath, JSON.stringify(envCreds)); - vi.stubEnv('GOOGLE_APPLICATION_CREDENTIALS', envCredsPath); - - const mockClient = { - setCredentials: vi.fn(), - getAccessToken: vi.fn().mockResolvedValue({ token: 'test-token' }), - getTokenInfo: vi.fn().mockResolvedValue({}), - on: vi.fn(), - }; - (OAuth2Client as unknown as Mock).mockImplementation( - () => mockClient as unknown as OAuth2Client, - ); - - await getOauthClient(AuthType.LOGIN_WITH_GOOGLE, mockConfig); - - // Assert the correct credentials were used - expect(mockClient.setCredentials).toHaveBeenCalledWith(envCreds); - }); - }); - - describe('with GCP environment variables', () => { - it('should use GOOGLE_CLOUD_ACCESS_TOKEN when GOOGLE_GENAI_USE_GCA is true', async () => { - vi.stubEnv('GOOGLE_GENAI_USE_GCA', 'true'); - vi.stubEnv('GOOGLE_CLOUD_ACCESS_TOKEN', 'gcp-access-token'); - - const mockSetCredentials = vi.fn(); - const mockGetAccessToken = vi - .fn() - .mockResolvedValue({ token: 'gcp-access-token' }); - const mockOAuth2Client = { - setCredentials: mockSetCredentials, - getAccessToken: mockGetAccessToken, - on: vi.fn(), - } as unknown as OAuth2Client; - (OAuth2Client as unknown as Mock).mockImplementation( - () => mockOAuth2Client, - ); - - // Mock the UserInfo API response for fetchAndCacheUserInfo - (global.fetch as Mock).mockResolvedValue({ - ok: true, - json: vi - .fn() - .mockResolvedValue({ email: 'test-gcp-account@gmail.com' }), - } as unknown as Response); - - const client = await getOauthClient( - AuthType.LOGIN_WITH_GOOGLE, - mockConfig, - ); - - expect(client).toBe(mockOAuth2Client); - expect(mockSetCredentials).toHaveBeenCalledWith({ - access_token: 'gcp-access-token', - }); - - // Verify fetchAndCacheUserInfo was effectively called - expect(mockGetAccessToken).toHaveBeenCalled(); - expect(global.fetch).toHaveBeenCalledWith( - 'https://www.googleapis.com/oauth2/v2/userinfo', - { - headers: { - Authorization: 'Bearer gcp-access-token', - }, - }, - ); - - // Verify Google Account was cached - const googleAccountPath = path.join( - tempHomeDir, - '.qwen', - 'google_accounts.json', - ); - const cachedContent = fs.readFileSync(googleAccountPath, 'utf-8'); - expect(JSON.parse(cachedContent)).toEqual({ - active: 'test-gcp-account@gmail.com', - old: [], - }); - }); - - it('should not use GCP token if GOOGLE_CLOUD_ACCESS_TOKEN is not set', async () => { - vi.stubEnv('GOOGLE_GENAI_USE_GCA', 'true'); - - const mockSetCredentials = vi.fn(); - const mockGetAccessToken = vi - .fn() - .mockResolvedValue({ token: 'cached-access-token' }); - const mockGetTokenInfo = vi.fn().mockResolvedValue({}); - const mockOAuth2Client = { - setCredentials: mockSetCredentials, - getAccessToken: mockGetAccessToken, - getTokenInfo: mockGetTokenInfo, - on: vi.fn(), - } as unknown as OAuth2Client; - (OAuth2Client as unknown as Mock).mockImplementation( - () => mockOAuth2Client, - ); - - // Make it fall through to cached credentials path - const cachedCreds = { refresh_token: 'cached-token' }; - const credsPath = path.join(tempHomeDir, '.qwen', 'oauth_creds.json'); - await fs.promises.mkdir(path.dirname(credsPath), { recursive: true }); - await fs.promises.writeFile(credsPath, JSON.stringify(cachedCreds)); - - await getOauthClient(AuthType.LOGIN_WITH_GOOGLE, mockConfig); - - // It should be called with the cached credentials, not the GCP access token. - expect(mockSetCredentials).toHaveBeenCalledTimes(1); - expect(mockSetCredentials).toHaveBeenCalledWith(cachedCreds); - }); - - it('should not use GCP token if GOOGLE_GENAI_USE_GCA is not set', async () => { - vi.stubEnv('GOOGLE_CLOUD_ACCESS_TOKEN', 'gcp-access-token'); - - const mockSetCredentials = vi.fn(); - const mockGetAccessToken = vi - .fn() - .mockResolvedValue({ token: 'cached-access-token' }); - const mockGetTokenInfo = vi.fn().mockResolvedValue({}); - const mockOAuth2Client = { - setCredentials: mockSetCredentials, - getAccessToken: mockGetAccessToken, - getTokenInfo: mockGetTokenInfo, - on: vi.fn(), - } as unknown as OAuth2Client; - (OAuth2Client as unknown as Mock).mockImplementation( - () => mockOAuth2Client, - ); - - // Make it fall through to cached credentials path - const cachedCreds = { refresh_token: 'cached-token' }; - const credsPath = path.join(tempHomeDir, '.qwen', 'oauth_creds.json'); - await fs.promises.mkdir(path.dirname(credsPath), { recursive: true }); - await fs.promises.writeFile(credsPath, JSON.stringify(cachedCreds)); - - await getOauthClient(AuthType.LOGIN_WITH_GOOGLE, mockConfig); - - // It should be called with the cached credentials, not the GCP access token. - expect(mockSetCredentials).toHaveBeenCalledTimes(1); - expect(mockSetCredentials).toHaveBeenCalledWith(cachedCreds); - }); - }); - - describe('error handling', () => { - it('should handle browser launch failure with FatalAuthenticationError', async () => { - const mockError = new Error('Browser launch failed'); - (open as Mock).mockRejectedValue(mockError); - - const mockOAuth2Client = { - generateAuthUrl: vi.fn().mockReturnValue('https://example.com/auth'), - on: vi.fn(), - } as unknown as OAuth2Client; - (OAuth2Client as unknown as Mock).mockImplementation( - () => mockOAuth2Client, - ); - - await expect( - getOauthClient(AuthType.LOGIN_WITH_GOOGLE, mockConfig), - ).rejects.toThrow('Failed to open browser: Browser launch failed'); - }); - - it('should handle authentication timeout with proper error message', async () => { - const mockAuthUrl = 'https://example.com/auth'; - const mockOAuth2Client = { - generateAuthUrl: vi.fn().mockReturnValue(mockAuthUrl), - on: vi.fn(), - } as unknown as OAuth2Client; - (OAuth2Client as unknown as Mock).mockImplementation( - () => mockOAuth2Client, - ); - - (open as Mock).mockImplementation( - async () => ({ on: vi.fn() }) as never, - ); - - const mockHttpServer = { - listen: vi.fn(), - close: vi.fn(), - on: vi.fn(), - address: () => ({ port: 3000 }), - }; - (http.createServer as Mock).mockImplementation( - () => mockHttpServer as unknown as http.Server, - ); - - // Mock setTimeout to trigger timeout immediately - const originalSetTimeout = global.setTimeout; - global.setTimeout = vi.fn( - (callback) => (callback(), {} as unknown as NodeJS.Timeout), - ) as unknown as typeof setTimeout; - - await expect( - getOauthClient(AuthType.LOGIN_WITH_GOOGLE, mockConfig), - ).rejects.toThrow( - 'Authentication timed out after 5 minutes. The browser tab may have gotten stuck in a loading state. Please try again or use NO_BROWSER=true for manual authentication.', - ); - - global.setTimeout = originalSetTimeout; - }); - - it('should handle OAuth callback errors with descriptive messages', async () => { - const mockAuthUrl = 'https://example.com/auth'; - const mockOAuth2Client = { - generateAuthUrl: vi.fn().mockReturnValue(mockAuthUrl), - on: vi.fn(), - } as unknown as OAuth2Client; - (OAuth2Client as unknown as Mock).mockImplementation( - () => mockOAuth2Client, - ); - - (open as Mock).mockImplementation( - async () => ({ on: vi.fn() }) as never, - ); - - let requestCallback!: http.RequestListener; - let serverListeningCallback: (value: unknown) => void; - const serverListeningPromise = new Promise( - (resolve) => (serverListeningCallback = resolve), - ); - - const mockHttpServer = { - listen: vi.fn( - (_port: number, _host: string, callback?: () => void) => { - if (callback) callback(); - serverListeningCallback(undefined); - }, - ), - close: vi.fn(), - on: vi.fn(), - address: () => ({ port: 3000 }), - }; - (http.createServer as Mock).mockImplementation((cb) => { - requestCallback = cb; - return mockHttpServer as unknown as http.Server; - }); - - const clientPromise = getOauthClient( - AuthType.LOGIN_WITH_GOOGLE, - mockConfig, - ); - await serverListeningPromise; - - // Test OAuth error with description - const mockReq = { - url: '/oauth2callback?error=access_denied&error_description=User+denied+access', - } as http.IncomingMessage; - const mockRes = { - writeHead: vi.fn(), - end: vi.fn(), - } as unknown as http.ServerResponse; - - await expect(async () => { - await requestCallback(mockReq, mockRes); - await clientPromise; - }).rejects.toThrow( - 'Google OAuth error: access_denied. User denied access', - ); - }); - - it('should handle OAuth error without description', async () => { - const mockAuthUrl = 'https://example.com/auth'; - const mockOAuth2Client = { - generateAuthUrl: vi.fn().mockReturnValue(mockAuthUrl), - on: vi.fn(), - } as unknown as OAuth2Client; - (OAuth2Client as unknown as Mock).mockImplementation( - () => mockOAuth2Client, - ); - - (open as Mock).mockImplementation( - async () => ({ on: vi.fn() }) as never, - ); - - let requestCallback!: http.RequestListener; - let serverListeningCallback: (value: unknown) => void; - const serverListeningPromise = new Promise( - (resolve) => (serverListeningCallback = resolve), - ); - - const mockHttpServer = { - listen: vi.fn( - (_port: number, _host: string, callback?: () => void) => { - if (callback) callback(); - serverListeningCallback(undefined); - }, - ), - close: vi.fn(), - on: vi.fn(), - address: () => ({ port: 3000 }), - }; - (http.createServer as Mock).mockImplementation((cb) => { - requestCallback = cb; - return mockHttpServer as unknown as http.Server; - }); - - const clientPromise = getOauthClient( - AuthType.LOGIN_WITH_GOOGLE, - mockConfig, - ); - await serverListeningPromise; - - // Test OAuth error without description - const mockReq = { - url: '/oauth2callback?error=server_error', - } as http.IncomingMessage; - const mockRes = { - writeHead: vi.fn(), - end: vi.fn(), - } as unknown as http.ServerResponse; - - await expect(async () => { - await requestCallback(mockReq, mockRes); - await clientPromise; - }).rejects.toThrow( - 'Google OAuth error: server_error. No additional details provided', - ); - }); - - it('should handle token exchange failure with descriptive error', async () => { - const mockAuthUrl = 'https://example.com/auth'; - const mockCode = 'test-code'; - const mockState = 'test-state'; - - const mockOAuth2Client = { - generateAuthUrl: vi.fn().mockReturnValue(mockAuthUrl), - getToken: vi - .fn() - .mockRejectedValue(new Error('Token exchange failed')), - on: vi.fn(), - } as unknown as OAuth2Client; - (OAuth2Client as unknown as Mock).mockImplementation( - () => mockOAuth2Client, - ); - - vi.spyOn(crypto, 'randomBytes').mockReturnValue(mockState as never); - (open as Mock).mockImplementation( - async () => ({ on: vi.fn() }) as never, - ); - - let requestCallback!: http.RequestListener; - let serverListeningCallback: (value: unknown) => void; - const serverListeningPromise = new Promise( - (resolve) => (serverListeningCallback = resolve), - ); - - const mockHttpServer = { - listen: vi.fn( - (_port: number, _host: string, callback?: () => void) => { - if (callback) callback(); - serverListeningCallback(undefined); - }, - ), - close: vi.fn(), - on: vi.fn(), - address: () => ({ port: 3000 }), - }; - (http.createServer as Mock).mockImplementation((cb) => { - requestCallback = cb; - return mockHttpServer as unknown as http.Server; - }); - - const clientPromise = getOauthClient( - AuthType.LOGIN_WITH_GOOGLE, - mockConfig, - ); - await serverListeningPromise; - - const mockReq = { - url: `/oauth2callback?code=${mockCode}&state=${mockState}`, - } as http.IncomingMessage; - const mockRes = { - writeHead: vi.fn(), - end: vi.fn(), - } as unknown as http.ServerResponse; - - await expect(async () => { - await requestCallback(mockReq, mockRes); - await clientPromise; - }).rejects.toThrow( - 'Failed to exchange authorization code for tokens: Token exchange failed', - ); - }); - - it('should handle fetchAndCacheUserInfo failure gracefully', async () => { - const mockAuthUrl = 'https://example.com/auth'; - const mockCode = 'test-code'; - const mockState = 'test-state'; - const mockTokens = { - access_token: 'test-access-token', - refresh_token: 'test-refresh-token', - }; - - const mockOAuth2Client = { - generateAuthUrl: vi.fn().mockReturnValue(mockAuthUrl), - getToken: vi.fn().mockResolvedValue({ tokens: mockTokens }), - setCredentials: vi.fn(), - getAccessToken: vi - .fn() - .mockResolvedValue({ token: 'test-access-token' }), - on: vi.fn(), - } as unknown as OAuth2Client; - (OAuth2Client as unknown as Mock).mockImplementation( - () => mockOAuth2Client, - ); - - vi.spyOn(crypto, 'randomBytes').mockReturnValue(mockState as never); - (open as Mock).mockImplementation( - async () => ({ on: vi.fn() }) as never, - ); - - // Mock fetch to fail - (global.fetch as Mock).mockResolvedValue({ - ok: false, - status: 500, - statusText: 'Internal Server Error', - } as unknown as Response); - - const consoleErrorSpy = vi - .spyOn(console, 'error') - .mockImplementation(() => {}); - - let requestCallback!: http.RequestListener; - let serverListeningCallback: (value: unknown) => void; - const serverListeningPromise = new Promise( - (resolve) => (serverListeningCallback = resolve), - ); - - const mockHttpServer = { - listen: vi.fn( - (_port: number, _host: string, callback?: () => void) => { - if (callback) callback(); - serverListeningCallback(undefined); - }, - ), - close: vi.fn(), - on: vi.fn(), - address: () => ({ port: 3000 }), - }; - (http.createServer as Mock).mockImplementation((cb) => { - requestCallback = cb; - return mockHttpServer as unknown as http.Server; - }); - - const clientPromise = getOauthClient( - AuthType.LOGIN_WITH_GOOGLE, - mockConfig, - ); - await serverListeningPromise; - - const mockReq = { - url: `/oauth2callback?code=${mockCode}&state=${mockState}`, - } as http.IncomingMessage; - const mockRes = { - writeHead: vi.fn(), - end: vi.fn(), - } as unknown as http.ServerResponse; - - await requestCallback(mockReq, mockRes); - const client = await clientPromise; - - // Authentication should succeed even if fetchAndCacheUserInfo fails - expect(client).toBe(mockOAuth2Client); - expect(consoleErrorSpy).toHaveBeenCalledWith( - 'Failed to fetch user info:', - 500, - 'Internal Server Error', - ); - - consoleErrorSpy.mockRestore(); - }); - - it('should handle user code authentication failure with descriptive error', async () => { - const mockConfigWithNoBrowser = { - getNoBrowser: () => true, - getProxy: () => 'http://test.proxy.com:8080', - isBrowserLaunchSuppressed: () => true, - } as unknown as Config; - - const mockOAuth2Client = { - generateCodeVerifierAsync: vi.fn().mockResolvedValue({ - codeChallenge: 'test-challenge', - codeVerifier: 'test-verifier', - }), - generateAuthUrl: vi.fn().mockReturnValue('https://example.com/auth'), - getToken: vi - .fn() - .mockRejectedValue(new Error('Invalid authorization code')), - on: vi.fn(), - } as unknown as OAuth2Client; - (OAuth2Client as unknown as Mock).mockImplementation( - () => mockOAuth2Client, - ); - - const mockReadline = { - question: vi.fn((_query, callback) => callback('invalid-code')), - close: vi.fn(), - }; - (readline.createInterface as Mock).mockReturnValue(mockReadline); - - const consoleLogSpy = vi - .spyOn(console, 'log') - .mockImplementation(() => {}); - const consoleErrorSpy = vi - .spyOn(console, 'error') - .mockImplementation(() => {}); - - await expect( - getOauthClient(AuthType.LOGIN_WITH_GOOGLE, mockConfigWithNoBrowser), - ).rejects.toThrow('Failed to authenticate with user code.'); - - expect(consoleErrorSpy).toHaveBeenCalledWith( - 'Failed to authenticate with authorization code:', - 'Invalid authorization code', - ); - - consoleLogSpy.mockRestore(); - consoleErrorSpy.mockRestore(); - }); - }); - - describe('clearCachedCredentialFile', () => { - it('should clear cached credentials and Google account', async () => { - const cachedCreds = { refresh_token: 'test-token' }; - const credsPath = path.join(tempHomeDir, '.qwen', 'oauth_creds.json'); - await fs.promises.mkdir(path.dirname(credsPath), { recursive: true }); - await fs.promises.writeFile(credsPath, JSON.stringify(cachedCreds)); - - const googleAccountPath = path.join( - tempHomeDir, - '.qwen', - 'google_accounts.json', - ); - const accountData = { active: 'test@example.com', old: [] }; - await fs.promises.writeFile( - googleAccountPath, - JSON.stringify(accountData), - ); - const userAccountManager = new UserAccountManager(); - - expect(fs.existsSync(credsPath)).toBe(true); - expect(fs.existsSync(googleAccountPath)).toBe(true); - expect(userAccountManager.getCachedGoogleAccount()).toBe( - 'test@example.com', - ); - - await clearCachedCredentialFile(); - expect(fs.existsSync(credsPath)).toBe(false); - expect(userAccountManager.getCachedGoogleAccount()).toBeNull(); - const updatedAccountData = JSON.parse( - fs.readFileSync(googleAccountPath, 'utf-8'), - ); - expect(updatedAccountData.active).toBeNull(); - expect(updatedAccountData.old).toContain('test@example.com'); - }); - - it('should clear the in-memory OAuth client cache', async () => { - const mockSetCredentials = vi.fn(); - const mockGetAccessToken = vi - .fn() - .mockResolvedValue({ token: 'test-token' }); - const mockGetTokenInfo = vi.fn().mockResolvedValue({}); - const mockOAuth2Client = { - setCredentials: mockSetCredentials, - getAccessToken: mockGetAccessToken, - getTokenInfo: mockGetTokenInfo, - on: vi.fn(), - } as unknown as OAuth2Client; - (OAuth2Client as unknown as Mock).mockImplementation( - () => mockOAuth2Client, - ); - - // Pre-populate credentials to make getOauthClient resolve quickly - const credsPath = path.join(tempHomeDir, '.qwen', 'oauth_creds.json'); - await fs.promises.mkdir(path.dirname(credsPath), { recursive: true }); - await fs.promises.writeFile( - credsPath, - JSON.stringify({ refresh_token: 'token' }), - ); - - // First call, should create a client - await getOauthClient(AuthType.LOGIN_WITH_GOOGLE, mockConfig); - expect(OAuth2Client).toHaveBeenCalledTimes(1); - - // Second call, should use cached client - await getOauthClient(AuthType.LOGIN_WITH_GOOGLE, mockConfig); - expect(OAuth2Client).toHaveBeenCalledTimes(1); - - clearOauthClientCache(); - - // Third call, after clearing cache, should create a new client - await getOauthClient(AuthType.LOGIN_WITH_GOOGLE, mockConfig); - expect(OAuth2Client).toHaveBeenCalledTimes(2); - }); - }); - }); - - describe('with encrypted flag true', () => { - let tempHomeDir: string; - beforeEach(() => { - process.env[FORCE_ENCRYPTED_FILE_ENV_VAR] = 'true'; - tempHomeDir = fs.mkdtempSync( - path.join(os.tmpdir(), 'qwen-code-test-home-'), - ); - (os.homedir as Mock).mockReturnValue(tempHomeDir); - }); - - afterEach(() => { - fs.rmSync(tempHomeDir, { recursive: true, force: true }); - vi.clearAllMocks(); - resetOauthClientForTesting(); - vi.unstubAllEnvs(); - }); - - it('should save credentials using OAuthCredentialStorage during web login', async () => { - const { OAuthCredentialStorage } = await import( - './oauth-credential-storage.js' - ); - const mockAuthUrl = 'https://example.com/auth'; - const mockCode = 'test-code'; - const mockState = 'test-state'; - const mockTokens = { - access_token: 'test-access-token', - refresh_token: 'test-refresh-token', - }; - - let onTokensCallback: (tokens: Credentials) => void = () => {}; - const mockOn = vi.fn((event, callback) => { - if (event === 'tokens') { - onTokensCallback = callback; - } - }); - - const mockGetToken = vi.fn().mockImplementation(async () => { - onTokensCallback(mockTokens); - return { tokens: mockTokens }; - }); - - const mockOAuth2Client = { - generateAuthUrl: vi.fn().mockReturnValue(mockAuthUrl), - getToken: mockGetToken, - setCredentials: vi.fn(), - getAccessToken: vi - .fn() - .mockResolvedValue({ token: 'mock-access-token' }), - on: mockOn, - credentials: mockTokens, - } as unknown as OAuth2Client; - (OAuth2Client as unknown as Mock).mockImplementation( - () => mockOAuth2Client, - ); - - vi.spyOn(crypto, 'randomBytes').mockReturnValue(mockState as never); - (open as Mock).mockImplementation(async () => ({ on: vi.fn() }) as never); - - (global.fetch as Mock).mockResolvedValue({ - ok: true, - json: vi - .fn() - .mockResolvedValue({ email: 'test-google-account@gmail.com' }), - } as unknown as Response); - - let requestCallback!: http.RequestListener; - let serverListeningCallback: (value: unknown) => void; - const serverListeningPromise = new Promise( - (resolve) => (serverListeningCallback = resolve), - ); - - let capturedPort = 0; - const mockHttpServer = { - listen: vi.fn((port: number, _host: string, callback?: () => void) => { - capturedPort = port; - if (callback) { - callback(); - } - serverListeningCallback(undefined); - }), - close: vi.fn((callback?: () => void) => { - if (callback) { - callback(); - } - }), - on: vi.fn(), - address: () => ({ port: capturedPort }), - }; - (http.createServer as Mock).mockImplementation((cb) => { - requestCallback = cb as http.RequestListener; - return mockHttpServer as unknown as http.Server; - }); - - const clientPromise = getOauthClient( - AuthType.LOGIN_WITH_GOOGLE, - mockConfig, - ); - - await serverListeningPromise; - - const mockReq = { - url: `/oauth2callback?code=${mockCode}&state=${mockState}`, - } as http.IncomingMessage; - const mockRes = { - writeHead: vi.fn(), - end: vi.fn(), - } as unknown as http.ServerResponse; - - requestCallback(mockReq, mockRes); - - await clientPromise; - - expect( - OAuthCredentialStorage.saveCredentials as Mock, - ).toHaveBeenCalledWith(mockTokens); - const credsPath = path.join(tempHomeDir, '.qwen', 'oauth_creds.json'); - expect(fs.existsSync(credsPath)).toBe(false); - }); - - it('should load credentials using OAuthCredentialStorage and not from file', async () => { - const { OAuthCredentialStorage } = await import( - './oauth-credential-storage.js' - ); - const cachedCreds = { refresh_token: 'cached-encrypted-token' }; - (OAuthCredentialStorage.loadCredentials as Mock).mockResolvedValue( - cachedCreds, - ); - - // Create a dummy unencrypted credential file. - // If the logic is correct, this file should be ignored. - const unencryptedCreds = { refresh_token: 'unencrypted-token' }; - const credsPath = path.join(tempHomeDir, '.qwen', 'oauth_creds.json'); - await fs.promises.mkdir(path.dirname(credsPath), { recursive: true }); - await fs.promises.writeFile(credsPath, JSON.stringify(unencryptedCreds)); - - const mockClient = { - setCredentials: vi.fn(), - getAccessToken: vi.fn().mockResolvedValue({ token: 'test-token' }), - getTokenInfo: vi.fn().mockResolvedValue({}), - on: vi.fn(), - }; - - (OAuth2Client as unknown as Mock).mockImplementation( - () => mockClient as unknown as OAuth2Client, - ); - - await getOauthClient(AuthType.LOGIN_WITH_GOOGLE, mockConfig); - - expect(OAuthCredentialStorage.loadCredentials as Mock).toHaveBeenCalled(); - expect(mockClient.setCredentials).toHaveBeenCalledWith(cachedCreds); - expect(mockClient.setCredentials).not.toHaveBeenCalledWith( - unencryptedCreds, - ); - }); - - it('should clear credentials using OAuthCredentialStorage', async () => { - const { OAuthCredentialStorage } = await import( - './oauth-credential-storage.js' - ); - - // Create a dummy unencrypted credential file. It should be deleted as part of cleanup. - const credsPath = path.join(tempHomeDir, '.qwen', 'oauth_creds.json'); - await fs.promises.mkdir(path.dirname(credsPath), { recursive: true }); - await fs.promises.writeFile(credsPath, '{}'); - - await clearCachedCredentialFile(); - - expect( - OAuthCredentialStorage.clearCredentials as Mock, - ).toHaveBeenCalled(); - expect(fs.existsSync(credsPath)).toBe(false); // The unencrypted file should be cleaned up - }); - }); -}); diff --git a/packages/core/src/code_assist/oauth2.ts b/packages/core/src/code_assist/oauth2.ts deleted file mode 100644 index b86148e9f..000000000 --- a/packages/core/src/code_assist/oauth2.ts +++ /dev/null @@ -1,563 +0,0 @@ -/** - * @license - * Copyright 2025 Google LLC - * SPDX-License-Identifier: Apache-2.0 - */ - -import type { Credentials } from 'google-auth-library'; -import { - CodeChallengeMethod, - Compute, - OAuth2Client, -} from 'google-auth-library'; -import crypto from 'node:crypto'; -import { promises as fs } from 'node:fs'; -import * as http from 'node:http'; -import * as net from 'node:net'; -import path from 'node:path'; -import readline from 'node:readline'; -import url from 'node:url'; -import open from 'open'; -import type { Config } from '../config/config.js'; -import { Storage } from '../config/storage.js'; -import { AuthType } from '../core/contentGenerator.js'; -import { FatalAuthenticationError, getErrorMessage } from '../utils/errors.js'; -import { UserAccountManager } from '../utils/userAccountManager.js'; -import { OAuthCredentialStorage } from './oauth-credential-storage.js'; -import { FORCE_ENCRYPTED_FILE_ENV_VAR } from '../mcp/token-storage/index.js'; - -const userAccountManager = new UserAccountManager(); - -// OAuth Client ID used to initiate OAuth2Client class. -const OAUTH_CLIENT_ID = - '681255809395-oo8ft2oprdrnp9e3aqf6av3hmdib135j.apps.googleusercontent.com'; - -// OAuth Secret value used to initiate OAuth2Client class. -// Note: It's ok to save this in git because this is an installed application -// as described here: https://developers.google.com/identity/protocols/oauth2#installed -// "The process results in a client ID and, in some cases, a client secret, -// which you embed in the source code of your application. (In this context, -// the client secret is obviously not treated as a secret.)" -const OAUTH_CLIENT_SECRET = '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'; - -// OAuth Scopes for Cloud Code authorization. -const OAUTH_SCOPE = [ - 'https://www.googleapis.com/auth/cloud-platform', - 'https://www.googleapis.com/auth/userinfo.email', - 'https://www.googleapis.com/auth/userinfo.profile', -]; - -const HTTP_REDIRECT = 301; -const SIGN_IN_SUCCESS_URL = - 'https://developers.google.com/gemini-code-assist/auth_success_gemini'; -const SIGN_IN_FAILURE_URL = - 'https://developers.google.com/gemini-code-assist/auth_failure_gemini'; - -/** - * An Authentication URL for updating the credentials of a Oauth2Client - * as well as a promise that will resolve when the credentials have - * been refreshed (or which throws error when refreshing credentials failed). - */ -export interface OauthWebLogin { - authUrl: string; - loginCompletePromise: Promise; -} - -const oauthClientPromises = new Map>(); - -function getUseEncryptedStorageFlag() { - return process.env[FORCE_ENCRYPTED_FILE_ENV_VAR] === 'true'; -} - -async function initOauthClient( - authType: AuthType, - config: Config, -): Promise { - const client = new OAuth2Client({ - clientId: OAUTH_CLIENT_ID, - clientSecret: OAUTH_CLIENT_SECRET, - transporterOptions: { - proxy: config.getProxy(), - }, - }); - const useEncryptedStorage = getUseEncryptedStorageFlag(); - - if ( - process.env['GOOGLE_GENAI_USE_GCA'] && - process.env['GOOGLE_CLOUD_ACCESS_TOKEN'] - ) { - client.setCredentials({ - access_token: process.env['GOOGLE_CLOUD_ACCESS_TOKEN'], - }); - await fetchAndCacheUserInfo(client); - return client; - } - - client.on('tokens', async (tokens: Credentials) => { - if (useEncryptedStorage) { - await OAuthCredentialStorage.saveCredentials(tokens); - } else { - await cacheCredentials(tokens); - } - }); - - // If there are cached creds on disk, they always take precedence - if (await loadCachedCredentials(client)) { - // Found valid cached credentials. - // Check if we need to retrieve Google Account ID or Email - if (!userAccountManager.getCachedGoogleAccount()) { - try { - await fetchAndCacheUserInfo(client); - } catch (error) { - // Non-fatal, continue with existing auth. - console.warn('Failed to fetch user info:', getErrorMessage(error)); - } - } - console.log('Loaded cached credentials.'); - return client; - } - - // In Google Cloud Shell, we can use Application Default Credentials (ADC) - // provided via its metadata server to authenticate non-interactively using - // the identity of the user logged into Cloud Shell. - if (authType === AuthType.CLOUD_SHELL) { - try { - console.log("Attempting to authenticate via Cloud Shell VM's ADC."); - const computeClient = new Compute({ - // We can leave this empty, since the metadata server will provide - // the service account email. - }); - await computeClient.getAccessToken(); - console.log('Authentication successful.'); - - // Do not cache creds in this case; note that Compute client will handle its own refresh - return computeClient; - } catch (e) { - throw new Error( - `Could not authenticate using Cloud Shell credentials. Please select a different authentication method or ensure you are in a properly configured environment. Error: ${getErrorMessage( - e, - )}`, - ); - } - } - - if (config.isBrowserLaunchSuppressed()) { - let success = false; - const maxRetries = 2; - for (let i = 0; !success && i < maxRetries; i++) { - success = await authWithUserCode(client); - if (!success) { - console.error( - '\nFailed to authenticate with user code.', - i === maxRetries - 1 ? '' : 'Retrying...\n', - ); - } - } - if (!success) { - throw new FatalAuthenticationError( - 'Failed to authenticate with user code.', - ); - } - } else { - const webLogin = await authWithWeb(client); - - console.log( - `\n\nCode Assist login required.\n` + - `Attempting to open authentication page in your browser.\n` + - `Otherwise navigate to:\n\n${webLogin.authUrl}\n\n`, - ); - try { - // Attempt to open the authentication URL in the default browser. - // We do not use the `wait` option here because the main script's execution - // is already paused by `loginCompletePromise`, which awaits the server callback. - const childProcess = await open(webLogin.authUrl); - - // IMPORTANT: Attach an error handler to the returned child process. - // Without this, if `open` fails to spawn a process (e.g., `xdg-open` is not found - // in a minimal Docker container), it will emit an unhandled 'error' event, - // causing the entire Node.js process to crash. - childProcess.on('error', (error) => { - console.error( - 'Failed to open browser automatically. Please try running again with NO_BROWSER=true set.', - ); - console.error('Browser error details:', getErrorMessage(error)); - }); - } catch (err) { - console.error( - 'An unexpected error occurred while trying to open the browser:', - getErrorMessage(err), - '\nThis might be due to browser compatibility issues or system configuration.', - '\nPlease try running again with NO_BROWSER=true set for manual authentication.', - ); - throw new FatalAuthenticationError( - `Failed to open browser: ${getErrorMessage(err)}`, - ); - } - console.log('Waiting for authentication...'); - - // Add timeout to prevent infinite waiting when browser tab gets stuck - const authTimeout = 5 * 60 * 1000; // 5 minutes timeout - const timeoutPromise = new Promise((_, reject) => { - setTimeout(() => { - reject( - new FatalAuthenticationError( - 'Authentication timed out after 5 minutes. The browser tab may have gotten stuck in a loading state. ' + - 'Please try again or use NO_BROWSER=true for manual authentication.', - ), - ); - }, authTimeout); - }); - - await Promise.race([webLogin.loginCompletePromise, timeoutPromise]); - } - - return client; -} - -export async function getOauthClient( - authType: AuthType, - config: Config, -): Promise { - if (!oauthClientPromises.has(authType)) { - oauthClientPromises.set(authType, initOauthClient(authType, config)); - } - return oauthClientPromises.get(authType)!; -} - -async function authWithUserCode(client: OAuth2Client): Promise { - const redirectUri = 'https://codeassist.google.com/authcode'; - const codeVerifier = await client.generateCodeVerifierAsync(); - const state = crypto.randomBytes(32).toString('hex'); - const authUrl: string = client.generateAuthUrl({ - redirect_uri: redirectUri, - access_type: 'offline', - scope: OAUTH_SCOPE, - code_challenge_method: CodeChallengeMethod.S256, - code_challenge: codeVerifier.codeChallenge, - state, - }); - console.log('Please visit the following URL to authorize the application:'); - console.log(''); - console.log(authUrl); - console.log(''); - - const code = await new Promise((resolve) => { - const rl = readline.createInterface({ - input: process.stdin, - output: process.stdout, - }); - rl.question('Enter the authorization code: ', (code) => { - rl.close(); - resolve(code.trim()); - }); - }); - - if (!code) { - console.error('Authorization code is required.'); - return false; - } - - try { - const { tokens } = await client.getToken({ - code, - codeVerifier: codeVerifier.codeVerifier, - redirect_uri: redirectUri, - }); - client.setCredentials(tokens); - } catch (error) { - console.error( - 'Failed to authenticate with authorization code:', - getErrorMessage(error), - ); - return false; - } - return true; -} - -async function authWithWeb(client: OAuth2Client): Promise { - const port = await getAvailablePort(); - // The hostname used for the HTTP server binding (e.g., '0.0.0.0' in Docker). - const host = process.env['OAUTH_CALLBACK_HOST'] || 'localhost'; - // The `redirectUri` sent to Google's authorization server MUST use a loopback IP literal - // (i.e., 'localhost' or '127.0.0.1'). This is a strict security policy for credentials of - // type 'Desktop app' or 'Web application' (when using loopback flow) to mitigate - // authorization code interception attacks. - const redirectUri = `http://localhost:${port}/oauth2callback`; - const state = crypto.randomBytes(32).toString('hex'); - const authUrl = client.generateAuthUrl({ - redirect_uri: redirectUri, - access_type: 'offline', - scope: OAUTH_SCOPE, - state, - }); - - const loginCompletePromise = new Promise((resolve, reject) => { - const server = http.createServer(async (req, res) => { - try { - if (req.url!.indexOf('/oauth2callback') === -1) { - res.writeHead(HTTP_REDIRECT, { Location: SIGN_IN_FAILURE_URL }); - res.end(); - reject( - new FatalAuthenticationError( - 'OAuth callback not received. Unexpected request: ' + req.url, - ), - ); - } - // acquire the code from the querystring, and close the web server. - const qs = new url.URL(req.url!, 'http://localhost:3000').searchParams; - if (qs.get('error')) { - res.writeHead(HTTP_REDIRECT, { Location: SIGN_IN_FAILURE_URL }); - res.end(); - - const errorCode = qs.get('error'); - const errorDescription = - qs.get('error_description') || 'No additional details provided'; - reject( - new FatalAuthenticationError( - `Google OAuth error: ${errorCode}. ${errorDescription}`, - ), - ); - } else if (qs.get('state') !== state) { - res.end('State mismatch. Possible CSRF attack'); - - reject( - new FatalAuthenticationError( - 'OAuth state mismatch. Possible CSRF attack or browser session issue.', - ), - ); - } else if (qs.get('code')) { - try { - const { tokens } = await client.getToken({ - code: qs.get('code')!, - redirect_uri: redirectUri, - }); - client.setCredentials(tokens); - - // Retrieve and cache Google Account ID during authentication - try { - await fetchAndCacheUserInfo(client); - } catch (error) { - console.warn( - 'Failed to retrieve Google Account ID during authentication:', - getErrorMessage(error), - ); - // Don't fail the auth flow if Google Account ID retrieval fails - } - - res.writeHead(HTTP_REDIRECT, { Location: SIGN_IN_SUCCESS_URL }); - res.end(); - resolve(); - } catch (error) { - res.writeHead(HTTP_REDIRECT, { Location: SIGN_IN_FAILURE_URL }); - res.end(); - reject( - new FatalAuthenticationError( - `Failed to exchange authorization code for tokens: ${getErrorMessage(error)}`, - ), - ); - } - } else { - reject( - new FatalAuthenticationError( - 'No authorization code received from Google OAuth. Please try authenticating again.', - ), - ); - } - } catch (e) { - // Provide more specific error message for unexpected errors during OAuth flow - if (e instanceof FatalAuthenticationError) { - reject(e); - } else { - reject( - new FatalAuthenticationError( - `Unexpected error during OAuth authentication: ${getErrorMessage(e)}`, - ), - ); - } - } finally { - server.close(); - } - }); - - server.listen(port, host, () => { - // Server started successfully - }); - - server.on('error', (err) => { - reject( - new FatalAuthenticationError( - `OAuth callback server error: ${getErrorMessage(err)}`, - ), - ); - }); - }); - - return { - authUrl, - loginCompletePromise, - }; -} - -export function getAvailablePort(): Promise { - return new Promise((resolve, reject) => { - let port = 0; - try { - const portStr = process.env['OAUTH_CALLBACK_PORT']; - if (portStr) { - port = parseInt(portStr, 10); - if (isNaN(port) || port <= 0 || port > 65535) { - return reject( - new Error(`Invalid value for OAUTH_CALLBACK_PORT: "${portStr}"`), - ); - } - return resolve(port); - } - const server = net.createServer(); - server.listen(0, () => { - const address = server.address()! as net.AddressInfo; - port = address.port; - }); - server.on('listening', () => { - server.close(); - server.unref(); - }); - server.on('error', (e) => reject(e)); - server.on('close', () => resolve(port)); - } catch (e) { - reject(e); - } - }); -} - -async function loadCachedCredentials(client: OAuth2Client): Promise { - const useEncryptedStorage = getUseEncryptedStorageFlag(); - if (useEncryptedStorage) { - const credentials = await OAuthCredentialStorage.loadCredentials(); - if (credentials) { - client.setCredentials(credentials); - return true; - } - return false; - } - - const pathsToTry = [ - Storage.getOAuthCredsPath(), - process.env['GOOGLE_APPLICATION_CREDENTIALS'], - ].filter((p): p is string => !!p); - - for (const keyFile of pathsToTry) { - try { - const creds = await fs.readFile(keyFile, 'utf-8'); - client.setCredentials(JSON.parse(creds)); - - // This will verify locally that the credentials look good. - const { token } = await client.getAccessToken(); - if (!token) { - continue; - } - - // This will check with the server to see if it hasn't been revoked. - await client.getTokenInfo(token); - - return true; - } catch (error) { - // Log specific error for debugging, but continue trying other paths - console.debug( - `Failed to load credentials from ${keyFile}:`, - getErrorMessage(error), - ); - } - } - - return false; -} - -async function cacheCredentials(credentials: Credentials) { - const filePath = Storage.getOAuthCredsPath(); - await fs.mkdir(path.dirname(filePath), { recursive: true }); - - const credString = JSON.stringify(credentials, null, 2); - await fs.writeFile(filePath, credString, { mode: 0o600 }); - try { - await fs.chmod(filePath, 0o600); - } catch { - /* empty */ - } -} - -export function clearOauthClientCache() { - oauthClientPromises.clear(); -} - -export async function clearCachedCredentialFile() { - try { - const useEncryptedStorage = getUseEncryptedStorageFlag(); - if (useEncryptedStorage) { - await OAuthCredentialStorage.clearCredentials(); - } else { - await fs.rm(Storage.getOAuthCredsPath(), { force: true }); - } - // Clear the Google Account ID cache when credentials are cleared - await userAccountManager.clearCachedGoogleAccount(); - // Clear the in-memory OAuth client cache to force re-authentication - clearOauthClientCache(); - - /** - * Also clear Qwen SharedTokenManager cache and credentials file to prevent stale credentials - * when switching between auth types - * TODO: We do not depend on code_assist, we'll have to build an independent auth-cleaning procedure. - */ - try { - const { SharedTokenManager } = await import( - '../qwen/sharedTokenManager.js' - ); - const { clearQwenCredentials } = await import('../qwen/qwenOAuth2.js'); - - const sharedManager = SharedTokenManager.getInstance(); - sharedManager.clearCache(); - - await clearQwenCredentials(); - } catch (qwenError) { - console.debug('Could not clear Qwen credentials:', qwenError); - } - } catch (e) { - console.error('Failed to clear cached credentials:', e); - } -} - -async function fetchAndCacheUserInfo(client: OAuth2Client): Promise { - try { - const { token } = await client.getAccessToken(); - if (!token) { - return; - } - - const response = await fetch( - 'https://www.googleapis.com/oauth2/v2/userinfo', - { - headers: { - Authorization: `Bearer ${token}`, - }, - }, - ); - - if (!response.ok) { - console.error( - 'Failed to fetch user info:', - response.status, - response.statusText, - ); - return; - } - - const userInfo = await response.json(); - await userAccountManager.cacheGoogleAccount(userInfo.email); - } catch (error) { - console.error('Error retrieving user info:', error); - } -} - -// Helper to ensure test isolation -export function resetOauthClientForTesting() { - oauthClientPromises.clear(); -} diff --git a/packages/core/src/code_assist/server.test.ts b/packages/core/src/code_assist/server.test.ts deleted file mode 100644 index 967493abc..000000000 --- a/packages/core/src/code_assist/server.test.ts +++ /dev/null @@ -1,255 +0,0 @@ -/** - * @license - * Copyright 2025 Google LLC - * SPDX-License-Identifier: Apache-2.0 - */ - -import { beforeEach, describe, it, expect, vi } from 'vitest'; -import { CodeAssistServer } from './server.js'; -import { OAuth2Client } from 'google-auth-library'; -import { UserTierId } from './types.js'; - -vi.mock('google-auth-library'); - -describe('CodeAssistServer', () => { - beforeEach(() => { - vi.resetAllMocks(); - }); - - it('should be able to be constructed', () => { - const auth = new OAuth2Client(); - const server = new CodeAssistServer( - auth, - 'test-project', - {}, - 'test-session', - UserTierId.FREE, - ); - expect(server).toBeInstanceOf(CodeAssistServer); - }); - - it('should call the generateContent endpoint', async () => { - const client = new OAuth2Client(); - const server = new CodeAssistServer( - client, - 'test-project', - {}, - 'test-session', - UserTierId.FREE, - ); - const mockResponse = { - response: { - candidates: [ - { - index: 0, - content: { - role: 'model', - parts: [{ text: 'response' }], - }, - finishReason: 'STOP', - safetyRatings: [], - }, - ], - }, - }; - vi.spyOn(server, 'requestPost').mockResolvedValue(mockResponse); - - const response = await server.generateContent( - { - model: 'test-model', - contents: [{ role: 'user', parts: [{ text: 'request' }] }], - }, - 'user-prompt-id', - ); - - expect(server.requestPost).toHaveBeenCalledWith( - 'generateContent', - expect.any(Object), - undefined, - ); - expect(response.candidates?.[0]?.content?.parts?.[0]?.text).toBe( - 'response', - ); - }); - - it('should call the generateContentStream endpoint', async () => { - const client = new OAuth2Client(); - const server = new CodeAssistServer( - client, - 'test-project', - {}, - 'test-session', - UserTierId.FREE, - ); - const mockResponse = (async function* () { - yield { - response: { - candidates: [ - { - index: 0, - content: { - role: 'model', - parts: [{ text: 'response' }], - }, - finishReason: 'STOP', - safetyRatings: [], - }, - ], - }, - }; - })(); - vi.spyOn(server, 'requestStreamingPost').mockResolvedValue(mockResponse); - - const stream = await server.generateContentStream( - { - model: 'test-model', - contents: [{ role: 'user', parts: [{ text: 'request' }] }], - }, - 'user-prompt-id', - ); - - for await (const res of stream) { - expect(server.requestStreamingPost).toHaveBeenCalledWith( - 'streamGenerateContent', - expect.any(Object), - undefined, - ); - expect(res.candidates?.[0]?.content?.parts?.[0]?.text).toBe('response'); - } - }); - - it('should call the onboardUser endpoint', async () => { - const client = new OAuth2Client(); - const server = new CodeAssistServer( - client, - 'test-project', - {}, - 'test-session', - UserTierId.FREE, - ); - const mockResponse = { - name: 'operations/123', - done: true, - }; - vi.spyOn(server, 'requestPost').mockResolvedValue(mockResponse); - - const response = await server.onboardUser({ - tierId: 'test-tier', - cloudaicompanionProject: 'test-project', - metadata: {}, - }); - - expect(server.requestPost).toHaveBeenCalledWith( - 'onboardUser', - expect.any(Object), - ); - expect(response.name).toBe('operations/123'); - }); - - it('should call the loadCodeAssist endpoint', async () => { - const client = new OAuth2Client(); - const server = new CodeAssistServer( - client, - 'test-project', - {}, - 'test-session', - UserTierId.FREE, - ); - const mockResponse = { - currentTier: { - id: UserTierId.FREE, - name: 'Free', - description: 'free tier', - }, - allowedTiers: [], - ineligibleTiers: [], - cloudaicompanionProject: 'projects/test', - }; - vi.spyOn(server, 'requestPost').mockResolvedValue(mockResponse); - - const response = await server.loadCodeAssist({ - metadata: {}, - }); - - expect(server.requestPost).toHaveBeenCalledWith( - 'loadCodeAssist', - expect.any(Object), - ); - expect(response).toEqual(mockResponse); - }); - - it('should return 0 for countTokens', async () => { - const client = new OAuth2Client(); - const server = new CodeAssistServer( - client, - 'test-project', - {}, - 'test-session', - UserTierId.FREE, - ); - const mockResponse = { - totalTokens: 100, - }; - vi.spyOn(server, 'requestPost').mockResolvedValue(mockResponse); - - const response = await server.countTokens({ - model: 'test-model', - contents: [{ role: 'user', parts: [{ text: 'request' }] }], - }); - expect(response.totalTokens).toBe(100); - }); - - it('should throw an error for embedContent', async () => { - const client = new OAuth2Client(); - const server = new CodeAssistServer( - client, - 'test-project', - {}, - 'test-session', - UserTierId.FREE, - ); - await expect( - server.embedContent({ - model: 'test-model', - contents: [{ role: 'user', parts: [{ text: 'request' }] }], - }), - ).rejects.toThrow(); - }); - - it('should handle VPC-SC errors when calling loadCodeAssist', async () => { - const client = new OAuth2Client(); - const server = new CodeAssistServer( - client, - 'test-project', - {}, - 'test-session', - UserTierId.FREE, - ); - const mockVpcScError = { - response: { - data: { - error: { - details: [ - { - reason: 'SECURITY_POLICY_VIOLATED', - }, - ], - }, - }, - }, - }; - vi.spyOn(server, 'requestPost').mockRejectedValue(mockVpcScError); - - const response = await server.loadCodeAssist({ - metadata: {}, - }); - - expect(server.requestPost).toHaveBeenCalledWith( - 'loadCodeAssist', - expect.any(Object), - ); - expect(response).toEqual({ - currentTier: { id: UserTierId.STANDARD }, - }); - }); -}); diff --git a/packages/core/src/code_assist/server.ts b/packages/core/src/code_assist/server.ts deleted file mode 100644 index cd4d0e8ab..000000000 --- a/packages/core/src/code_assist/server.ts +++ /dev/null @@ -1,253 +0,0 @@ -/** - * @license - * Copyright 2025 Google LLC - * SPDX-License-Identifier: Apache-2.0 - */ - -import type { OAuth2Client } from 'google-auth-library'; -import type { - CodeAssistGlobalUserSettingResponse, - GoogleRpcResponse, - LoadCodeAssistRequest, - LoadCodeAssistResponse, - LongRunningOperationResponse, - OnboardUserRequest, - SetCodeAssistGlobalUserSettingRequest, -} from './types.js'; -import type { - CountTokensParameters, - CountTokensResponse, - EmbedContentParameters, - EmbedContentResponse, - GenerateContentParameters, - GenerateContentResponse, -} from '@google/genai'; -import * as readline from 'node:readline'; -import type { ContentGenerator } from '../core/contentGenerator.js'; -import { UserTierId } from './types.js'; -import type { - CaCountTokenResponse, - CaGenerateContentResponse, -} from './converter.js'; -import { - fromCountTokenResponse, - fromGenerateContentResponse, - toCountTokenRequest, - toGenerateContentRequest, -} from './converter.js'; - -/** HTTP options to be used in each of the requests. */ -export interface HttpOptions { - /** Additional HTTP headers to be sent with the request. */ - headers?: Record; -} - -export const CODE_ASSIST_ENDPOINT = 'https://localhost:0'; // Disable Google Code Assist API Request -export const CODE_ASSIST_API_VERSION = 'v1internal'; - -export class CodeAssistServer implements ContentGenerator { - constructor( - readonly client: OAuth2Client, - readonly projectId?: string, - readonly httpOptions: HttpOptions = {}, - readonly sessionId?: string, - readonly userTier?: UserTierId, - ) {} - - async generateContentStream( - req: GenerateContentParameters, - userPromptId: string, - ): Promise> { - const resps = await this.requestStreamingPost( - 'streamGenerateContent', - toGenerateContentRequest( - req, - userPromptId, - this.projectId, - this.sessionId, - ), - req.config?.abortSignal, - ); - return (async function* (): AsyncGenerator { - for await (const resp of resps) { - yield fromGenerateContentResponse(resp); - } - })(); - } - - async generateContent( - req: GenerateContentParameters, - userPromptId: string, - ): Promise { - const resp = await this.requestPost( - 'generateContent', - toGenerateContentRequest( - req, - userPromptId, - this.projectId, - this.sessionId, - ), - req.config?.abortSignal, - ); - return fromGenerateContentResponse(resp); - } - - async onboardUser( - req: OnboardUserRequest, - ): Promise { - return await this.requestPost( - 'onboardUser', - req, - ); - } - - async loadCodeAssist( - req: LoadCodeAssistRequest, - ): Promise { - try { - return await this.requestPost( - 'loadCodeAssist', - req, - ); - } catch (e) { - if (isVpcScAffectedUser(e)) { - return { - currentTier: { id: UserTierId.STANDARD }, - }; - } else { - throw e; - } - } - } - - async getCodeAssistGlobalUserSetting(): Promise { - return await this.requestGet( - 'getCodeAssistGlobalUserSetting', - ); - } - - async setCodeAssistGlobalUserSetting( - req: SetCodeAssistGlobalUserSettingRequest, - ): Promise { - return await this.requestPost( - 'setCodeAssistGlobalUserSetting', - req, - ); - } - - async countTokens(req: CountTokensParameters): Promise { - const resp = await this.requestPost( - 'countTokens', - toCountTokenRequest(req), - ); - return fromCountTokenResponse(resp); - } - - async embedContent( - _req: EmbedContentParameters, - ): Promise { - throw Error(); - } - - async requestPost( - method: string, - req: object, - signal?: AbortSignal, - ): Promise { - const res = await this.client.request({ - url: this.getMethodUrl(method), - method: 'POST', - headers: { - 'Content-Type': 'application/json', - ...this.httpOptions.headers, - }, - responseType: 'json', - body: JSON.stringify(req), - signal, - }); - return res.data as T; - } - - async requestGet(method: string, signal?: AbortSignal): Promise { - const res = await this.client.request({ - url: this.getMethodUrl(method), - method: 'GET', - headers: { - 'Content-Type': 'application/json', - ...this.httpOptions.headers, - }, - responseType: 'json', - signal, - }); - return res.data as T; - } - - async requestStreamingPost( - method: string, - req: object, - signal?: AbortSignal, - ): Promise> { - const res = await this.client.request({ - url: this.getMethodUrl(method), - method: 'POST', - params: { - alt: 'sse', - }, - headers: { - 'Content-Type': 'application/json', - ...this.httpOptions.headers, - }, - responseType: 'stream', - body: JSON.stringify(req), - signal, - }); - - return (async function* (): AsyncGenerator { - const rl = readline.createInterface({ - input: res.data as NodeJS.ReadableStream, - crlfDelay: Infinity, // Recognizes '\r\n' and '\n' as line breaks - }); - - let bufferedLines: string[] = []; - for await (const line of rl) { - // blank lines are used to separate JSON objects in the stream - if (line === '') { - if (bufferedLines.length === 0) { - continue; // no data to yield - } - yield JSON.parse(bufferedLines.join('\n')) as T; - bufferedLines = []; // Reset the buffer after yielding - } else if (line.startsWith('data: ')) { - bufferedLines.push(line.slice(6).trim()); - } else { - throw new Error(`Unexpected line format in response: ${line}`); - } - } - })(); - } - - getMethodUrl(method: string): string { - const endpoint = - process.env['CODE_ASSIST_ENDPOINT'] ?? CODE_ASSIST_ENDPOINT; - return `${endpoint}/${CODE_ASSIST_API_VERSION}:${method}`; - } -} - -function isVpcScAffectedUser(error: unknown): boolean { - if (error && typeof error === 'object' && 'response' in error) { - const gaxiosError = error as { - response?: { - data?: unknown; - }; - }; - const response = gaxiosError.response?.data as - | GoogleRpcResponse - | undefined; - if (Array.isArray(response?.error?.details)) { - return response.error.details.some( - (detail) => detail.reason === 'SECURITY_POLICY_VIOLATED', - ); - } - } - return false; -} diff --git a/packages/core/src/code_assist/setup.test.ts b/packages/core/src/code_assist/setup.test.ts deleted file mode 100644 index 54ad7f249..000000000 --- a/packages/core/src/code_assist/setup.test.ts +++ /dev/null @@ -1,224 +0,0 @@ -/** - * @license - * Copyright 2025 Google LLC - * SPDX-License-Identifier: Apache-2.0 - */ - -import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; -import { setupUser, ProjectIdRequiredError } from './setup.js'; -import { CodeAssistServer } from '../code_assist/server.js'; -import type { OAuth2Client } from 'google-auth-library'; -import type { GeminiUserTier } from './types.js'; -import { UserTierId } from './types.js'; - -vi.mock('../code_assist/server.js'); - -const mockPaidTier: GeminiUserTier = { - id: UserTierId.STANDARD, - name: 'paid', - description: 'Paid tier', - isDefault: true, -}; - -const mockFreeTier: GeminiUserTier = { - id: UserTierId.FREE, - name: 'free', - description: 'Free tier', - isDefault: true, -}; - -describe('setupUser for existing user', () => { - let mockLoad: ReturnType; - let mockOnboardUser: ReturnType; - - beforeEach(() => { - vi.resetAllMocks(); - mockLoad = vi.fn(); - mockOnboardUser = vi.fn().mockResolvedValue({ - done: true, - response: { - cloudaicompanionProject: { - id: 'server-project', - }, - }, - }); - vi.mocked(CodeAssistServer).mockImplementation( - () => - ({ - loadCodeAssist: mockLoad, - onboardUser: mockOnboardUser, - }) as unknown as CodeAssistServer, - ); - }); - - afterEach(() => { - vi.unstubAllEnvs(); - }); - - it('should use GOOGLE_CLOUD_PROJECT when set and project from server is undefined', async () => { - vi.stubEnv('GOOGLE_CLOUD_PROJECT', 'test-project'); - mockLoad.mockResolvedValue({ - currentTier: mockPaidTier, - }); - await setupUser({} as OAuth2Client); - expect(CodeAssistServer).toHaveBeenCalledWith( - {}, - 'test-project', - {}, - '', - undefined, - ); - }); - - it('should ignore GOOGLE_CLOUD_PROJECT when project from server is set', async () => { - vi.stubEnv('GOOGLE_CLOUD_PROJECT', 'test-project'); - mockLoad.mockResolvedValue({ - cloudaicompanionProject: 'server-project', - currentTier: mockPaidTier, - }); - const projectId = await setupUser({} as OAuth2Client); - expect(CodeAssistServer).toHaveBeenCalledWith( - {}, - 'test-project', - {}, - '', - undefined, - ); - expect(projectId).toEqual({ - projectId: 'server-project', - userTier: 'standard-tier', - }); - }); - - it('should throw ProjectIdRequiredError when no project ID is available', async () => { - vi.stubEnv('GOOGLE_CLOUD_PROJECT', ''); - // And the server itself requires a project ID internally - vi.mocked(CodeAssistServer).mockImplementation(() => { - throw new ProjectIdRequiredError(); - }); - - await expect(setupUser({} as OAuth2Client)).rejects.toThrow( - ProjectIdRequiredError, - ); - }); -}); - -describe('setupUser for new user', () => { - let mockLoad: ReturnType; - let mockOnboardUser: ReturnType; - - beforeEach(() => { - vi.resetAllMocks(); - mockLoad = vi.fn(); - mockOnboardUser = vi.fn().mockResolvedValue({ - done: true, - response: { - cloudaicompanionProject: { - id: 'server-project', - }, - }, - }); - vi.mocked(CodeAssistServer).mockImplementation( - () => - ({ - loadCodeAssist: mockLoad, - onboardUser: mockOnboardUser, - }) as unknown as CodeAssistServer, - ); - }); - - afterEach(() => { - vi.unstubAllEnvs(); - }); - - it('should use GOOGLE_CLOUD_PROJECT when set and onboard a new paid user', async () => { - vi.stubEnv('GOOGLE_CLOUD_PROJECT', 'test-project'); - mockLoad.mockResolvedValue({ - allowedTiers: [mockPaidTier], - }); - const userData = await setupUser({} as OAuth2Client); - expect(CodeAssistServer).toHaveBeenCalledWith( - {}, - 'test-project', - {}, - '', - undefined, - ); - expect(mockLoad).toHaveBeenCalled(); - expect(mockOnboardUser).toHaveBeenCalledWith({ - tierId: 'standard-tier', - cloudaicompanionProject: 'test-project', - metadata: { - ideType: 'IDE_UNSPECIFIED', - platform: 'PLATFORM_UNSPECIFIED', - pluginType: 'GEMINI', - duetProject: 'test-project', - }, - }); - expect(userData).toEqual({ - projectId: 'server-project', - userTier: 'standard-tier', - }); - }); - - it('should onboard a new free user when GOOGLE_CLOUD_PROJECT is not set', async () => { - vi.stubEnv('GOOGLE_CLOUD_PROJECT', ''); - mockLoad.mockResolvedValue({ - allowedTiers: [mockFreeTier], - }); - const userData = await setupUser({} as OAuth2Client); - expect(CodeAssistServer).toHaveBeenCalledWith( - {}, - undefined, - {}, - '', - undefined, - ); - expect(mockLoad).toHaveBeenCalled(); - expect(mockOnboardUser).toHaveBeenCalledWith({ - tierId: 'free-tier', - cloudaicompanionProject: undefined, - metadata: { - ideType: 'IDE_UNSPECIFIED', - platform: 'PLATFORM_UNSPECIFIED', - pluginType: 'GEMINI', - }, - }); - expect(userData).toEqual({ - projectId: 'server-project', - userTier: 'free-tier', - }); - }); - - it('should use GOOGLE_CLOUD_PROJECT when onboard response has no project ID', async () => { - vi.stubEnv('GOOGLE_CLOUD_PROJECT', 'test-project'); - mockLoad.mockResolvedValue({ - allowedTiers: [mockPaidTier], - }); - mockOnboardUser.mockResolvedValue({ - done: true, - response: { - cloudaicompanionProject: undefined, - }, - }); - const userData = await setupUser({} as OAuth2Client); - expect(userData).toEqual({ - projectId: 'test-project', - userTier: 'standard-tier', - }); - }); - - it('should throw ProjectIdRequiredError when no project ID is available', async () => { - vi.stubEnv('GOOGLE_CLOUD_PROJECT', ''); - mockLoad.mockResolvedValue({ - allowedTiers: [mockPaidTier], - }); - mockOnboardUser.mockResolvedValue({ - done: true, - response: {}, - }); - await expect(setupUser({} as OAuth2Client)).rejects.toThrow( - ProjectIdRequiredError, - ); - }); -}); diff --git a/packages/core/src/code_assist/setup.ts b/packages/core/src/code_assist/setup.ts deleted file mode 100644 index 43e9fb275..000000000 --- a/packages/core/src/code_assist/setup.ts +++ /dev/null @@ -1,124 +0,0 @@ -/** - * @license - * Copyright 2025 Google LLC - * SPDX-License-Identifier: Apache-2.0 - */ - -import type { - ClientMetadata, - GeminiUserTier, - LoadCodeAssistResponse, - OnboardUserRequest, -} from './types.js'; -import { UserTierId } from './types.js'; -import { CodeAssistServer } from './server.js'; -import type { OAuth2Client } from 'google-auth-library'; - -export class ProjectIdRequiredError extends Error { - constructor() { - super( - 'This account requires setting the GOOGLE_CLOUD_PROJECT env var. See https://goo.gle/gemini-cli-auth-docs#workspace-gca', - ); - } -} - -export interface UserData { - projectId: string; - userTier: UserTierId; -} - -/** - * - * @param projectId the user's project id, if any - * @returns the user's actual project id - */ -export async function setupUser(client: OAuth2Client): Promise { - const projectId = process.env['GOOGLE_CLOUD_PROJECT'] || undefined; - const caServer = new CodeAssistServer(client, projectId, {}, '', undefined); - const coreClientMetadata: ClientMetadata = { - ideType: 'IDE_UNSPECIFIED', - platform: 'PLATFORM_UNSPECIFIED', - pluginType: 'GEMINI', - }; - - const loadRes = await caServer.loadCodeAssist({ - cloudaicompanionProject: projectId, - metadata: { - ...coreClientMetadata, - duetProject: projectId, - }, - }); - - if (loadRes.currentTier) { - if (!loadRes.cloudaicompanionProject) { - if (projectId) { - return { - projectId, - userTier: loadRes.currentTier.id, - }; - } - throw new ProjectIdRequiredError(); - } - return { - projectId: loadRes.cloudaicompanionProject, - userTier: loadRes.currentTier.id, - }; - } - - const tier = getOnboardTier(loadRes); - - let onboardReq: OnboardUserRequest; - if (tier.id === UserTierId.FREE) { - // The free tier uses a managed google cloud project. Setting a project in the `onboardUser` request causes a `Precondition Failed` error. - onboardReq = { - tierId: tier.id, - cloudaicompanionProject: undefined, - metadata: coreClientMetadata, - }; - } else { - onboardReq = { - tierId: tier.id, - cloudaicompanionProject: projectId, - metadata: { - ...coreClientMetadata, - duetProject: projectId, - }, - }; - } - - // Poll onboardUser until long running operation is complete. - let lroRes = await caServer.onboardUser(onboardReq); - while (!lroRes.done) { - await new Promise((f) => setTimeout(f, 5000)); - lroRes = await caServer.onboardUser(onboardReq); - } - - if (!lroRes.response?.cloudaicompanionProject?.id) { - if (projectId) { - return { - projectId, - userTier: tier.id, - }; - } - throw new ProjectIdRequiredError(); - } - - return { - projectId: lroRes.response.cloudaicompanionProject.id, - userTier: tier.id, - }; -} - -function getOnboardTier(res: LoadCodeAssistResponse): GeminiUserTier { - for (const tier of res.allowedTiers || []) { - if (tier.isDefault) { - return tier; - } - } - return { - name: '', - description: '', - id: UserTierId.LEGACY, - userDefinedCloudaicompanionProject: true, - }; -} diff --git a/packages/core/src/code_assist/types.ts b/packages/core/src/code_assist/types.ts deleted file mode 100644 index b79094bbb..000000000 --- a/packages/core/src/code_assist/types.ts +++ /dev/null @@ -1,201 +0,0 @@ -/** - * @license - * Copyright 2025 Google LLC - * SPDX-License-Identifier: Apache-2.0 - */ - -export interface ClientMetadata { - ideType?: ClientMetadataIdeType; - ideVersion?: string; - pluginVersion?: string; - platform?: ClientMetadataPlatform; - updateChannel?: string; - duetProject?: string; - pluginType?: ClientMetadataPluginType; - ideName?: string; -} - -export type ClientMetadataIdeType = - | 'IDE_UNSPECIFIED' - | 'VSCODE' - | 'INTELLIJ' - | 'VSCODE_CLOUD_WORKSTATION' - | 'INTELLIJ_CLOUD_WORKSTATION' - | 'CLOUD_SHELL'; -export type ClientMetadataPlatform = - | 'PLATFORM_UNSPECIFIED' - | 'DARWIN_AMD64' - | 'DARWIN_ARM64' - | 'LINUX_AMD64' - | 'LINUX_ARM64' - | 'WINDOWS_AMD64'; -export type ClientMetadataPluginType = - | 'PLUGIN_UNSPECIFIED' - | 'CLOUD_CODE' - | 'GEMINI' - | 'AIPLUGIN_INTELLIJ' - | 'AIPLUGIN_STUDIO'; - -export interface LoadCodeAssistRequest { - cloudaicompanionProject?: string; - metadata: ClientMetadata; -} - -/** - * Represents LoadCodeAssistResponse proto json field - * http://google3/google/internal/cloud/code/v1internal/cloudcode.proto;l=224 - */ -export interface LoadCodeAssistResponse { - currentTier?: GeminiUserTier | null; - allowedTiers?: GeminiUserTier[] | null; - ineligibleTiers?: IneligibleTier[] | null; - cloudaicompanionProject?: string | null; -} - -/** - * GeminiUserTier reflects the structure received from the CodeAssist when calling LoadCodeAssist. - */ -export interface GeminiUserTier { - id: UserTierId; - name?: string; - description?: string; - // This value is used to declare whether a given tier requires the user to configure the project setting on the IDE settings or not. - userDefinedCloudaicompanionProject?: boolean | null; - isDefault?: boolean; - privacyNotice?: PrivacyNotice; - hasAcceptedTos?: boolean; - hasOnboardedPreviously?: boolean; -} - -/** - * Includes information specifying the reasons for a user's ineligibility for a specific tier. - * @param reasonCode mnemonic code representing the reason for in-eligibility. - * @param reasonMessage message to display to the user. - * @param tierId id of the tier. - * @param tierName name of the tier. - */ -export interface IneligibleTier { - reasonCode: IneligibleTierReasonCode; - reasonMessage: string; - tierId: UserTierId; - tierName: string; -} - -/** - * List of predefined reason codes when a tier is blocked from a specific tier. - * https://source.corp.google.com/piper///depot/google3/google/internal/cloud/code/v1internal/cloudcode.proto;l=378 - */ -export enum IneligibleTierReasonCode { - // go/keep-sorted start - DASHER_USER = 'DASHER_USER', - INELIGIBLE_ACCOUNT = 'INELIGIBLE_ACCOUNT', - NON_USER_ACCOUNT = 'NON_USER_ACCOUNT', - RESTRICTED_AGE = 'RESTRICTED_AGE', - RESTRICTED_NETWORK = 'RESTRICTED_NETWORK', - UNKNOWN = 'UNKNOWN', - UNKNOWN_LOCATION = 'UNKNOWN_LOCATION', - UNSUPPORTED_LOCATION = 'UNSUPPORTED_LOCATION', - // go/keep-sorted end -} -/** - * UserTierId represents IDs returned from the Cloud Code Private API representing a user's tier - * - * //depot/google3/cloud/developer_experience/cloudcode/pa/service/usertier.go;l=16 - */ -export enum UserTierId { - FREE = 'free-tier', - LEGACY = 'legacy-tier', - STANDARD = 'standard-tier', -} - -/** - * PrivacyNotice reflects the structure received from the CodeAssist in regards to a tier - * privacy notice. - */ -export interface PrivacyNotice { - showNotice: boolean; - noticeText?: string; -} - -/** - * Proto signature of OnboardUserRequest as payload to OnboardUser call - */ -export interface OnboardUserRequest { - tierId: string | undefined; - cloudaicompanionProject: string | undefined; - metadata: ClientMetadata | undefined; -} - -/** - * Represents LongRunningOperation proto - * http://google3/google/longrunning/operations.proto;rcl=698857719;l=107 - */ -export interface LongRunningOperationResponse { - name: string; - done?: boolean; - response?: OnboardUserResponse; -} - -/** - * Represents OnboardUserResponse proto - * http://google3/google/internal/cloud/code/v1internal/cloudcode.proto;l=215 - */ -export interface OnboardUserResponse { - // tslint:disable-next-line:enforce-name-casing This is the name of the field in the proto. - cloudaicompanionProject?: { - id: string; - name: string; - }; -} - -/** - * Status code of user license status - * it does not strictly correspond to the proto - * Error value is an additional value assigned to error responses from OnboardUser - */ -export enum OnboardUserStatusCode { - Default = 'DEFAULT', - Notice = 'NOTICE', - Warning = 'WARNING', - Error = 'ERROR', -} - -/** - * Status of user onboarded to gemini - */ -export interface OnboardUserStatus { - statusCode: OnboardUserStatusCode; - displayMessage: string; - helpLink: HelpLinkUrl | undefined; -} - -export interface HelpLinkUrl { - description: string; - url: string; -} - -export interface SetCodeAssistGlobalUserSettingRequest { - cloudaicompanionProject?: string; - freeTierDataCollectionOptin: boolean; -} - -export interface CodeAssistGlobalUserSettingResponse { - cloudaicompanionProject?: string; - freeTierDataCollectionOptin: boolean; -} - -/** - * Relevant fields that can be returned from a Google RPC response - */ -export interface GoogleRpcResponse { - error?: { - details?: GoogleRpcErrorInfo[]; - }; -} - -/** - * Relevant fields that can be returned in the details of an error returned from GoogleRPCs - */ -interface GoogleRpcErrorInfo { - reason?: string; -} diff --git a/packages/core/src/config/config.test.ts b/packages/core/src/config/config.test.ts index 6aa49306c..1b163b9a6 100644 --- a/packages/core/src/config/config.test.ts +++ b/packages/core/src/config/config.test.ts @@ -16,7 +16,6 @@ import { QwenLogger, } from '../telemetry/index.js'; import type { ContentGeneratorConfig } from '../core/contentGenerator.js'; -import { DEFAULT_DASHSCOPE_BASE_URL } from '../core/openaiContentGenerator/constants.js'; import { AuthType, createContentGeneratorConfig, @@ -273,7 +272,7 @@ describe('Server Config (config.ts)', () => { authType, { model: MODEL, - baseUrl: DEFAULT_DASHSCOPE_BASE_URL, + baseUrl: undefined, }, ); // Verify that contentGeneratorConfig is updated @@ -283,23 +282,6 @@ describe('Server Config (config.ts)', () => { expect(config.isInFallbackMode()).toBe(false); }); - it('should strip thoughts when switching from GenAI to Vertex', async () => { - const config = new Config(baseParams); - - vi.mocked(createContentGeneratorConfig).mockImplementation( - (_: Config, authType: AuthType | undefined) => - ({ authType }) as unknown as ContentGeneratorConfig, - ); - - await config.refreshAuth(AuthType.USE_GEMINI); - - await config.refreshAuth(AuthType.LOGIN_WITH_GOOGLE); - - expect( - config.getGeminiClient().stripThoughtsFromHistory, - ).toHaveBeenCalledWith(); - }); - it('should not strip thoughts when switching from Vertex to GenAI', async () => { const config = new Config(baseParams); diff --git a/packages/core/src/config/config.ts b/packages/core/src/config/config.ts index 6383cb174..34dbb4649 100644 --- a/packages/core/src/config/config.ts +++ b/packages/core/src/config/config.ts @@ -16,6 +16,7 @@ import { ProxyAgent, setGlobalDispatcher } from 'undici'; import type { ContentGenerator, ContentGeneratorConfig, + AuthType, } from '../core/contentGenerator.js'; import type { FallbackModelHandler } from '../fallback/types.js'; import type { MCPOAuthConfig } from '../mcp/oauth-provider.js'; @@ -26,7 +27,6 @@ import type { AnyToolInvocation } from '../tools/tools.js'; import { BaseLlmClient } from '../core/baseLlmClient.js'; import { GeminiClient } from '../core/client.js'; import { - AuthType, createContentGenerator, createContentGeneratorConfig, } from '../core/contentGenerator.js'; @@ -54,6 +54,7 @@ import { canUseRipgrep } from '../utils/ripgrepUtils.js'; import { RipGrepTool } from '../tools/ripGrep.js'; import { ShellTool } from '../tools/shell.js'; import { SmartEditTool } from '../tools/smart-edit.js'; +import { SkillTool } from '../tools/skill.js'; import { TaskTool } from '../tools/task.js'; import { TodoWriteTool } from '../tools/todoWrite.js'; import { ToolRegistry } from '../tools/tool-registry.js'; @@ -65,6 +66,7 @@ import { WriteFileTool } from '../tools/write-file.js'; import { ideContextStore } from '../ide/ideContext.js'; import { InputFormat, OutputFormat } from '../output/types.js'; import { PromptRegistry } from '../prompts/prompt-registry.js'; +import { SkillManager } from '../skills/skill-manager.js'; import { SubagentManager } from '../subagents/subagent-manager.js'; import type { SubagentConfig } from '../subagents/types.js'; import { @@ -94,7 +96,6 @@ import { } from './constants.js'; import { DEFAULT_QWEN_EMBEDDING_MODEL, DEFAULT_QWEN_MODEL } from './models.js'; import { Storage } from './storage.js'; -import { DEFAULT_DASHSCOPE_BASE_URL } from '../core/openaiContentGenerator/constants.js'; import { ChatRecordingService } from '../services/chatRecordingService.js'; import { SessionService, @@ -287,7 +288,7 @@ export interface ConfigParameters { contextFileName?: string | string[]; accessibility?: AccessibilitySettings; telemetry?: TelemetrySettings; - gitCoAuthor?: GitCoAuthorSettings; + gitCoAuthor?: boolean; usageStatisticsEnabled?: boolean; fileFiltering?: { respectGitIgnore?: boolean; @@ -305,6 +306,7 @@ export interface ConfigParameters { extensionContextFilePaths?: string[]; maxSessionTurns?: number; sessionTokenLimit?: number; + experimentalSkills?: boolean; experimentalZedIntegration?: boolean; listExtensions?: boolean; extensions?: GeminiCLIExtension[]; @@ -318,6 +320,7 @@ export interface ConfigParameters { generationConfig?: Partial; cliVersion?: string; loadMemoryFromIncludeDirectories?: boolean; + chatRecording?: boolean; // Web search providers webSearch?: { provider: Array<{ @@ -349,6 +352,7 @@ export interface ConfigParameters { skipStartupContext?: boolean; sdkMode?: boolean; sessionSubagents?: SubagentConfig[]; + channel?: string; } function normalizeConfigOutputFormat( @@ -387,6 +391,7 @@ export class Config { private toolRegistry!: ToolRegistry; private promptRegistry!: PromptRegistry; private subagentManager!: SubagentManager; + private skillManager!: SkillManager; private fileSystemService: FileSystemService; private contentGeneratorConfig!: ContentGeneratorConfig; private contentGenerator!: ContentGenerator; @@ -456,6 +461,8 @@ export class Config { | undefined; private readonly cliVersion?: string; private readonly experimentalZedIntegration: boolean = false; + private readonly experimentalSkills: boolean = false; + private readonly chatRecordingEnabled: boolean; private readonly loadMemoryFromIncludeDirectories: boolean = false; private readonly webSearch?: { provider: Array<{ @@ -485,6 +492,7 @@ export class Config { private readonly enableToolOutputTruncation: boolean; private readonly eventEmitter?: EventEmitter; private readonly useSmartEdit: boolean; + private readonly channel: string | undefined; constructor(params: ConfigParameters) { this.sessionId = params.sessionId ?? randomUUID(); @@ -530,9 +538,9 @@ export class Config { useCollector: params.telemetry?.useCollector, }; this.gitCoAuthor = { - enabled: params.gitCoAuthor?.enabled ?? true, - name: params.gitCoAuthor?.name ?? 'Qwen-Coder', - email: params.gitCoAuthor?.email ?? 'qwen-coder@alibabacloud.com', + enabled: params.gitCoAuthor ?? true, + name: 'Qwen-Coder', + email: 'qwen-coder@alibabacloud.com', }; this.usageStatisticsEnabled = params.usageStatisticsEnabled ?? true; @@ -553,6 +561,7 @@ export class Config { this.sessionTokenLimit = params.sessionTokenLimit ?? -1; this.experimentalZedIntegration = params.experimentalZedIntegration ?? false; + this.experimentalSkills = params.experimentalSkills ?? false; this.listExtensions = params.listExtensions ?? false; this._extensions = params.extensions ?? []; this._blockedMcpServers = params.blockedMcpServers ?? []; @@ -564,12 +573,14 @@ export class Config { this._generationConfig = { model: params.model, ...(params.generationConfig || {}), - baseUrl: params.generationConfig?.baseUrl || DEFAULT_DASHSCOPE_BASE_URL, + baseUrl: params.generationConfig?.baseUrl, }; this.contentGeneratorConfig = this ._generationConfig as ContentGeneratorConfig; this.cliVersion = params.cliVersion; + this.chatRecordingEnabled = params.chatRecording ?? true; + this.loadMemoryFromIncludeDirectories = params.loadMemoryFromIncludeDirectories ?? false; this.chatCompression = params.chatCompression; @@ -598,6 +609,7 @@ export class Config { this.enableToolOutputTruncation = params.enableToolOutputTruncation ?? true; this.useSmartEdit = params.useSmartEdit ?? false; this.extensionManagement = params.extensionManagement ?? true; + this.channel = params.channel; this.storage = new Storage(this.targetDir); this.vlmSwitchMode = params.vlmSwitchMode; this.inputFormat = params.inputFormat ?? InputFormat.TEXT; @@ -615,7 +627,9 @@ export class Config { setGlobalDispatcher(new ProxyAgent(this.getProxy() as string)); } this.geminiClient = new GeminiClient(this); - this.chatRecordingService = new ChatRecordingService(this); + this.chatRecordingService = this.chatRecordingEnabled + ? new ChatRecordingService(this) + : undefined; } /** @@ -635,6 +649,7 @@ export class Config { } this.promptRegistry = new PromptRegistry(); this.subagentManager = new SubagentManager(this); + this.skillManager = new SkillManager(this); // Load session subagents if they were provided before initialization if (this.sessionSubagents.length > 0) { @@ -675,16 +690,6 @@ export class Config { } async refreshAuth(authMethod: AuthType, isInitialAuth?: boolean) { - // Vertex and Genai have incompatible encryption and sending history with - // throughtSignature from Genai to Vertex will fail, we need to strip them - if ( - this.contentGeneratorConfig?.authType === AuthType.USE_GEMINI && - authMethod === AuthType.LOGIN_WITH_GOOGLE - ) { - // Restore the conversation history to the new client - this.geminiClient.stripThoughtsFromHistory(); - } - const newContentGeneratorConfig = createContentGeneratorConfig( this, authMethod, @@ -732,10 +737,15 @@ export class Config { /** * Starts a new session and resets session-scoped services. */ - startNewSession(sessionId?: string): string { + startNewSession( + sessionId?: string, + sessionData?: ResumedSessionData, + ): string { this.sessionId = sessionId ?? randomUUID(); - this.sessionData = undefined; - this.chatRecordingService = new ChatRecordingService(this); + this.sessionData = sessionData; + this.chatRecordingService = this.chatRecordingEnabled + ? new ChatRecordingService(this) + : undefined; if (this.initialized) { logStartSession(this, new StartSessionEvent(this)); } @@ -1062,6 +1072,10 @@ export class Config { return this.experimentalZedIntegration; } + getExperimentalSkills(): boolean { + return this.experimentalSkills; + } + getListExtensions(): boolean { return this.listExtensions; } @@ -1144,6 +1158,10 @@ export class Config { return this.cliVersion; } + getChannel(): string | undefined { + return this.channel; + } + /** * Get the current FileSystemService */ @@ -1260,7 +1278,10 @@ export class Config { /** * Returns the chat recording service. */ - getChatRecordingService(): ChatRecordingService { + getChatRecordingService(): ChatRecordingService | undefined { + if (!this.chatRecordingEnabled) { + return undefined; + } if (!this.chatRecordingService) { this.chatRecordingService = new ChatRecordingService(this); } @@ -1285,6 +1306,10 @@ export class Config { return this.subagentManager; } + getSkillManager(): SkillManager { + return this.skillManager; + } + async createToolRegistry( sendSdkMcpMessage?: SendSdkMcpMessage, ): Promise { @@ -1327,6 +1352,9 @@ export class Config { }; registerCoreTool(TaskTool, this); + if (this.getExperimentalSkills()) { + registerCoreTool(SkillTool, this); + } registerCoreTool(LSTool, this); registerCoreTool(ReadFileTool, this); diff --git a/packages/core/src/config/flashFallback.test.ts b/packages/core/src/config/flashFallback.test.ts index 2de20b2bb..2675ea840 100644 --- a/packages/core/src/config/flashFallback.test.ts +++ b/packages/core/src/config/flashFallback.test.ts @@ -31,7 +31,7 @@ describe('Flash Model Fallback Configuration', () => { config as unknown as { contentGeneratorConfig: unknown } ).contentGeneratorConfig = { model: DEFAULT_GEMINI_MODEL, - authType: 'oauth-personal', + authType: 'gemini-api-key', }; }); diff --git a/packages/core/src/config/storage.ts b/packages/core/src/config/storage.ts index 8e598787b..49b59ca1f 100644 --- a/packages/core/src/config/storage.ts +++ b/packages/core/src/config/storage.ts @@ -15,6 +15,7 @@ export const OAUTH_FILE = 'oauth_creds.json'; const TMP_DIR_NAME = 'tmp'; const BIN_DIR_NAME = 'bin'; const PROJECT_DIR_NAME = 'projects'; +const IDE_DIR_NAME = 'ide'; export class Storage { private readonly targetDir: string; @@ -59,6 +60,10 @@ export class Storage { return path.join(Storage.getGlobalQwenDir(), TMP_DIR_NAME); } + static getGlobalIdeDir(): string { + return path.join(Storage.getGlobalQwenDir(), IDE_DIR_NAME); + } + static getGlobalBinDir(): string { return path.join(Storage.getGlobalQwenDir(), BIN_DIR_NAME); } @@ -121,6 +126,10 @@ export class Storage { return path.join(this.getExtensionsDir(), 'qwen-extension.json'); } + getUserSkillsDir(): string { + return path.join(Storage.getGlobalQwenDir(), 'skills'); + } + getHistoryFilePath(): string { return path.join(this.getProjectTempDir(), 'shell_history'); } diff --git a/packages/core/src/core/__tests__/openaiTimeoutHandling.test.ts b/packages/core/src/core/__tests__/openaiTimeoutHandling.test.ts index 07d3b9309..62e3e39ed 100644 --- a/packages/core/src/core/__tests__/openaiTimeoutHandling.test.ts +++ b/packages/core/src/core/__tests__/openaiTimeoutHandling.test.ts @@ -73,6 +73,7 @@ describe('OpenAIContentGenerator Timeout Handling', () => { }), buildClient: vi.fn().mockReturnValue(mockOpenAIClient), buildRequest: vi.fn().mockImplementation((req) => req), + getDefaultGenerationConfig: vi.fn().mockReturnValue({}), }; // Create generator instance @@ -299,6 +300,7 @@ describe('OpenAIContentGenerator Timeout Handling', () => { }), buildClient: vi.fn().mockReturnValue(mockOpenAIClient), buildRequest: vi.fn().mockImplementation((req) => req), + getDefaultGenerationConfig: vi.fn().mockReturnValue({}), }; new OpenAIContentGenerator( @@ -333,6 +335,7 @@ describe('OpenAIContentGenerator Timeout Handling', () => { }), buildClient: vi.fn().mockReturnValue(mockOpenAIClient), buildRequest: vi.fn().mockImplementation((req) => req), + getDefaultGenerationConfig: vi.fn().mockReturnValue({}), }; new OpenAIContentGenerator( diff --git a/packages/core/src/core/anthropicContentGenerator/anthropicContentGenerator.test.ts b/packages/core/src/core/anthropicContentGenerator/anthropicContentGenerator.test.ts new file mode 100644 index 000000000..483edac1a --- /dev/null +++ b/packages/core/src/core/anthropicContentGenerator/anthropicContentGenerator.test.ts @@ -0,0 +1,500 @@ +/** + * @license + * Copyright 2025 Qwen + * SPDX-License-Identifier: Apache-2.0 + */ + +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import type { + CountTokensParameters, + GenerateContentParameters, +} from '@google/genai'; +import { FinishReason, GenerateContentResponse } from '@google/genai'; + +// Mock the request tokenizer module BEFORE importing the class that uses it. +const mockTokenizer = { + calculateTokens: vi.fn(), + dispose: vi.fn(), +}; + +vi.mock('../../utils/request-tokenizer/index.js', () => ({ + getDefaultTokenizer: vi.fn(() => mockTokenizer), + DefaultRequestTokenizer: vi.fn(() => mockTokenizer), + disposeDefaultTokenizer: vi.fn(), +})); + +type AnthropicCreateArgs = [unknown, { signal?: AbortSignal }?]; + +const anthropicMockState: { + constructorOptions?: Record; + lastCreateArgs?: AnthropicCreateArgs; + createImpl: ReturnType; +} = { + constructorOptions: undefined, + lastCreateArgs: undefined, + createImpl: vi.fn(), +}; + +vi.mock('@anthropic-ai/sdk', () => { + class AnthropicMock { + messages: { create: (...args: AnthropicCreateArgs) => unknown }; + + constructor(options: Record) { + anthropicMockState.constructorOptions = options; + this.messages = { + create: (...args: AnthropicCreateArgs) => { + anthropicMockState.lastCreateArgs = args; + return anthropicMockState.createImpl(...args); + }, + }; + } + } + + return { + default: AnthropicMock, + __anthropicState: anthropicMockState, + }; +}); + +// Now import the modules that depend on the mocked modules. +import type { Config } from '../../config/config.js'; + +const importGenerator = async (): Promise<{ + AnthropicContentGenerator: typeof import('./anthropicContentGenerator.js').AnthropicContentGenerator; +}> => import('./anthropicContentGenerator.js'); + +const importConverter = async (): Promise<{ + AnthropicContentConverter: typeof import('./converter.js').AnthropicContentConverter; +}> => import('./converter.js'); + +describe('AnthropicContentGenerator', () => { + let mockConfig: Config; + let anthropicState: { + constructorOptions?: Record; + lastCreateArgs?: AnthropicCreateArgs; + createImpl: ReturnType; + }; + + beforeEach(async () => { + vi.clearAllMocks(); + vi.resetModules(); + + mockTokenizer.calculateTokens.mockResolvedValue({ + totalTokens: 50, + breakdown: { + textTokens: 50, + imageTokens: 0, + audioTokens: 0, + otherTokens: 0, + }, + processingTime: 1, + }); + anthropicState = anthropicMockState; + + anthropicState.createImpl.mockReset(); + anthropicState.lastCreateArgs = undefined; + anthropicState.constructorOptions = undefined; + + mockConfig = { + getCliVersion: vi.fn().mockReturnValue('1.2.3'), + } as unknown as Config; + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + it('passes a QwenCode User-Agent header to the Anthropic SDK', async () => { + const { AnthropicContentGenerator } = await importGenerator(); + void new AnthropicContentGenerator( + { + model: 'claude-test', + apiKey: 'test-key', + baseUrl: 'https://example.invalid', + timeout: 10_000, + maxRetries: 2, + samplingParams: {}, + schemaCompliance: 'auto', + }, + mockConfig, + ); + + const headers = (anthropicState.constructorOptions?.['defaultHeaders'] || + {}) as Record; + expect(headers['User-Agent']).toContain('QwenCode/1.2.3'); + expect(headers['User-Agent']).toContain( + `(${process.platform}; ${process.arch})`, + ); + }); + + it('adds the effort beta header when reasoning.effort is set', async () => { + const { AnthropicContentGenerator } = await importGenerator(); + void new AnthropicContentGenerator( + { + model: 'claude-test', + apiKey: 'test-key', + baseUrl: 'https://example.invalid', + timeout: 10_000, + maxRetries: 2, + samplingParams: {}, + schemaCompliance: 'auto', + reasoning: { effort: 'medium' }, + }, + mockConfig, + ); + + const headers = (anthropicState.constructorOptions?.['defaultHeaders'] || + {}) as Record; + expect(headers['anthropic-beta']).toContain('effort-2025-11-24'); + }); + + it('does not add the effort beta header when reasoning.effort is not set', async () => { + const { AnthropicContentGenerator } = await importGenerator(); + void new AnthropicContentGenerator( + { + model: 'claude-test', + apiKey: 'test-key', + baseUrl: 'https://example.invalid', + timeout: 10_000, + maxRetries: 2, + samplingParams: {}, + schemaCompliance: 'auto', + }, + mockConfig, + ); + + const headers = (anthropicState.constructorOptions?.['defaultHeaders'] || + {}) as Record; + expect(headers['anthropic-beta']).not.toContain('effort-2025-11-24'); + }); + + it('omits the anthropic beta header when reasoning is disabled', async () => { + const { AnthropicContentGenerator } = await importGenerator(); + void new AnthropicContentGenerator( + { + model: 'claude-test', + apiKey: 'test-key', + baseUrl: 'https://example.invalid', + timeout: 10_000, + maxRetries: 2, + samplingParams: {}, + schemaCompliance: 'auto', + reasoning: false, + }, + mockConfig, + ); + + const headers = (anthropicState.constructorOptions?.['defaultHeaders'] || + {}) as Record; + expect(headers['anthropic-beta']).toBeUndefined(); + }); + + describe('generateContent', () => { + it('builds request with config sampling params (config overrides request) and thinking budget', async () => { + const { AnthropicContentConverter } = await importConverter(); + const { AnthropicContentGenerator } = await importGenerator(); + + const convertResponseSpy = vi + .spyOn( + AnthropicContentConverter.prototype, + 'convertAnthropicResponseToGemini', + ) + .mockReturnValue( + (() => { + const r = new GenerateContentResponse(); + r.responseId = 'gemini-1'; + return r; + })(), + ); + + anthropicState.createImpl.mockResolvedValue({ + id: 'anthropic-1', + model: 'claude-test', + content: [{ type: 'text', text: 'hi' }], + }); + + const generator = new AnthropicContentGenerator( + { + model: 'claude-test', + apiKey: 'test-key', + baseUrl: 'https://example.invalid', + timeout: 10_000, + maxRetries: 2, + samplingParams: { + temperature: 0.7, + max_tokens: 1000, + top_p: 0.9, + top_k: 20, + }, + schemaCompliance: 'auto', + reasoning: { effort: 'high', budget_tokens: 1000 }, + }, + mockConfig, + ); + + const abortController = new AbortController(); + const request: GenerateContentParameters = { + model: 'models/ignored', + contents: 'Hello', + config: { + temperature: 0.1, + maxOutputTokens: 200, + topP: 0.5, + topK: 5, + abortSignal: abortController.signal, + }, + }; + + const result = await generator.generateContent(request); + expect(result.responseId).toBe('gemini-1'); + + expect(anthropicState.lastCreateArgs).toBeDefined(); + const [anthropicRequest, options] = + anthropicState.lastCreateArgs as AnthropicCreateArgs; + + expect(options?.signal).toBe(abortController.signal); + + expect(anthropicRequest).toEqual( + expect.objectContaining({ + model: 'claude-test', + max_tokens: 1000, + temperature: 0.7, + top_p: 0.9, + top_k: 20, + thinking: { type: 'enabled', budget_tokens: 1000 }, + output_config: { effort: 'high' }, + }), + ); + + expect(convertResponseSpy).toHaveBeenCalledTimes(1); + }); + + it('omits thinking when request.config.thinkingConfig.includeThoughts is false', async () => { + const { AnthropicContentGenerator } = await importGenerator(); + anthropicState.createImpl.mockResolvedValue({ + id: 'anthropic-1', + model: 'claude-test', + content: [{ type: 'text', text: 'hi' }], + }); + + const generator = new AnthropicContentGenerator( + { + model: 'claude-test', + apiKey: 'test-key', + timeout: 10_000, + maxRetries: 2, + samplingParams: { max_tokens: 500 }, + schemaCompliance: 'auto', + reasoning: { effort: 'high' }, + }, + mockConfig, + ); + + await generator.generateContent({ + model: 'models/ignored', + contents: 'Hello', + config: { thinkingConfig: { includeThoughts: false } }, + } as unknown as GenerateContentParameters); + + const [anthropicRequest] = + anthropicState.lastCreateArgs as AnthropicCreateArgs; + expect(anthropicRequest).toEqual( + expect.not.objectContaining({ thinking: expect.anything() }), + ); + }); + }); + + describe('countTokens', () => { + it('counts tokens using the request tokenizer', async () => { + const { AnthropicContentGenerator } = await importGenerator(); + const generator = new AnthropicContentGenerator( + { + model: 'claude-test', + apiKey: 'test-key', + timeout: 10_000, + maxRetries: 2, + samplingParams: {}, + schemaCompliance: 'auto', + }, + mockConfig, + ); + + const request: CountTokensParameters = { + contents: [{ role: 'user', parts: [{ text: 'Hello world' }] }], + model: 'claude-test', + }; + + const result = await generator.countTokens(request); + expect(mockTokenizer.calculateTokens).toHaveBeenCalledWith(request, { + textEncoding: 'cl100k_base', + }); + expect(result.totalTokens).toBe(50); + }); + + it('falls back to character approximation when tokenizer throws', async () => { + const { AnthropicContentGenerator } = await importGenerator(); + mockTokenizer.calculateTokens.mockRejectedValueOnce(new Error('boom')); + const generator = new AnthropicContentGenerator( + { + model: 'claude-test', + apiKey: 'test-key', + timeout: 10_000, + maxRetries: 2, + samplingParams: {}, + schemaCompliance: 'auto', + }, + mockConfig, + ); + + const request: CountTokensParameters = { + contents: [{ role: 'user', parts: [{ text: 'Hello' }] }], + model: 'claude-test', + }; + + const content = JSON.stringify(request.contents); + const expected = Math.ceil(content.length / 4); + const result = await generator.countTokens(request); + expect(result.totalTokens).toBe(expected); + }); + }); + + describe('generateContentStream', () => { + it('requests stream=true and converts streamed events into Gemini chunks', async () => { + const { AnthropicContentGenerator } = await importGenerator(); + anthropicState.createImpl.mockResolvedValue( + (async function* () { + yield { + type: 'message_start', + message: { + id: 'msg-1', + model: 'claude-test', + usage: { cache_read_input_tokens: 2, input_tokens: 3 }, + }, + }; + + yield { + type: 'content_block_start', + index: 0, + content_block: { type: 'text' }, + }; + yield { + type: 'content_block_delta', + index: 0, + delta: { type: 'text_delta', text: 'Hello' }, + }; + yield { type: 'content_block_stop', index: 0 }; + + yield { + type: 'content_block_start', + index: 1, + content_block: { type: 'thinking', signature: '' }, + }; + yield { + type: 'content_block_delta', + index: 1, + delta: { type: 'thinking_delta', thinking: 'Think' }, + }; + yield { + type: 'content_block_delta', + index: 1, + delta: { type: 'signature_delta', signature: 'abc' }, + }; + yield { type: 'content_block_stop', index: 1 }; + + yield { + type: 'content_block_start', + index: 2, + content_block: { + type: 'tool_use', + id: 't1', + name: 'tool', + input: {}, + }, + }; + yield { + type: 'content_block_delta', + index: 2, + delta: { type: 'input_json_delta', partial_json: '{"x":' }, + }; + yield { + type: 'content_block_delta', + index: 2, + delta: { type: 'input_json_delta', partial_json: '1}' }, + }; + yield { type: 'content_block_stop', index: 2 }; + + yield { + type: 'message_delta', + delta: { stop_reason: 'end_turn' }, + usage: { + output_tokens: 5, + input_tokens: 7, + cache_read_input_tokens: 2, + }, + }; + yield { type: 'message_stop' }; + })(), + ); + + const generator = new AnthropicContentGenerator( + { + model: 'claude-test', + apiKey: 'test-key', + timeout: 10_000, + maxRetries: 2, + samplingParams: { max_tokens: 123 }, + schemaCompliance: 'auto', + }, + mockConfig, + ); + + const stream = await generator.generateContentStream({ + model: 'models/ignored', + contents: 'Hello', + } as unknown as GenerateContentParameters); + + const chunks: GenerateContentResponse[] = []; + for await (const chunk of stream) { + chunks.push(chunk); + } + + const [anthropicRequest] = + anthropicState.lastCreateArgs as AnthropicCreateArgs; + expect(anthropicRequest).toEqual( + expect.objectContaining({ stream: true }), + ); + + // Text chunk. + expect(chunks[0]?.candidates?.[0]?.content?.parts?.[0]).toEqual({ + text: 'Hello', + }); + + // Thinking chunk. + expect(chunks[1]?.candidates?.[0]?.content?.parts?.[0]).toEqual({ + text: 'Think', + thought: true, + }); + + // Signature chunk. + expect(chunks[2]?.candidates?.[0]?.content?.parts?.[0]).toEqual({ + thought: true, + thoughtSignature: 'abc', + }); + + // Tool call chunk. + expect(chunks[3]?.candidates?.[0]?.content?.parts?.[0]).toEqual({ + functionCall: { id: 't1', name: 'tool', args: { x: 1 } }, + }); + + // Usage/finish chunks exist; check the last one. + const last = chunks[chunks.length - 1]!; + expect(last.candidates?.[0]?.finishReason).toBe(FinishReason.STOP); + expect(last.usageMetadata).toEqual({ + cachedContentTokenCount: 2, + promptTokenCount: 9, // cached(2) + input(7) + candidatesTokenCount: 5, + totalTokenCount: 14, + }); + }); + }); +}); diff --git a/packages/core/src/core/anthropicContentGenerator/anthropicContentGenerator.ts b/packages/core/src/core/anthropicContentGenerator/anthropicContentGenerator.ts new file mode 100644 index 000000000..228f93853 --- /dev/null +++ b/packages/core/src/core/anthropicContentGenerator/anthropicContentGenerator.ts @@ -0,0 +1,502 @@ +/** + * @license + * Copyright 2025 Qwen + * SPDX-License-Identifier: Apache-2.0 + */ + +import Anthropic from '@anthropic-ai/sdk'; +import type { + CountTokensParameters, + CountTokensResponse, + EmbedContentParameters, + EmbedContentResponse, + GenerateContentParameters, + GenerateContentResponseUsageMetadata, + Part, +} from '@google/genai'; +import { GenerateContentResponse } from '@google/genai'; +import type { Config } from '../../config/config.js'; +import type { + ContentGenerator, + ContentGeneratorConfig, +} from '../contentGenerator.js'; +type Message = Anthropic.Message; +type MessageCreateParamsNonStreaming = + Anthropic.MessageCreateParamsNonStreaming; +type MessageCreateParamsStreaming = Anthropic.MessageCreateParamsStreaming; +type RawMessageStreamEvent = Anthropic.RawMessageStreamEvent; +import { getDefaultTokenizer } from '../../utils/request-tokenizer/index.js'; +import { safeJsonParse } from '../../utils/safeJsonParse.js'; +import { AnthropicContentConverter } from './converter.js'; + +type StreamingBlockState = { + type: string; + id?: string; + name?: string; + inputJson: string; + signature: string; +}; + +type MessageCreateParamsWithThinking = MessageCreateParamsNonStreaming & { + thinking?: { type: 'enabled'; budget_tokens: number }; + // Anthropic beta feature: output_config.effort (requires beta header effort-2025-11-24) + // This is not yet represented in the official SDK types we depend on. + output_config?: { effort: 'low' | 'medium' | 'high' }; +}; + +export class AnthropicContentGenerator implements ContentGenerator { + private client: Anthropic; + private converter: AnthropicContentConverter; + + constructor( + private contentGeneratorConfig: ContentGeneratorConfig, + private readonly cliConfig: Config, + ) { + const defaultHeaders = this.buildHeaders(); + const baseURL = contentGeneratorConfig.baseUrl; + + this.client = new Anthropic({ + apiKey: contentGeneratorConfig.apiKey, + baseURL, + timeout: contentGeneratorConfig.timeout, + maxRetries: contentGeneratorConfig.maxRetries, + defaultHeaders, + }); + + this.converter = new AnthropicContentConverter( + contentGeneratorConfig.model, + contentGeneratorConfig.schemaCompliance, + ); + } + + async generateContent( + request: GenerateContentParameters, + ): Promise { + const anthropicRequest = await this.buildRequest(request); + const response = (await this.client.messages.create(anthropicRequest, { + signal: request.config?.abortSignal, + })) as Message; + + return this.converter.convertAnthropicResponseToGemini(response); + } + + async generateContentStream( + request: GenerateContentParameters, + ): Promise> { + const anthropicRequest = await this.buildRequest(request); + const streamingRequest: MessageCreateParamsStreaming & { + thinking?: { type: 'enabled'; budget_tokens: number }; + } = { + ...anthropicRequest, + stream: true, + }; + + const stream = (await this.client.messages.create( + streamingRequest as MessageCreateParamsStreaming, + { + signal: request.config?.abortSignal, + }, + )) as AsyncIterable; + + return this.processStream(stream); + } + + async countTokens( + request: CountTokensParameters, + ): Promise { + try { + const tokenizer = getDefaultTokenizer(); + const result = await tokenizer.calculateTokens(request, { + textEncoding: 'cl100k_base', + }); + + return { + totalTokens: result.totalTokens, + }; + } catch (error) { + console.warn( + 'Failed to calculate tokens with tokenizer, ' + + 'falling back to simple method:', + error, + ); + + const content = JSON.stringify(request.contents); + const totalTokens = Math.ceil(content.length / 4); + return { + totalTokens, + }; + } + } + + async embedContent( + _request: EmbedContentParameters, + ): Promise { + throw new Error('Anthropic does not support embeddings.'); + } + + useSummarizedThinking(): boolean { + return false; + } + + private buildHeaders(): Record { + const version = this.cliConfig.getCliVersion() || 'unknown'; + const userAgent = `QwenCode/${version} (${process.platform}; ${process.arch})`; + + const betas: string[] = []; + const reasoning = this.contentGeneratorConfig.reasoning; + + // Interleaved thinking is used when we send the `thinking` field. + if (reasoning !== false) { + betas.push('interleaved-thinking-2025-05-14'); + } + + // Effort (beta) is enabled when reasoning.effort is set. + if (reasoning !== false && reasoning?.effort !== undefined) { + betas.push('effort-2025-11-24'); + } + + const headers: Record = { + 'User-Agent': userAgent, + }; + + if (betas.length) { + headers['anthropic-beta'] = betas.join(','); + } + + return headers; + } + + private async buildRequest( + request: GenerateContentParameters, + ): Promise { + const { system, messages } = + this.converter.convertGeminiRequestToAnthropic(request); + + const tools = request.config?.tools + ? await this.converter.convertGeminiToolsToAnthropic(request.config.tools) + : undefined; + + const sampling = this.buildSamplingParameters(request); + const thinking = this.buildThinkingConfig(request); + const outputConfig = this.buildOutputConfig(); + + return { + model: this.contentGeneratorConfig.model, + system, + messages, + tools, + ...sampling, + ...(thinking ? { thinking } : {}), + ...(outputConfig ? { output_config: outputConfig } : {}), + }; + } + + private buildSamplingParameters(request: GenerateContentParameters): { + max_tokens: number; + temperature?: number; + top_p?: number; + top_k?: number; + } { + const configSamplingParams = this.contentGeneratorConfig.samplingParams; + const requestConfig = request.config || {}; + + const getParam = ( + configKey: keyof NonNullable, + requestKey?: keyof NonNullable, + ): T | undefined => { + const configValue = configSamplingParams?.[configKey] as T | undefined; + const requestValue = requestKey + ? (requestConfig[requestKey] as T | undefined) + : undefined; + return configValue !== undefined ? configValue : requestValue; + }; + + const maxTokens = + getParam('max_tokens', 'maxOutputTokens') ?? 10_000; + + return { + max_tokens: maxTokens, + temperature: getParam('temperature', 'temperature') ?? 1, + top_p: getParam('top_p', 'topP'), + top_k: getParam('top_k', 'topK'), + }; + } + + private buildThinkingConfig( + request: GenerateContentParameters, + ): { type: 'enabled'; budget_tokens: number } | undefined { + if (request.config?.thinkingConfig?.includeThoughts === false) { + return undefined; + } + + const reasoning = this.contentGeneratorConfig.reasoning; + + if (reasoning === false) { + return undefined; + } + + if (reasoning?.budget_tokens !== undefined) { + return { + type: 'enabled', + budget_tokens: reasoning.budget_tokens, + }; + } + + const effort = reasoning?.effort; + // When using interleaved thinking with tools, this budget token limit is the entire context window(200k tokens). + const budgetTokens = + effort === 'low' ? 16_000 : effort === 'high' ? 64_000 : 32_000; + + return { + type: 'enabled', + budget_tokens: budgetTokens, + }; + } + + private buildOutputConfig(): + | { effort: 'low' | 'medium' | 'high' } + | undefined { + const reasoning = this.contentGeneratorConfig.reasoning; + if (reasoning === false || reasoning === undefined) { + return undefined; + } + + if (reasoning.effort === undefined) { + return undefined; + } + + return { effort: reasoning.effort }; + } + + private async *processStream( + stream: AsyncIterable, + ): AsyncGenerator { + let messageId: string | undefined; + let model = this.contentGeneratorConfig.model; + let cachedTokens = 0; + let promptTokens = 0; + let completionTokens = 0; + let finishReason: string | undefined; + + const blocks = new Map(); + const collectedResponses: GenerateContentResponse[] = []; + + for await (const event of stream) { + switch (event.type) { + case 'message_start': { + messageId = event.message.id ?? messageId; + model = event.message.model ?? model; + cachedTokens = + event.message.usage?.cache_read_input_tokens ?? cachedTokens; + promptTokens = event.message.usage?.input_tokens ?? promptTokens; + break; + } + case 'content_block_start': { + const index = event.index ?? 0; + const type = String(event.content_block.type || 'text'); + const initialInput = + type === 'tool_use' && 'input' in event.content_block + ? JSON.stringify(event.content_block.input) + : ''; + blocks.set(index, { + type, + id: + 'id' in event.content_block ? event.content_block.id : undefined, + name: + 'name' in event.content_block + ? event.content_block.name + : undefined, + inputJson: initialInput !== '{}' ? initialInput : '', + signature: + type === 'thinking' && + 'signature' in event.content_block && + typeof event.content_block.signature === 'string' + ? event.content_block.signature + : '', + }); + break; + } + case 'content_block_delta': { + const index = event.index ?? 0; + const deltaType = (event.delta as { type?: string }).type || ''; + const blockState = blocks.get(index); + + if (deltaType === 'text_delta') { + const text = 'text' in event.delta ? event.delta.text : ''; + if (text) { + const chunk = this.buildGeminiChunk({ text }, messageId, model); + collectedResponses.push(chunk); + yield chunk; + } + } else if (deltaType === 'thinking_delta') { + const thinking = + (event.delta as { thinking?: string }).thinking || ''; + if (thinking) { + const chunk = this.buildGeminiChunk( + { text: thinking, thought: true }, + messageId, + model, + ); + collectedResponses.push(chunk); + yield chunk; + } + } else if (deltaType === 'signature_delta' && blockState) { + const signature = + (event.delta as { signature?: string }).signature || ''; + if (signature) { + blockState.signature += signature; + const chunk = this.buildGeminiChunk( + { thought: true, thoughtSignature: signature }, + messageId, + model, + ); + collectedResponses.push(chunk); + yield chunk; + } + } else if (deltaType === 'input_json_delta' && blockState) { + const jsonDelta = + (event.delta as { partial_json?: string }).partial_json || ''; + if (jsonDelta) { + blockState.inputJson += jsonDelta; + } + } + break; + } + case 'content_block_stop': { + const index = event.index ?? 0; + const blockState = blocks.get(index); + if (blockState?.type === 'tool_use') { + const args = safeJsonParse(blockState.inputJson || '{}', {}); + const chunk = this.buildGeminiChunk( + { + functionCall: { + id: blockState.id, + name: blockState.name, + args, + }, + }, + messageId, + model, + ); + collectedResponses.push(chunk); + yield chunk; + } + blocks.delete(index); + break; + } + case 'message_delta': { + const stopReasonValue = event.delta.stop_reason; + if (stopReasonValue) { + finishReason = stopReasonValue; + } + + // Some Anthropic-compatible providers may include additional usage fields + // (e.g. `input_tokens`, `cache_read_input_tokens`) even though the official + // Anthropic SDK types only expose `output_tokens` here. + const usageUnknown = event.usage as unknown; + const usageRecord = + usageUnknown && typeof usageUnknown === 'object' + ? (usageUnknown as Record) + : undefined; + + if (event.usage?.output_tokens !== undefined) { + completionTokens = event.usage.output_tokens; + } + if (usageRecord?.['input_tokens'] !== undefined) { + const inputTokens = usageRecord['input_tokens']; + if (typeof inputTokens === 'number') { + promptTokens = inputTokens; + } + } + if (usageRecord?.['cache_read_input_tokens'] !== undefined) { + const cacheRead = usageRecord['cache_read_input_tokens']; + if (typeof cacheRead === 'number') { + cachedTokens = cacheRead; + } + } + + if (finishReason || event.usage) { + const chunk = this.buildGeminiChunk( + undefined, + messageId, + model, + finishReason, + { + cachedContentTokenCount: cachedTokens, + promptTokenCount: cachedTokens + promptTokens, + candidatesTokenCount: completionTokens, + totalTokenCount: cachedTokens + promptTokens + completionTokens, + }, + ); + collectedResponses.push(chunk); + yield chunk; + } + break; + } + case 'message_stop': { + if (promptTokens || completionTokens) { + const chunk = this.buildGeminiChunk( + undefined, + messageId, + model, + finishReason, + { + cachedContentTokenCount: cachedTokens, + promptTokenCount: cachedTokens + promptTokens, + candidatesTokenCount: completionTokens, + totalTokenCount: cachedTokens + promptTokens + completionTokens, + }, + ); + collectedResponses.push(chunk); + yield chunk; + } + break; + } + default: + break; + } + } + } + + private buildGeminiChunk( + part?: { + text?: string; + thought?: boolean; + thoughtSignature?: string; + functionCall?: unknown; + }, + responseId?: string, + model?: string, + finishReason?: string, + usageMetadata?: GenerateContentResponseUsageMetadata, + ): GenerateContentResponse { + const response = new GenerateContentResponse(); + response.responseId = responseId; + response.createTime = Date.now().toString(); + response.modelVersion = model || this.contentGeneratorConfig.model; + response.promptFeedback = { safetyRatings: [] }; + + const candidateParts = part ? [part as unknown as Part] : []; + const mappedFinishReason = + finishReason !== undefined + ? this.converter.mapAnthropicFinishReasonToGemini(finishReason) + : undefined; + response.candidates = [ + { + content: { + parts: candidateParts, + role: 'model' as const, + }, + index: 0, + safetyRatings: [], + ...(mappedFinishReason ? { finishReason: mappedFinishReason } : {}), + }, + ]; + + if (usageMetadata) { + response.usageMetadata = usageMetadata; + } + + return response; + } +} diff --git a/packages/core/src/core/anthropicContentGenerator/converter.test.ts b/packages/core/src/core/anthropicContentGenerator/converter.test.ts new file mode 100644 index 000000000..f2ab79411 --- /dev/null +++ b/packages/core/src/core/anthropicContentGenerator/converter.test.ts @@ -0,0 +1,377 @@ +/** + * @license + * Copyright 2025 Qwen + * SPDX-License-Identifier: Apache-2.0 + */ + +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import type { CallableTool, Content, Tool } from '@google/genai'; +import { FinishReason } from '@google/genai'; +import type Anthropic from '@anthropic-ai/sdk'; + +// Mock schema conversion so we can force edge-cases (e.g. missing `type`). +vi.mock('../../utils/schemaConverter.js', () => ({ + convertSchema: vi.fn((schema: unknown) => schema), +})); + +import { convertSchema } from '../../utils/schemaConverter.js'; +import { AnthropicContentConverter } from './converter.js'; + +describe('AnthropicContentConverter', () => { + let converter: AnthropicContentConverter; + + beforeEach(() => { + vi.clearAllMocks(); + converter = new AnthropicContentConverter('test-model', 'auto'); + }); + + describe('convertGeminiRequestToAnthropic', () => { + it('extracts systemInstruction text from string', () => { + const { system } = converter.convertGeminiRequestToAnthropic({ + model: 'models/test', + contents: 'hi', + config: { systemInstruction: 'sys' }, + }); + + expect(system).toBe('sys'); + }); + + it('extracts systemInstruction text from parts and joins with newlines', () => { + const { system } = converter.convertGeminiRequestToAnthropic({ + model: 'models/test', + contents: 'hi', + config: { + systemInstruction: { + role: 'system', + parts: [{ text: 'a' }, { text: 'b' }], + } as unknown as Content, + }, + }); + + expect(system).toBe('a\nb'); + }); + + it('converts a plain string content into a user message', () => { + const { messages } = converter.convertGeminiRequestToAnthropic({ + model: 'models/test', + contents: 'Hello', + }); + + expect(messages).toEqual([ + { role: 'user', content: [{ type: 'text', text: 'Hello' }] }, + ]); + }); + + it('converts user content parts into a user message with text blocks', () => { + const { messages } = converter.convertGeminiRequestToAnthropic({ + model: 'models/test', + contents: [ + { + role: 'user', + parts: [{ text: 'Hello' }, { text: 'World' }], + }, + ], + }); + + expect(messages).toEqual([ + { + role: 'user', + content: [ + { type: 'text', text: 'Hello' }, + { type: 'text', text: 'World' }, + ], + }, + ]); + }); + + it('converts assistant thought parts into Anthropic thinking blocks', () => { + const { messages } = converter.convertGeminiRequestToAnthropic({ + model: 'models/test', + contents: [ + { + role: 'model', + parts: [ + { text: 'internal', thought: true, thoughtSignature: 'sig' }, + { text: 'visible' }, + ], + }, + ], + }); + + expect(messages).toEqual([ + { + role: 'assistant', + content: [ + { type: 'thinking', thinking: 'internal', signature: 'sig' }, + { type: 'text', text: 'visible' }, + ], + }, + ]); + }); + + it('converts functionCall parts from model role into tool_use blocks', () => { + const { messages } = converter.convertGeminiRequestToAnthropic({ + model: 'models/test', + contents: [ + { + role: 'model', + parts: [ + { text: 'preface' }, + { + functionCall: { + id: 'call-1', + name: 'tool_name', + args: { a: 1 }, + }, + }, + ], + }, + ], + }); + + expect(messages).toEqual([ + { + role: 'assistant', + content: [ + { type: 'text', text: 'preface' }, + { + type: 'tool_use', + id: 'call-1', + name: 'tool_name', + input: { a: 1 }, + }, + ], + }, + ]); + }); + + it('converts functionResponse parts into user tool_result messages', () => { + const { messages } = converter.convertGeminiRequestToAnthropic({ + model: 'models/test', + contents: [ + { + role: 'user', + parts: [ + { + functionResponse: { + id: 'call-1', + name: 'tool_name', + response: { output: 'ok' }, + }, + }, + ], + }, + ], + }); + + expect(messages).toEqual([ + { + role: 'user', + content: [ + { + type: 'tool_result', + tool_use_id: 'call-1', + content: 'ok', + }, + ], + }, + ]); + }); + + it('extracts function response error field when present', () => { + const { messages } = converter.convertGeminiRequestToAnthropic({ + model: 'models/test', + contents: [ + { + role: 'user', + parts: [ + { + functionResponse: { + id: 'call-1', + name: 'tool_name', + response: { error: 'boom' }, + }, + }, + ], + }, + ], + }); + + expect(messages[0]).toEqual({ + role: 'user', + content: [ + { + type: 'tool_result', + tool_use_id: 'call-1', + content: 'boom', + }, + ], + }); + }); + }); + + describe('convertGeminiToolsToAnthropic', () => { + it('converts Tool.functionDeclarations to Anthropic tools and runs schema conversion', async () => { + const tools = [ + { + functionDeclarations: [ + { + name: 'get_weather', + description: 'Get weather', + parametersJsonSchema: { + type: 'object', + properties: { location: { type: 'string' } }, + required: ['location'], + }, + }, + ], + }, + ] as Tool[]; + + const result = await converter.convertGeminiToolsToAnthropic(tools); + + expect(result).toHaveLength(1); + expect(result[0]).toEqual({ + name: 'get_weather', + description: 'Get weather', + input_schema: { + type: 'object', + properties: { location: { type: 'string' } }, + required: ['location'], + }, + }); + + expect(vi.mocked(convertSchema)).toHaveBeenCalledTimes(1); + }); + + it('resolves CallableTool.tool() and converts its functionDeclarations', async () => { + const callable = [ + { + tool: async () => + ({ + functionDeclarations: [ + { + name: 'dynamic_tool', + description: 'resolved tool', + parametersJsonSchema: { type: 'object', properties: {} }, + }, + ], + }) as unknown as Tool, + }, + ] as CallableTool[]; + + const result = await converter.convertGeminiToolsToAnthropic(callable); + + expect(result).toHaveLength(1); + expect(result[0].name).toBe('dynamic_tool'); + }); + + it('defaults missing parameters to an empty object schema', async () => { + const tools = [ + { + functionDeclarations: [ + { name: 'no_params', description: 'no params' }, + ], + }, + ] as Tool[]; + + const result = await converter.convertGeminiToolsToAnthropic(tools); + + expect(result).toHaveLength(1); + expect(result[0]).toEqual({ + name: 'no_params', + description: 'no params', + input_schema: { type: 'object', properties: {} }, + }); + }); + + it('forces input_schema.type to "object" when schema conversion yields no type', async () => { + vi.mocked(convertSchema).mockImplementationOnce(() => ({ + properties: {}, + })); + const tools = [ + { + functionDeclarations: [ + { + name: 'edge', + description: 'edge', + parametersJsonSchema: { type: 'object', properties: {} }, + }, + ], + }, + ] as Tool[]; + + const result = await converter.convertGeminiToolsToAnthropic(tools); + expect(result[0]?.input_schema?.type).toBe('object'); + }); + }); + + describe('convertAnthropicResponseToGemini', () => { + it('converts text, tool_use, thinking, and redacted_thinking blocks', () => { + const response = converter.convertAnthropicResponseToGemini({ + id: 'msg-1', + model: 'claude-test', + stop_reason: 'end_turn', + content: [ + { type: 'thinking', thinking: 'thought', signature: 'sig' }, + { type: 'text', text: 'hello' }, + { type: 'tool_use', id: 't1', name: 'tool', input: { x: 1 } }, + { type: 'redacted_thinking' }, + ], + usage: { input_tokens: 3, output_tokens: 5 }, + } as unknown as Anthropic.Message); + + expect(response.responseId).toBe('msg-1'); + expect(response.modelVersion).toBe('claude-test'); + expect(response.candidates?.[0]?.finishReason).toBe(FinishReason.STOP); + expect(response.usageMetadata).toEqual({ + promptTokenCount: 3, + candidatesTokenCount: 5, + totalTokenCount: 8, + }); + + const parts = response.candidates?.[0]?.content?.parts || []; + expect(parts).toEqual([ + { text: 'thought', thought: true, thoughtSignature: 'sig' }, + { text: 'hello' }, + { functionCall: { id: 't1', name: 'tool', args: { x: 1 } } }, + { text: '', thought: true }, + ]); + }); + + it('handles tool_use input that is a JSON string', () => { + const response = converter.convertAnthropicResponseToGemini({ + id: 'msg-1', + model: 'claude-test', + stop_reason: null, + content: [ + { type: 'tool_use', id: 't1', name: 'tool', input: '{"x":1}' }, + ], + } as unknown as Anthropic.Message); + + const parts = response.candidates?.[0]?.content?.parts || []; + expect(parts).toEqual([ + { functionCall: { id: 't1', name: 'tool', args: { x: 1 } } }, + ]); + }); + }); + + describe('mapAnthropicFinishReasonToGemini', () => { + it('maps known reasons', () => { + expect(converter.mapAnthropicFinishReasonToGemini('end_turn')).toBe( + FinishReason.STOP, + ); + expect(converter.mapAnthropicFinishReasonToGemini('max_tokens')).toBe( + FinishReason.MAX_TOKENS, + ); + expect(converter.mapAnthropicFinishReasonToGemini('content_filter')).toBe( + FinishReason.SAFETY, + ); + }); + + it('returns undefined for null/empty', () => { + expect(converter.mapAnthropicFinishReasonToGemini(null)).toBeUndefined(); + expect(converter.mapAnthropicFinishReasonToGemini('')).toBeUndefined(); + }); + }); +}); diff --git a/packages/core/src/core/anthropicContentGenerator/converter.ts b/packages/core/src/core/anthropicContentGenerator/converter.ts new file mode 100644 index 000000000..2fb9b7fee --- /dev/null +++ b/packages/core/src/core/anthropicContentGenerator/converter.ts @@ -0,0 +1,448 @@ +/** + * @license + * Copyright 2025 Qwen + * SPDX-License-Identifier: Apache-2.0 + */ + +import type { + Candidate, + CallableTool, + Content, + ContentListUnion, + ContentUnion, + FunctionCall, + FunctionResponse, + GenerateContentParameters, + Part, + PartUnion, + Tool, + ToolListUnion, +} from '@google/genai'; +import { FinishReason, GenerateContentResponse } from '@google/genai'; +import type Anthropic from '@anthropic-ai/sdk'; +import { safeJsonParse } from '../../utils/safeJsonParse.js'; +import { + convertSchema, + type SchemaComplianceMode, +} from '../../utils/schemaConverter.js'; + +type AnthropicMessageParam = Anthropic.MessageParam; +type AnthropicToolParam = Anthropic.Tool; +type AnthropicContentBlockParam = Anthropic.ContentBlockParam; + +type ThoughtPart = { text: string; signature?: string }; + +interface ParsedParts { + thoughtParts: ThoughtPart[]; + contentParts: string[]; + functionCalls: FunctionCall[]; + functionResponses: FunctionResponse[]; +} + +export class AnthropicContentConverter { + private model: string; + private schemaCompliance: SchemaComplianceMode; + + constructor(model: string, schemaCompliance: SchemaComplianceMode = 'auto') { + this.model = model; + this.schemaCompliance = schemaCompliance; + } + + convertGeminiRequestToAnthropic(request: GenerateContentParameters): { + system?: string; + messages: AnthropicMessageParam[]; + } { + const messages: AnthropicMessageParam[] = []; + + const system = this.extractTextFromContentUnion( + request.config?.systemInstruction, + ); + + this.processContents(request.contents, messages); + + return { + system: system || undefined, + messages, + }; + } + + async convertGeminiToolsToAnthropic( + geminiTools: ToolListUnion, + ): Promise { + const tools: AnthropicToolParam[] = []; + + for (const tool of geminiTools) { + let actualTool: Tool; + + if ('tool' in tool) { + actualTool = await (tool as CallableTool).tool(); + } else { + actualTool = tool as Tool; + } + + if (!actualTool.functionDeclarations) { + continue; + } + + for (const func of actualTool.functionDeclarations) { + if (!func.name) continue; + + let inputSchema: Record | undefined; + if (func.parametersJsonSchema) { + inputSchema = { + ...(func.parametersJsonSchema as Record), + }; + } else if (func.parameters) { + inputSchema = func.parameters as Record; + } + + if (!inputSchema) { + inputSchema = { type: 'object', properties: {} }; + } + + inputSchema = convertSchema(inputSchema, this.schemaCompliance); + if (typeof inputSchema['type'] !== 'string') { + inputSchema['type'] = 'object'; + } + + tools.push({ + name: func.name, + description: func.description, + input_schema: inputSchema as Anthropic.Tool.InputSchema, + }); + } + } + + return tools; + } + + convertAnthropicResponseToGemini( + response: Anthropic.Message, + ): GenerateContentResponse { + const geminiResponse = new GenerateContentResponse(); + const parts: Part[] = []; + + for (const block of response.content || []) { + const blockType = String((block as { type?: string })['type'] || ''); + if (blockType === 'text') { + const text = + typeof (block as { text?: string }).text === 'string' + ? (block as { text?: string }).text + : ''; + if (text) { + parts.push({ text }); + } + } else if (blockType === 'tool_use') { + const toolUse = block as { + id?: string; + name?: string; + input?: unknown; + }; + parts.push({ + functionCall: { + id: typeof toolUse.id === 'string' ? toolUse.id : undefined, + name: typeof toolUse.name === 'string' ? toolUse.name : undefined, + args: this.safeInputToArgs(toolUse.input), + }, + }); + } else if (blockType === 'thinking') { + const thinking = + typeof (block as { thinking?: string }).thinking === 'string' + ? (block as { thinking?: string }).thinking + : ''; + const signature = + typeof (block as { signature?: string }).signature === 'string' + ? (block as { signature?: string }).signature + : ''; + if (thinking || signature) { + const thoughtPart: Part = { + text: thinking, + thought: true, + thoughtSignature: signature, + }; + parts.push(thoughtPart); + } + } else if (blockType === 'redacted_thinking') { + parts.push({ text: '', thought: true }); + } + } + + const candidate: Candidate = { + content: { + parts, + role: 'model' as const, + }, + index: 0, + safetyRatings: [], + }; + + const finishReason = this.mapAnthropicFinishReasonToGemini( + response.stop_reason, + ); + if (finishReason) { + candidate.finishReason = finishReason; + } + + geminiResponse.candidates = [candidate]; + geminiResponse.responseId = response.id; + geminiResponse.createTime = Date.now().toString(); + geminiResponse.modelVersion = response.model || this.model; + geminiResponse.promptFeedback = { safetyRatings: [] }; + + if (response.usage) { + const promptTokens = response.usage.input_tokens || 0; + const completionTokens = response.usage.output_tokens || 0; + geminiResponse.usageMetadata = { + promptTokenCount: promptTokens, + candidatesTokenCount: completionTokens, + totalTokenCount: promptTokens + completionTokens, + }; + } + + return geminiResponse; + } + + private processContents( + contents: ContentListUnion, + messages: AnthropicMessageParam[], + ): void { + if (Array.isArray(contents)) { + for (const content of contents) { + this.processContent(content, messages); + } + } else if (contents) { + this.processContent(contents, messages); + } + } + + private processContent( + content: ContentUnion | PartUnion, + messages: AnthropicMessageParam[], + ): void { + if (typeof content === 'string') { + messages.push({ + role: 'user', + content: [{ type: 'text', text: content }], + }); + return; + } + + if (!this.isContentObject(content)) return; + + const parsed = this.parseParts(content.parts || []); + + if (parsed.functionResponses.length > 0) { + for (const response of parsed.functionResponses) { + messages.push({ + role: 'user', + content: [ + { + type: 'tool_result', + tool_use_id: response.id || '', + content: this.extractFunctionResponseContent(response.response), + }, + ], + }); + } + return; + } + + if (content.role === 'model' && parsed.functionCalls.length > 0) { + const thinkingBlocks: AnthropicContentBlockParam[] = + parsed.thoughtParts.map((part) => { + const thinkingBlock: unknown = { + type: 'thinking', + thinking: part.text, + }; + if (part.signature) { + (thinkingBlock as { signature?: string }).signature = + part.signature; + } + return thinkingBlock as AnthropicContentBlockParam; + }); + const toolUses: AnthropicContentBlockParam[] = parsed.functionCalls.map( + (call, index) => ({ + type: 'tool_use', + id: call.id || `tool_${index}`, + name: call.name || '', + input: (call.args as Record) || {}, + }), + ); + + const textBlocks: AnthropicContentBlockParam[] = parsed.contentParts.map( + (text) => ({ + type: 'text' as const, + text, + }), + ); + + messages.push({ + role: 'assistant', + content: [...thinkingBlocks, ...textBlocks, ...toolUses], + }); + return; + } + + const role = content.role === 'model' ? 'assistant' : 'user'; + const thinkingBlocks: AnthropicContentBlockParam[] = + role === 'assistant' + ? parsed.thoughtParts.map((part) => { + const thinkingBlock: unknown = { + type: 'thinking', + thinking: part.text, + }; + if (part.signature) { + (thinkingBlock as { signature?: string }).signature = + part.signature; + } + return thinkingBlock as AnthropicContentBlockParam; + }) + : []; + const textBlocks: AnthropicContentBlockParam[] = [ + ...thinkingBlocks, + ...parsed.contentParts.map((text) => ({ + type: 'text' as const, + text, + })), + ]; + if (textBlocks.length > 0) { + messages.push({ role, content: textBlocks }); + } + } + + private parseParts(parts: Part[]): ParsedParts { + const thoughtParts: ThoughtPart[] = []; + const contentParts: string[] = []; + const functionCalls: FunctionCall[] = []; + const functionResponses: FunctionResponse[] = []; + + for (const part of parts) { + if (typeof part === 'string') { + contentParts.push(part); + } else if ( + 'text' in part && + part.text && + !('thought' in part && part.thought) + ) { + contentParts.push(part.text); + } else if ('text' in part && 'thought' in part && part.thought) { + thoughtParts.push({ + text: part.text || '', + signature: + 'thoughtSignature' in part && + typeof part.thoughtSignature === 'string' + ? part.thoughtSignature + : undefined, + }); + } else if ('functionCall' in part && part.functionCall) { + functionCalls.push(part.functionCall); + } else if ('functionResponse' in part && part.functionResponse) { + functionResponses.push(part.functionResponse); + } + } + + return { + thoughtParts, + contentParts, + functionCalls, + functionResponses, + }; + } + + private extractTextFromContentUnion(contentUnion: unknown): string { + if (typeof contentUnion === 'string') { + return contentUnion; + } + + if (Array.isArray(contentUnion)) { + return contentUnion + .map((item) => this.extractTextFromContentUnion(item)) + .filter(Boolean) + .join('\n'); + } + + if (typeof contentUnion === 'object' && contentUnion !== null) { + if ('parts' in contentUnion) { + const content = contentUnion as Content; + return ( + content.parts + ?.map((part: Part) => { + if (typeof part === 'string') return part; + if ('text' in part) return part.text || ''; + return ''; + }) + .filter(Boolean) + .join('\n') || '' + ); + } + } + + return ''; + } + + private extractFunctionResponseContent(response: unknown): string { + if (response === null || response === undefined) { + return ''; + } + + if (typeof response === 'string') { + return response; + } + + if (typeof response === 'object') { + const responseObject = response as Record; + const output = responseObject['output']; + if (typeof output === 'string') { + return output; + } + + const error = responseObject['error']; + if (typeof error === 'string') { + return error; + } + } + + try { + const serialized = JSON.stringify(response); + return serialized ?? String(response); + } catch { + return String(response); + } + } + + private safeInputToArgs(input: unknown): Record { + if (input && typeof input === 'object') { + return input as Record; + } + if (typeof input === 'string') { + return safeJsonParse(input, {}); + } + return {}; + } + + mapAnthropicFinishReasonToGemini( + reason?: string | null, + ): FinishReason | undefined { + if (!reason) return undefined; + const mapping: Record = { + end_turn: FinishReason.STOP, + stop_sequence: FinishReason.STOP, + tool_use: FinishReason.STOP, + max_tokens: FinishReason.MAX_TOKENS, + content_filter: FinishReason.SAFETY, + }; + return mapping[reason] || FinishReason.FINISH_REASON_UNSPECIFIED; + } + + private isContentObject( + content: unknown, + ): content is { role: string; parts: Part[] } { + return ( + typeof content === 'object' && + content !== null && + 'role' in content && + 'parts' in content && + Array.isArray((content as Record)['parts']) + ); + } +} diff --git a/packages/core/src/core/anthropicContentGenerator/index.ts b/packages/core/src/core/anthropicContentGenerator/index.ts new file mode 100644 index 000000000..ce480790a --- /dev/null +++ b/packages/core/src/core/anthropicContentGenerator/index.ts @@ -0,0 +1,21 @@ +/** + * @license + * Copyright 2025 Qwen + * SPDX-License-Identifier: Apache-2.0 + */ + +import type { + ContentGenerator, + ContentGeneratorConfig, +} from '../contentGenerator.js'; +import type { Config } from '../../config/config.js'; +import { AnthropicContentGenerator } from './anthropicContentGenerator.js'; + +export { AnthropicContentGenerator } from './anthropicContentGenerator.js'; + +export function createAnthropicContentGenerator( + contentGeneratorConfig: ContentGeneratorConfig, + cliConfig: Config, +): ContentGenerator { + return new AnthropicContentGenerator(contentGeneratorConfig, cliConfig); +} diff --git a/packages/core/src/core/baseLlmClient.test.ts b/packages/core/src/core/baseLlmClient.test.ts index 999a6903d..3f2b71d15 100644 --- a/packages/core/src/core/baseLlmClient.test.ts +++ b/packages/core/src/core/baseLlmClient.test.ts @@ -146,13 +146,11 @@ describe('BaseLlmClient', () => { // Validate the parameters passed to the underlying generator expect(mockGenerateContent).toHaveBeenCalledTimes(1); expect(mockGenerateContent).toHaveBeenCalledWith( - { + expect.objectContaining({ model: 'test-model', contents: defaultOptions.contents, - config: { + config: expect.objectContaining({ abortSignal: defaultOptions.abortSignal, - temperature: 0, - topP: 1, tools: [ { functionDeclarations: [ @@ -164,9 +162,8 @@ describe('BaseLlmClient', () => { ], }, ], - // Crucial: systemInstruction should NOT be in the config object if not provided - }, - }, + }), + }), 'test-prompt-id', ); }); @@ -189,7 +186,6 @@ describe('BaseLlmClient', () => { expect.objectContaining({ config: expect.objectContaining({ temperature: 0.8, - topP: 1, // Default should remain if not overridden topK: 10, tools: expect.any(Array), }), diff --git a/packages/core/src/core/baseLlmClient.ts b/packages/core/src/core/baseLlmClient.ts index b8ce2a688..e97ce892f 100644 --- a/packages/core/src/core/baseLlmClient.ts +++ b/packages/core/src/core/baseLlmClient.ts @@ -64,12 +64,6 @@ export interface GenerateJsonOptions { * A client dedicated to stateless, utility-focused LLM calls. */ export class BaseLlmClient { - // Default configuration for utility tasks - private readonly defaultUtilityConfig: GenerateContentConfig = { - temperature: 0, - topP: 1, - }; - constructor( private readonly contentGenerator: ContentGenerator, private readonly config: Config, @@ -90,7 +84,6 @@ export class BaseLlmClient { const requestConfig: GenerateContentConfig = { abortSignal, - ...this.defaultUtilityConfig, ...options.config, ...(systemInstruction && { systemInstruction }), }; diff --git a/packages/core/src/core/client.test.ts b/packages/core/src/core/client.test.ts index 8adaf4f69..f069ce4d5 100644 --- a/packages/core/src/core/client.test.ts +++ b/packages/core/src/core/client.test.ts @@ -15,11 +15,7 @@ import { } from 'vitest'; import type { Content, GenerateContentResponse, Part } from '@google/genai'; -import { - isThinkingDefault, - isThinkingSupported, - GeminiClient, -} from './client.js'; +import { GeminiClient } from './client.js'; import { findCompressSplitPoint } from '../services/chatCompressionService.js'; import { AuthType, @@ -247,40 +243,6 @@ describe('findCompressSplitPoint', () => { }); }); -describe('isThinkingSupported', () => { - it('should return true for gemini-2.5', () => { - expect(isThinkingSupported('gemini-2.5')).toBe(true); - }); - - it('should return true for gemini-2.5-pro', () => { - expect(isThinkingSupported('gemini-2.5-pro')).toBe(true); - }); - - it('should return false for other models', () => { - expect(isThinkingSupported('gemini-1.5-flash')).toBe(false); - expect(isThinkingSupported('some-other-model')).toBe(false); - }); -}); - -describe('isThinkingDefault', () => { - it('should return false for gemini-2.5-flash-lite', () => { - expect(isThinkingDefault('gemini-2.5-flash-lite')).toBe(false); - }); - - it('should return true for gemini-2.5', () => { - expect(isThinkingDefault('gemini-2.5')).toBe(true); - }); - - it('should return true for gemini-2.5-pro', () => { - expect(isThinkingDefault('gemini-2.5-pro')).toBe(true); - }); - - it('should return false for other models', () => { - expect(isThinkingDefault('gemini-1.5-flash')).toBe(false); - expect(isThinkingDefault('some-other-model')).toBe(false); - }); -}); - describe('Gemini Client (client.ts)', () => { let mockContentGenerator: ContentGenerator; let mockConfig: Config; @@ -2304,16 +2266,15 @@ ${JSON.stringify( ); expect(mockContentGenerator.generateContent).toHaveBeenCalledWith( - { + expect.objectContaining({ model: DEFAULT_GEMINI_FLASH_MODEL, - config: { + config: expect.objectContaining({ abortSignal, systemInstruction: getCoreSystemPrompt(''), temperature: 0.5, - topP: 1, - }, + }), contents, - }, + }), 'test-session-id', ); }); diff --git a/packages/core/src/core/client.ts b/packages/core/src/core/client.ts index 6e3be2098..6c62478d0 100644 --- a/packages/core/src/core/client.ts +++ b/packages/core/src/core/client.ts @@ -15,11 +15,7 @@ import type { // Config import { ApprovalMode, type Config } from '../config/config.js'; -import { - DEFAULT_GEMINI_FLASH_MODEL, - DEFAULT_GEMINI_MODEL_AUTO, - DEFAULT_THINKING_MODE, -} from '../config/models.js'; +import { DEFAULT_GEMINI_FLASH_MODEL } from '../config/models.js'; // Core modules import type { ContentGenerator } from './contentGenerator.js'; @@ -78,25 +74,10 @@ import { type File, type IdeContext } from '../ide/types.js'; // Fallback handling import { handleFallback } from '../fallback/handler.js'; -export function isThinkingSupported(model: string) { - return model.startsWith('gemini-2.5') || model === DEFAULT_GEMINI_MODEL_AUTO; -} - -export function isThinkingDefault(model: string) { - if (model.startsWith('gemini-2.5-flash-lite')) { - return false; - } - return model.startsWith('gemini-2.5') || model === DEFAULT_GEMINI_MODEL_AUTO; -} - const MAX_TURNS = 100; export class GeminiClient { private chat?: GeminiChat; - private readonly generateContentConfig: GenerateContentConfig = { - temperature: 0, - topP: 1, - }; private sessionTurnCount = 0; private readonly loopDetector: LoopDetectionService; @@ -208,20 +189,10 @@ export class GeminiClient { const model = this.config.getModel(); const systemInstruction = getCoreSystemPrompt(userMemory, model); - const config: GenerateContentConfig = { ...this.generateContentConfig }; - - if (isThinkingSupported(model)) { - config.thinkingConfig = { - includeThoughts: true, - thinkingBudget: DEFAULT_THINKING_MODE, - }; - } - return new GeminiChat( this.config, { systemInstruction, - ...config, tools, }, history, @@ -618,11 +589,6 @@ export class GeminiClient { ): Promise { let currentAttemptModel: string = model; - const configToUse: GenerateContentConfig = { - ...this.generateContentConfig, - ...generationConfig, - }; - try { const userMemory = this.config.getUserMemory(); const finalSystemInstruction = generationConfig.systemInstruction @@ -631,7 +597,7 @@ export class GeminiClient { const requestConfig: GenerateContentConfig = { abortSignal, - ...configToUse, + ...generationConfig, systemInstruction: finalSystemInstruction, }; @@ -672,7 +638,7 @@ export class GeminiClient { `Error generating content via API with model ${currentAttemptModel}.`, { requestContents: contents, - requestConfig: configToUse, + requestConfig: generationConfig, }, 'generateContent-api', ); diff --git a/packages/core/src/core/contentGenerator.test.ts b/packages/core/src/core/contentGenerator.test.ts index 729481c01..4b176c989 100644 --- a/packages/core/src/core/contentGenerator.test.ts +++ b/packages/core/src/core/contentGenerator.test.ts @@ -5,42 +5,19 @@ */ import { describe, it, expect, vi } from 'vitest'; -import type { ContentGenerator } from './contentGenerator.js'; import { createContentGenerator, AuthType } from './contentGenerator.js'; -import { createCodeAssistContentGenerator } from '../code_assist/codeAssist.js'; import { GoogleGenAI } from '@google/genai'; import type { Config } from '../config/config.js'; -import { LoggingContentGenerator } from './loggingContentGenerator.js'; +import { LoggingContentGenerator } from './loggingContentGenerator/index.js'; -vi.mock('../code_assist/codeAssist.js'); vi.mock('@google/genai'); -const mockConfig = { - getCliVersion: vi.fn().mockReturnValue('1.0.0'), -} as unknown as Config; - describe('createContentGenerator', () => { - it('should create a CodeAssistContentGenerator', async () => { - const mockGenerator = {} as unknown as ContentGenerator; - vi.mocked(createCodeAssistContentGenerator).mockResolvedValue( - mockGenerator as never, - ); - const generator = await createContentGenerator( - { - model: 'test-model', - authType: AuthType.LOGIN_WITH_GOOGLE, - }, - mockConfig, - ); - expect(createCodeAssistContentGenerator).toHaveBeenCalled(); - expect(generator).toEqual( - new LoggingContentGenerator(mockGenerator, mockConfig), - ); - }); - - it('should create a GoogleGenAI content generator', async () => { + it('should create a Gemini content generator', async () => { const mockConfig = { getUsageStatisticsEnabled: () => true, + getContentGeneratorConfig: () => ({}), + getCliVersion: () => '1.0.0', } as unknown as Config; const mockGenerator = { @@ -65,17 +42,17 @@ describe('createContentGenerator', () => { }, }, }); - expect(generator).toEqual( - new LoggingContentGenerator( - (mockGenerator as GoogleGenAI).models, - mockConfig, - ), - ); + // We expect it to be a LoggingContentGenerator wrapping a GeminiContentGenerator + expect(generator).toBeInstanceOf(LoggingContentGenerator); + const wrapped = (generator as LoggingContentGenerator).getWrapped(); + expect(wrapped).toBeDefined(); }); - it('should create a GoogleGenAI content generator with client install id logging disabled', async () => { + it('should create a Gemini content generator with client install id logging disabled', async () => { const mockConfig = { getUsageStatisticsEnabled: () => false, + getContentGeneratorConfig: () => ({}), + getCliVersion: () => '1.0.0', } as unknown as Config; const mockGenerator = { models: {}, @@ -98,11 +75,6 @@ describe('createContentGenerator', () => { }, }, }); - expect(generator).toEqual( - new LoggingContentGenerator( - (mockGenerator as GoogleGenAI).models, - mockConfig, - ), - ); + expect(generator).toBeInstanceOf(LoggingContentGenerator); }); }); diff --git a/packages/core/src/core/contentGenerator.ts b/packages/core/src/core/contentGenerator.ts index 6b480d423..f6f83761f 100644 --- a/packages/core/src/core/contentGenerator.ts +++ b/packages/core/src/core/contentGenerator.ts @@ -12,14 +12,9 @@ import type { GenerateContentParameters, GenerateContentResponse, } from '@google/genai'; -import { GoogleGenAI } from '@google/genai'; -import { createCodeAssistContentGenerator } from '../code_assist/codeAssist.js'; import { DEFAULT_QWEN_MODEL } from '../config/models.js'; import type { Config } from '../config/config.js'; - -import type { UserTierId } from '../code_assist/types.js'; -import { InstallationManager } from '../utils/installationManager.js'; -import { LoggingContentGenerator } from './loggingContentGenerator.js'; +import { LoggingContentGenerator } from './loggingContentGenerator/index.js'; /** * Interface abstracting the core functionalities for generating content and counting tokens. @@ -39,16 +34,15 @@ export interface ContentGenerator { embedContent(request: EmbedContentParameters): Promise; - userTier?: UserTierId; + useSummarizedThinking(): boolean; } export enum AuthType { - LOGIN_WITH_GOOGLE = 'oauth-personal', - USE_GEMINI = 'gemini-api-key', - USE_VERTEX_AI = 'vertex-ai', - CLOUD_SHELL = 'cloud-shell', USE_OPENAI = 'openai', QWEN_OAUTH = 'qwen-oauth', + USE_GEMINI = 'gemini', + USE_VERTEX_AI = 'vertex-ai', + USE_ANTHROPIC = 'anthropic', } export type ContentGeneratorConfig = { @@ -59,12 +53,9 @@ export type ContentGeneratorConfig = { authType?: AuthType | undefined; enableOpenAILogging?: boolean; openAILoggingDir?: string; - // Timeout configuration in milliseconds - timeout?: number; - // Maximum retries for failed requests - maxRetries?: number; - // Disable cache control for DashScope providers - disableCacheControl?: boolean; + timeout?: number; // Timeout configuration in milliseconds + maxRetries?: number; // Maximum retries for failed requests + disableCacheControl?: boolean; // Disable cache control for DashScope providers samplingParams?: { top_p?: number; top_k?: number; @@ -74,8 +65,16 @@ export type ContentGeneratorConfig = { temperature?: number; max_tokens?: number; }; + reasoning?: + | false + | { + effort?: 'low' | 'medium' | 'high'; + budget_tokens?: number; + }; proxy?: string | undefined; userAgent?: string; + // Schema compliance mode for tool definitions + schemaCompliance?: 'auto' | 'openapi_30'; }; export function createContentGeneratorConfig( @@ -83,7 +82,7 @@ export function createContentGeneratorConfig( authType: AuthType | undefined, generationConfig?: Partial, ): ContentGeneratorConfig { - const newContentGeneratorConfig: Partial = { + let newContentGeneratorConfig: Partial = { ...(generationConfig || {}), authType, proxy: config?.getProxy(), @@ -100,8 +99,16 @@ export function createContentGeneratorConfig( } if (authType === AuthType.USE_OPENAI) { + newContentGeneratorConfig = { + ...newContentGeneratorConfig, + apiKey: newContentGeneratorConfig.apiKey || process.env['OPENAI_API_KEY'], + baseUrl: + newContentGeneratorConfig.baseUrl || process.env['OPENAI_BASE_URL'], + model: newContentGeneratorConfig.model || process.env['OPENAI_MODEL'], + }; + if (!newContentGeneratorConfig.apiKey) { - throw new Error('OpenAI API key is required'); + throw new Error('OPENAI_API_KEY environment variable not found.'); } return { @@ -110,10 +117,62 @@ export function createContentGeneratorConfig( } as ContentGeneratorConfig; } - return { - ...newContentGeneratorConfig, - model: newContentGeneratorConfig?.model || DEFAULT_QWEN_MODEL, - } as ContentGeneratorConfig; + if (authType === AuthType.USE_ANTHROPIC) { + newContentGeneratorConfig = { + ...newContentGeneratorConfig, + apiKey: + newContentGeneratorConfig.apiKey || process.env['ANTHROPIC_API_KEY'], + baseUrl: + newContentGeneratorConfig.baseUrl || process.env['ANTHROPIC_BASE_URL'], + model: newContentGeneratorConfig.model || process.env['ANTHROPIC_MODEL'], + }; + + if (!newContentGeneratorConfig.apiKey) { + throw new Error('ANTHROPIC_API_KEY environment variable not found.'); + } + + if (!newContentGeneratorConfig.baseUrl) { + throw new Error('ANTHROPIC_BASE_URL environment variable not found.'); + } + + if (!newContentGeneratorConfig.model) { + throw new Error('ANTHROPIC_MODEL environment variable not found.'); + } + } + + if (authType === AuthType.USE_GEMINI) { + newContentGeneratorConfig = { + ...newContentGeneratorConfig, + apiKey: newContentGeneratorConfig.apiKey || process.env['GEMINI_API_KEY'], + model: newContentGeneratorConfig.model || process.env['GEMINI_MODEL'], + }; + + if (!newContentGeneratorConfig.apiKey) { + throw new Error('GEMINI_API_KEY environment variable not found.'); + } + + if (!newContentGeneratorConfig.model) { + throw new Error('GEMINI_MODEL environment variable not found.'); + } + } + + if (authType === AuthType.USE_VERTEX_AI) { + newContentGeneratorConfig = { + ...newContentGeneratorConfig, + apiKey: newContentGeneratorConfig.apiKey || process.env['GOOGLE_API_KEY'], + model: newContentGeneratorConfig.model || process.env['GOOGLE_MODEL'], + }; + + if (!newContentGeneratorConfig.apiKey) { + throw new Error('GOOGLE_API_KEY environment variable not found.'); + } + + if (!newContentGeneratorConfig.model) { + throw new Error('GOOGLE_MODEL environment variable not found.'); + } + } + + return newContentGeneratorConfig as ContentGeneratorConfig; } export async function createContentGenerator( @@ -121,53 +180,9 @@ export async function createContentGenerator( gcConfig: Config, isInitialAuth?: boolean, ): Promise { - const version = process.env['CLI_VERSION'] || process.version; - const userAgent = `QwenCode/${version} (${process.platform}; ${process.arch})`; - const baseHeaders: Record = { - 'User-Agent': userAgent, - }; - - if ( - config.authType === AuthType.LOGIN_WITH_GOOGLE || - config.authType === AuthType.CLOUD_SHELL - ) { - const httpOptions = { headers: baseHeaders }; - return new LoggingContentGenerator( - await createCodeAssistContentGenerator( - httpOptions, - config.authType, - gcConfig, - ), - gcConfig, - ); - } - - if ( - config.authType === AuthType.USE_GEMINI || - config.authType === AuthType.USE_VERTEX_AI - ) { - let headers: Record = { ...baseHeaders }; - if (gcConfig?.getUsageStatisticsEnabled()) { - const installationManager = new InstallationManager(); - const installationId = installationManager.getInstallationId(); - headers = { - ...headers, - 'x-gemini-api-privileged-user-id': `${installationId}`, - }; - } - const httpOptions = { headers }; - - const googleGenAI = new GoogleGenAI({ - apiKey: config.apiKey === '' ? undefined : config.apiKey, - vertexai: config.vertexai, - httpOptions, - }); - return new LoggingContentGenerator(googleGenAI.models, gcConfig); - } - if (config.authType === AuthType.USE_OPENAI) { if (!config.apiKey) { - throw new Error('OpenAI API key is required'); + throw new Error('OPENAI_API_KEY environment variable not found.'); } // Import OpenAIContentGenerator dynamically to avoid circular dependencies @@ -176,7 +191,8 @@ export async function createContentGenerator( ); // Always use OpenAIContentGenerator, logging is controlled by enableOpenAILogging flag - return createOpenAIContentGenerator(config, gcConfig); + const generator = createOpenAIContentGenerator(config, gcConfig); + return new LoggingContentGenerator(generator, gcConfig); } if (config.authType === AuthType.QWEN_OAUTH) { @@ -197,7 +213,8 @@ export async function createContentGenerator( ); // Create the content generator with dynamic token management - return new QwenContentGenerator(qwenClient, config, gcConfig); + const generator = new QwenContentGenerator(qwenClient, config, gcConfig); + return new LoggingContentGenerator(generator, gcConfig); } catch (error) { throw new Error( `${error instanceof Error ? error.message : String(error)}`, @@ -205,6 +222,30 @@ export async function createContentGenerator( } } + if (config.authType === AuthType.USE_ANTHROPIC) { + if (!config.apiKey) { + throw new Error('ANTHROPIC_API_KEY environment variable not found.'); + } + + const { createAnthropicContentGenerator } = await import( + './anthropicContentGenerator/index.js' + ); + + const generator = createAnthropicContentGenerator(config, gcConfig); + return new LoggingContentGenerator(generator, gcConfig); + } + + if ( + config.authType === AuthType.USE_GEMINI || + config.authType === AuthType.USE_VERTEX_AI + ) { + const { createGeminiContentGenerator } = await import( + './geminiContentGenerator/index.js' + ); + const generator = createGeminiContentGenerator(config, gcConfig); + return new LoggingContentGenerator(generator, gcConfig); + } + throw new Error( `Error creating contentGenerator: Unsupported authType: ${config.authType}`, ); diff --git a/packages/core/src/core/coreToolScheduler.test.ts b/packages/core/src/core/coreToolScheduler.test.ts index 26a1b29c6..bd970b9da 100644 --- a/packages/core/src/core/coreToolScheduler.test.ts +++ b/packages/core/src/core/coreToolScheduler.test.ts @@ -240,7 +240,7 @@ describe('CoreToolScheduler', () => { getAllowedTools: () => [], getContentGeneratorConfig: () => ({ model: 'test-model', - authType: 'oauth-personal', + authType: 'gemini-api-key', }), getShellExecutionConfig: () => ({ terminalWidth: 90, @@ -318,7 +318,7 @@ describe('CoreToolScheduler', () => { getAllowedTools: () => [], getContentGeneratorConfig: () => ({ model: 'test-model', - authType: 'oauth-personal', + authType: 'gemini-api-key', }), getShellExecutionConfig: () => ({ terminalWidth: 90, @@ -497,7 +497,7 @@ describe('CoreToolScheduler', () => { getExcludeTools: () => ['write_file', 'edit', 'run_shell_command'], getContentGeneratorConfig: () => ({ model: 'test-model', - authType: 'oauth-personal', + authType: 'gemini-api-key', }), getShellExecutionConfig: () => ({ terminalWidth: 90, @@ -584,7 +584,7 @@ describe('CoreToolScheduler', () => { getExcludeTools: () => ['write_file', 'edit'], // Different excluded tools getContentGeneratorConfig: () => ({ model: 'test-model', - authType: 'oauth-personal', + authType: 'gemini-api-key', }), getShellExecutionConfig: () => ({ terminalWidth: 90, @@ -674,7 +674,7 @@ describe('CoreToolScheduler with payload', () => { getAllowedTools: () => [], getContentGeneratorConfig: () => ({ model: 'test-model', - authType: 'oauth-personal', + authType: 'gemini-api-key', }), getShellExecutionConfig: () => ({ terminalWidth: 90, @@ -1001,7 +1001,7 @@ describe('CoreToolScheduler edit cancellation', () => { getAllowedTools: () => [], getContentGeneratorConfig: () => ({ model: 'test-model', - authType: 'oauth-personal', + authType: 'gemini-api-key', }), getShellExecutionConfig: () => ({ terminalWidth: 90, @@ -1108,7 +1108,7 @@ describe('CoreToolScheduler YOLO mode', () => { getAllowedTools: () => [], getContentGeneratorConfig: () => ({ model: 'test-model', - authType: 'oauth-personal', + authType: 'gemini-api-key', }), getShellExecutionConfig: () => ({ terminalWidth: 90, @@ -1258,7 +1258,7 @@ describe('CoreToolScheduler cancellation during executing with live output', () getApprovalMode: () => ApprovalMode.DEFAULT, getContentGeneratorConfig: () => ({ model: 'test-model', - authType: 'oauth-personal', + authType: 'gemini-api-key', }), getToolRegistry: () => mockToolRegistry, getShellExecutionConfig: () => ({ @@ -1350,7 +1350,7 @@ describe('CoreToolScheduler request queueing', () => { getAllowedTools: () => [], getContentGeneratorConfig: () => ({ model: 'test-model', - authType: 'oauth-personal', + authType: 'gemini-api-key', }), getShellExecutionConfig: () => ({ terminalWidth: 90, @@ -1482,7 +1482,7 @@ describe('CoreToolScheduler request queueing', () => { getToolRegistry: () => toolRegistry, getContentGeneratorConfig: () => ({ model: 'test-model', - authType: 'oauth-personal', + authType: 'gemini-api-key', }), getShellExecutionConfig: () => ({ terminalWidth: 80, @@ -1586,7 +1586,7 @@ describe('CoreToolScheduler request queueing', () => { getAllowedTools: () => [], getContentGeneratorConfig: () => ({ model: 'test-model', - authType: 'oauth-personal', + authType: 'gemini-api-key', }), getShellExecutionConfig: () => ({ terminalWidth: 90, @@ -1854,7 +1854,7 @@ describe('CoreToolScheduler Sequential Execution', () => { getAllowedTools: () => [], getContentGeneratorConfig: () => ({ model: 'test-model', - authType: 'oauth-personal', + authType: 'gemini-api-key', }), getShellExecutionConfig: () => ({ terminalWidth: 90, @@ -1975,7 +1975,7 @@ describe('CoreToolScheduler Sequential Execution', () => { getAllowedTools: () => [], getContentGeneratorConfig: () => ({ model: 'test-model', - authType: 'oauth-personal', + authType: 'gemini-api-key', }), getShellExecutionConfig: () => ({ terminalWidth: 90, diff --git a/packages/core/src/core/geminiChat.test.ts b/packages/core/src/core/geminiChat.test.ts index 5aaa814f3..39b732cd9 100644 --- a/packages/core/src/core/geminiChat.test.ts +++ b/packages/core/src/core/geminiChat.test.ts @@ -100,6 +100,7 @@ describe('GeminiChat', () => { countTokens: vi.fn(), embedContent: vi.fn(), batchEmbedContents: vi.fn(), + useSummarizedThinking: vi.fn().mockReturnValue(false), } as unknown as ContentGenerator; mockHandleFallback.mockClear(); @@ -111,7 +112,7 @@ describe('GeminiChat', () => { getUsageStatisticsEnabled: () => true, getDebugMode: () => false, getContentGeneratorConfig: vi.fn().mockReturnValue({ - authType: 'oauth-personal', // Ensure this is set for fallback tests + authType: 'gemini-api-key', // Ensure this is set for fallback tests model: 'test-model', }), getModel: vi.fn().mockReturnValue('gemini-pro'), @@ -718,6 +719,39 @@ describe('GeminiChat', () => { 1, ); }); + + it('should keep parts with thoughtSignature when consolidating history', async () => { + const stream = (async function* () { + yield { + candidates: [ + { + content: { + role: 'model', + parts: [ + { + text: 'p1', + thoughtSignature: 's1', + } as unknown as { text: string; thoughtSignature: string }, + ], + }, + finishReason: 'STOP', + }, + ], + } as unknown as GenerateContentResponse; + })(); + vi.mocked(mockContentGenerator.generateContentStream).mockResolvedValue( + stream, + ); + + const res = await chat.sendMessageStream('m1', { message: 'h1' }, 'p1'); + for await (const _ of res); + + const history = chat.getHistory(); + expect(history[1].parts![0]).toEqual({ + text: 'p1', + thoughtSignature: 's1', + }); + }); }); describe('addHistory', () => { @@ -1382,7 +1416,7 @@ describe('GeminiChat', () => { }); it('should call handleFallback with the specific failed model and retry if handler returns true', async () => { - const authType = AuthType.LOGIN_WITH_GOOGLE; + const authType = AuthType.USE_GEMINI; vi.mocked(mockConfig.getContentGeneratorConfig).mockReturnValue({ model: 'test-model', authType, @@ -1532,7 +1566,7 @@ describe('GeminiChat', () => { }); describe('stripThoughtsFromHistory', () => { - it('should strip thought signatures', () => { + it('should strip thoughts and thought signatures, and remove empty content objects', () => { chat.setHistory([ { role: 'user', @@ -1544,10 +1578,15 @@ describe('GeminiChat', () => { { text: 'thinking...', thought: true }, { text: 'hi' }, { - functionCall: { name: 'test', args: {} }, - }, + text: 'hidden metadata', + thoughtSignature: 'abc', + } as unknown as { text: string; thoughtSignature: string }, ], }, + { + role: 'model', + parts: [{ text: 'only thinking', thought: true }], + }, ]); chat.stripThoughtsFromHistory(); @@ -1559,7 +1598,7 @@ describe('GeminiChat', () => { }, { role: 'model', - parts: [{ text: 'hi' }, { functionCall: { name: 'test', args: {} } }], + parts: [{ text: 'hi' }, { text: 'hidden metadata' }], }, ]); }); diff --git a/packages/core/src/core/geminiChat.ts b/packages/core/src/core/geminiChat.ts index e9e4fcc28..04add3419 100644 --- a/packages/core/src/core/geminiChat.ts +++ b/packages/core/src/core/geminiChat.ts @@ -92,6 +92,7 @@ export function isValidNonThoughtTextPart(part: Part): boolean { return ( typeof part.text === 'string' && !part.thought && + !part.thoughtSignature && // Technically, the model should never generate parts that have text and // any of these but we don't trust them so check anyways. !part.functionCall && @@ -109,18 +110,24 @@ function isValidContent(content: Content): boolean { if (part === undefined || Object.keys(part).length === 0) { return false; } - if ( - !part.thought && - part.text !== undefined && - part.text === '' && - part.functionCall === undefined - ) { + if (!isValidContentPart(part)) { return false; } } return true; } +function isValidContentPart(part: Part): boolean { + const isInvalid = + !part.thought && + !part.thoughtSignature && + part.text !== undefined && + part.text === '' && + part.functionCall === undefined; + + return !isInvalid; +} + /** * Validates the history contains the correct roles. * @@ -448,15 +455,29 @@ export class GeminiChat { if (!content.parts) return content; // Filter out thought parts entirely - const filteredParts = content.parts.filter( - (part) => - !( + const filteredParts = content.parts + .filter( + (part) => + !( + part && + typeof part === 'object' && + 'thought' in part && + part.thought + ), + ) + .map((part) => { + if ( part && typeof part === 'object' && - 'thought' in part && - part.thought - ), - ); + 'thoughtSignature' in part + ) { + const newPart = { ...part }; + delete (newPart as { thoughtSignature?: string }) + .thoughtSignature; + return newPart; + } + return part; + }); return { ...content, @@ -538,12 +559,27 @@ export class GeminiChat { yield chunk; // Yield every chunk to the UI immediately. } - const thoughtParts = allModelParts.filter((part) => part.thought); - const thoughtText = thoughtParts + let thoughtContentPart: Part | undefined; + const thoughtText = allModelParts + .filter((part) => part.thought) .map((part) => part.text) .join('') .trim(); + if (thoughtText !== '') { + thoughtContentPart = { + text: thoughtText, + thought: true, + }; + + const thoughtSignature = allModelParts.filter( + (part) => part.thoughtSignature && part.thought, + )?.[0]?.thoughtSignature; + if (thoughtContentPart && thoughtSignature) { + thoughtContentPart.thoughtSignature = thoughtSignature; + } + } + const contentParts = allModelParts.filter((part) => !part.thought); const consolidatedHistoryParts: Part[] = []; for (const part of contentParts) { @@ -555,7 +591,7 @@ export class GeminiChat { isValidNonThoughtTextPart(part) ) { lastPart.text += part.text; - } else { + } else if (isValidContentPart(part)) { consolidatedHistoryParts.push(part); } } @@ -567,11 +603,11 @@ export class GeminiChat { .trim(); // Record assistant turn with raw Content and metadata - if (thoughtText || contentText || hasToolCall || usageMetadata) { + if (thoughtContentPart || contentText || hasToolCall || usageMetadata) { this.chatRecordingService?.recordAssistantTurn({ model, message: [ - ...(thoughtText ? [{ text: thoughtText, thought: true }] : []), + ...(thoughtContentPart ? [thoughtContentPart] : []), ...(contentText ? [{ text: contentText }] : []), ...(hasToolCall ? contentParts @@ -607,7 +643,7 @@ export class GeminiChat { this.history.push({ role: 'model', parts: [ - ...(thoughtText ? [{ text: thoughtText, thought: true }] : []), + ...(thoughtContentPart ? [thoughtContentPart] : []), ...consolidatedHistoryParts, ], }); diff --git a/packages/core/src/core/geminiContentGenerator/geminiContentGenerator.test.ts b/packages/core/src/core/geminiContentGenerator/geminiContentGenerator.test.ts new file mode 100644 index 000000000..82f3b186e --- /dev/null +++ b/packages/core/src/core/geminiContentGenerator/geminiContentGenerator.test.ts @@ -0,0 +1,173 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { GeminiContentGenerator } from './geminiContentGenerator.js'; +import { GoogleGenAI } from '@google/genai'; + +vi.mock('@google/genai', () => { + const mockGenerateContent = vi.fn(); + const mockGenerateContentStream = vi.fn(); + const mockCountTokens = vi.fn(); + const mockEmbedContent = vi.fn(); + + return { + GoogleGenAI: vi.fn().mockImplementation(() => ({ + models: { + generateContent: mockGenerateContent, + generateContentStream: mockGenerateContentStream, + countTokens: mockCountTokens, + embedContent: mockEmbedContent, + }, + })), + }; +}); + +describe('GeminiContentGenerator', () => { + let generator: GeminiContentGenerator; + // eslint-disable-next-line @typescript-eslint/no-explicit-any + let mockGoogleGenAI: any; + + beforeEach(() => { + vi.clearAllMocks(); + generator = new GeminiContentGenerator({ + apiKey: 'test-api-key', + }); + mockGoogleGenAI = vi.mocked(GoogleGenAI).mock.results[0].value; + }); + + it('should call generateContent on the underlying model', async () => { + const request = { model: 'gemini-1.5-flash', contents: [] }; + const expectedResponse = { responseId: 'test-id' }; + mockGoogleGenAI.models.generateContent.mockResolvedValue(expectedResponse); + + const response = await generator.generateContent(request, 'prompt-id'); + + expect(mockGoogleGenAI.models.generateContent).toHaveBeenCalledWith( + expect.objectContaining({ + ...request, + config: expect.objectContaining({ + temperature: 1, + topP: 0.95, + thinkingConfig: { + includeThoughts: true, + thinkingLevel: 'THINKING_LEVEL_UNSPECIFIED', + }, + }), + }), + ); + expect(response).toBe(expectedResponse); + }); + + it('should call generateContentStream on the underlying model', async () => { + const request = { model: 'gemini-1.5-flash', contents: [] }; + const mockStream = (async function* () { + yield { responseId: '1' }; + })(); + mockGoogleGenAI.models.generateContentStream.mockResolvedValue(mockStream); + + const stream = await generator.generateContentStream(request, 'prompt-id'); + + expect(mockGoogleGenAI.models.generateContentStream).toHaveBeenCalledWith( + expect.objectContaining({ + ...request, + config: expect.objectContaining({ + temperature: 1, + topP: 0.95, + thinkingConfig: { + includeThoughts: true, + thinkingLevel: 'THINKING_LEVEL_UNSPECIFIED', + }, + }), + }), + ); + expect(stream).toBe(mockStream); + }); + + it('should call countTokens on the underlying model', async () => { + const request = { model: 'gemini-1.5-flash', contents: [] }; + const expectedResponse = { totalTokens: 10 }; + mockGoogleGenAI.models.countTokens.mockResolvedValue(expectedResponse); + + const response = await generator.countTokens(request); + + expect(mockGoogleGenAI.models.countTokens).toHaveBeenCalledWith(request); + expect(response).toBe(expectedResponse); + }); + + it('should call embedContent on the underlying model', async () => { + const request = { model: 'embedding-model', contents: [] }; + const expectedResponse = { embeddings: [] }; + mockGoogleGenAI.models.embedContent.mockResolvedValue(expectedResponse); + + const response = await generator.embedContent(request); + + expect(mockGoogleGenAI.models.embedContent).toHaveBeenCalledWith(request); + expect(response).toBe(expectedResponse); + }); + + it('should prioritize contentGeneratorConfig samplingParams over request config', async () => { + const generatorWithParams = new GeminiContentGenerator({ apiKey: 'test' }, { + model: 'gemini-1.5-flash', + samplingParams: { + temperature: 0.1, + top_p: 0.2, + }, + // eslint-disable-next-line @typescript-eslint/no-explicit-any + } as any); + + const request = { + model: 'gemini-1.5-flash', + contents: [], + config: { + temperature: 0.9, + topP: 0.9, + }, + }; + + await generatorWithParams.generateContent(request, 'prompt-id'); + + expect(mockGoogleGenAI.models.generateContent).toHaveBeenCalledWith( + expect.objectContaining({ + config: expect.objectContaining({ + temperature: 0.1, + topP: 0.2, + }), + }), + ); + }); + + it('should map reasoning effort to thinkingConfig', async () => { + const generatorWithReasoning = new GeminiContentGenerator( + { apiKey: 'test' }, + { + model: 'gemini-2.5-pro', + reasoning: { + effort: 'high', + }, + // eslint-disable-next-line @typescript-eslint/no-explicit-any + } as any, + ); + + const request = { + model: 'gemini-2.5-pro', + contents: [], + }; + + await generatorWithReasoning.generateContent(request, 'prompt-id'); + + expect(mockGoogleGenAI.models.generateContent).toHaveBeenCalledWith( + expect.objectContaining({ + config: expect.objectContaining({ + thinkingConfig: { + includeThoughts: true, + thinkingLevel: 'HIGH', + }, + }), + }), + ); + }); +}); diff --git a/packages/core/src/core/geminiContentGenerator/geminiContentGenerator.ts b/packages/core/src/core/geminiContentGenerator/geminiContentGenerator.ts new file mode 100644 index 000000000..0008b8eb5 --- /dev/null +++ b/packages/core/src/core/geminiContentGenerator/geminiContentGenerator.ts @@ -0,0 +1,161 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import type { + CountTokensParameters, + CountTokensResponse, + EmbedContentParameters, + EmbedContentResponse, + GenerateContentParameters, + GenerateContentResponse, + GenerateContentConfig, + ThinkingLevel, +} from '@google/genai'; +import { GoogleGenAI } from '@google/genai'; +import type { + ContentGenerator, + ContentGeneratorConfig, +} from '../contentGenerator.js'; + +/** + * A wrapper for GoogleGenAI that implements the ContentGenerator interface. + */ +export class GeminiContentGenerator implements ContentGenerator { + private readonly googleGenAI: GoogleGenAI; + private readonly contentGeneratorConfig?: ContentGeneratorConfig; + + constructor( + options: { + apiKey?: string; + vertexai?: boolean; + httpOptions?: { headers: Record }; + }, + contentGeneratorConfig?: ContentGeneratorConfig, + ) { + this.googleGenAI = new GoogleGenAI(options); + this.contentGeneratorConfig = contentGeneratorConfig; + } + + private buildGenerateContentConfig( + request: GenerateContentParameters, + ): GenerateContentConfig { + const configSamplingParams = this.contentGeneratorConfig?.samplingParams; + const requestConfig = request.config || {}; + + // Helper function to get parameter value with priority: config > request > default + const getParameterValue = ( + configValue: T | undefined, + requestKey: keyof GenerateContentConfig, + defaultValue?: T, + ): T | undefined => { + const requestValue = requestConfig[requestKey] as T | undefined; + + if (configValue !== undefined) return configValue; + if (requestValue !== undefined) return requestValue; + return defaultValue; + }; + + return { + ...requestConfig, + temperature: getParameterValue( + configSamplingParams?.temperature, + 'temperature', + 1, + ), + topP: getParameterValue( + configSamplingParams?.top_p, + 'topP', + 0.95, + ), + topK: getParameterValue(configSamplingParams?.top_k, 'topK', 64), + maxOutputTokens: getParameterValue( + configSamplingParams?.max_tokens, + 'maxOutputTokens', + ), + presencePenalty: getParameterValue( + configSamplingParams?.presence_penalty, + 'presencePenalty', + ), + frequencyPenalty: getParameterValue( + configSamplingParams?.frequency_penalty, + 'frequencyPenalty', + ), + thinkingConfig: getParameterValue( + this.buildThinkingConfig(), + 'thinkingConfig', + { + includeThoughts: true, + thinkingLevel: 'THINKING_LEVEL_UNSPECIFIED' as ThinkingLevel, + }, + ), + }; + } + + private buildThinkingConfig(): + | { includeThoughts: boolean; thinkingLevel?: ThinkingLevel } + | undefined { + const reasoning = this.contentGeneratorConfig?.reasoning; + + if (reasoning === false) { + return { includeThoughts: false }; + } + + if (reasoning) { + const thinkingLevel = ( + reasoning.effort === 'low' + ? 'LOW' + : reasoning.effort === 'high' + ? 'HIGH' + : 'THINKING_LEVEL_UNSPECIFIED' + ) as ThinkingLevel; + + return { + includeThoughts: true, + thinkingLevel, + }; + } + + return undefined; + } + + async generateContent( + request: GenerateContentParameters, + _userPromptId: string, + ): Promise { + const finalRequest = { + ...request, + config: this.buildGenerateContentConfig(request), + }; + return this.googleGenAI.models.generateContent(finalRequest); + } + + async generateContentStream( + request: GenerateContentParameters, + _userPromptId: string, + ): Promise> { + const finalRequest = { + ...request, + config: this.buildGenerateContentConfig(request), + }; + return this.googleGenAI.models.generateContentStream(finalRequest); + } + + async countTokens( + request: CountTokensParameters, + ): Promise { + return this.googleGenAI.models.countTokens(request); + } + + async embedContent( + request: EmbedContentParameters, + ): Promise { + return this.googleGenAI.models.embedContent(request); + } + + useSummarizedThinking(): boolean { + return true; + } +} diff --git a/packages/core/src/core/geminiContentGenerator/index.test.ts b/packages/core/src/core/geminiContentGenerator/index.test.ts new file mode 100644 index 000000000..c7effd220 --- /dev/null +++ b/packages/core/src/core/geminiContentGenerator/index.test.ts @@ -0,0 +1,41 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { createGeminiContentGenerator } from './index.js'; +import { GeminiContentGenerator } from './geminiContentGenerator.js'; +import type { Config } from '../../config/config.js'; +import { AuthType } from '../contentGenerator.js'; + +vi.mock('./geminiContentGenerator.js', () => ({ + GeminiContentGenerator: vi.fn().mockImplementation(() => ({})), +})); + +describe('createGeminiContentGenerator', () => { + let mockConfig: Config; + + beforeEach(() => { + vi.clearAllMocks(); + mockConfig = { + getUsageStatisticsEnabled: vi.fn().mockReturnValue(false), + getContentGeneratorConfig: vi.fn().mockReturnValue({}), + getCliVersion: vi.fn().mockReturnValue('1.0.0'), + } as unknown as Config; + }); + + it('should create a GeminiContentGenerator', () => { + const config = { + model: 'gemini-1.5-flash', + apiKey: 'test-key', + authType: AuthType.USE_GEMINI, + }; + + const generator = createGeminiContentGenerator(config, mockConfig); + + expect(GeminiContentGenerator).toHaveBeenCalled(); + expect(generator).toBeDefined(); + }); +}); diff --git a/packages/core/src/core/geminiContentGenerator/index.ts b/packages/core/src/core/geminiContentGenerator/index.ts new file mode 100644 index 000000000..4a615c0d8 --- /dev/null +++ b/packages/core/src/core/geminiContentGenerator/index.ts @@ -0,0 +1,53 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import { GeminiContentGenerator } from './geminiContentGenerator.js'; +import type { + ContentGenerator, + ContentGeneratorConfig, +} from '../contentGenerator.js'; +import type { Config } from '../../config/config.js'; +import { InstallationManager } from '../../utils/installationManager.js'; + +export { GeminiContentGenerator } from './geminiContentGenerator.js'; + +/** + * Create a Gemini content generator. + */ +export function createGeminiContentGenerator( + config: ContentGeneratorConfig, + gcConfig: Config, +): ContentGenerator { + const version = process.env['CLI_VERSION'] || process.version; + const userAgent = + config.userAgent || + `QwenCode/${version} (${process.platform}; ${process.arch})`; + const baseHeaders: Record = { + 'User-Agent': userAgent, + }; + + let headers: Record = { ...baseHeaders }; + if (gcConfig?.getUsageStatisticsEnabled()) { + const installationManager = new InstallationManager(); + const installationId = installationManager.getInstallationId(); + headers = { + ...headers, + 'x-gemini-api-privileged-user-id': `${installationId}`, + }; + } + const httpOptions = { headers }; + + const geminiContentGenerator = new GeminiContentGenerator( + { + apiKey: config.apiKey === '' ? undefined : config.apiKey, + vertexai: config.vertexai, + httpOptions, + }, + config, + ); + + return geminiContentGenerator; +} diff --git a/packages/core/src/core/loggingContentGenerator.ts b/packages/core/src/core/loggingContentGenerator.ts deleted file mode 100644 index 151ac4ff9..000000000 --- a/packages/core/src/core/loggingContentGenerator.ts +++ /dev/null @@ -1,208 +0,0 @@ -/** - * @license - * Copyright 2025 Google LLC - * SPDX-License-Identifier: Apache-2.0 - */ - -import type { - Content, - CountTokensParameters, - CountTokensResponse, - EmbedContentParameters, - EmbedContentResponse, - GenerateContentParameters, - GenerateContentResponseUsageMetadata, - GenerateContentResponse, -} from '@google/genai'; -import { - ApiRequestEvent, - ApiResponseEvent, - ApiErrorEvent, -} from '../telemetry/types.js'; -import type { Config } from '../config/config.js'; -import { - logApiError, - logApiRequest, - logApiResponse, -} from '../telemetry/loggers.js'; -import type { ContentGenerator } from './contentGenerator.js'; -import { toContents } from '../code_assist/converter.js'; -import { isStructuredError } from '../utils/quotaErrorDetection.js'; - -interface StructuredError { - status: number; -} - -/** - * A decorator that wraps a ContentGenerator to add logging to API calls. - */ -export class LoggingContentGenerator implements ContentGenerator { - constructor( - private readonly wrapped: ContentGenerator, - private readonly config: Config, - ) {} - - getWrapped(): ContentGenerator { - return this.wrapped; - } - - private logApiRequest( - contents: Content[], - model: string, - promptId: string, - ): void { - const requestText = JSON.stringify(contents); - logApiRequest( - this.config, - new ApiRequestEvent(model, promptId, requestText), - ); - } - - private _logApiResponse( - responseId: string, - durationMs: number, - model: string, - prompt_id: string, - usageMetadata?: GenerateContentResponseUsageMetadata, - responseText?: string, - ): void { - logApiResponse( - this.config, - new ApiResponseEvent( - responseId, - model, - durationMs, - prompt_id, - this.config.getContentGeneratorConfig()?.authType, - usageMetadata, - responseText, - ), - ); - } - - private _logApiError( - responseId: string | undefined, - durationMs: number, - error: unknown, - model: string, - prompt_id: string, - ): void { - const errorMessage = error instanceof Error ? error.message : String(error); - const errorType = error instanceof Error ? error.name : 'unknown'; - - logApiError( - this.config, - new ApiErrorEvent( - responseId, - model, - errorMessage, - durationMs, - prompt_id, - this.config.getContentGeneratorConfig()?.authType, - errorType, - isStructuredError(error) - ? (error as StructuredError).status - : undefined, - ), - ); - } - - async generateContent( - req: GenerateContentParameters, - userPromptId: string, - ): Promise { - const startTime = Date.now(); - this.logApiRequest(toContents(req.contents), req.model, userPromptId); - try { - const response = await this.wrapped.generateContent(req, userPromptId); - const durationMs = Date.now() - startTime; - this._logApiResponse( - response.responseId ?? '', - durationMs, - response.modelVersion || req.model, - userPromptId, - response.usageMetadata, - JSON.stringify(response), - ); - return response; - } catch (error) { - const durationMs = Date.now() - startTime; - this._logApiError(undefined, durationMs, error, req.model, userPromptId); - throw error; - } - } - - async generateContentStream( - req: GenerateContentParameters, - userPromptId: string, - ): Promise> { - const startTime = Date.now(); - this.logApiRequest(toContents(req.contents), req.model, userPromptId); - - let stream: AsyncGenerator; - try { - stream = await this.wrapped.generateContentStream(req, userPromptId); - } catch (error) { - const durationMs = Date.now() - startTime; - this._logApiError(undefined, durationMs, error, req.model, userPromptId); - throw error; - } - - return this.loggingStreamWrapper( - stream, - startTime, - userPromptId, - req.model, - ); - } - - private async *loggingStreamWrapper( - stream: AsyncGenerator, - startTime: number, - userPromptId: string, - model: string, - ): AsyncGenerator { - const responses: GenerateContentResponse[] = []; - - let lastUsageMetadata: GenerateContentResponseUsageMetadata | undefined; - try { - for await (const response of stream) { - responses.push(response); - if (response.usageMetadata) { - lastUsageMetadata = response.usageMetadata; - } - yield response; - } - // Only log successful API response if no error occurred - const durationMs = Date.now() - startTime; - this._logApiResponse( - responses[0]?.responseId ?? '', - durationMs, - responses[0]?.modelVersion || model, - userPromptId, - lastUsageMetadata, - JSON.stringify(responses), - ); - } catch (error) { - const durationMs = Date.now() - startTime; - this._logApiError( - undefined, - durationMs, - error, - responses[0]?.modelVersion || model, - userPromptId, - ); - throw error; - } - } - - async countTokens(req: CountTokensParameters): Promise { - return this.wrapped.countTokens(req); - } - - async embedContent( - req: EmbedContentParameters, - ): Promise { - return this.wrapped.embedContent(req); - } -} diff --git a/packages/core/src/core/loggingContentGenerator/index.ts b/packages/core/src/core/loggingContentGenerator/index.ts new file mode 100644 index 000000000..c3957292c --- /dev/null +++ b/packages/core/src/core/loggingContentGenerator/index.ts @@ -0,0 +1,7 @@ +/** + * @license + * Copyright 2025 Qwen + * SPDX-License-Identifier: Apache-2.0 + */ + +export { LoggingContentGenerator } from './loggingContentGenerator.js'; diff --git a/packages/core/src/core/loggingContentGenerator/loggingContentGenerator.test.ts b/packages/core/src/core/loggingContentGenerator/loggingContentGenerator.test.ts new file mode 100644 index 000000000..0a61e5573 --- /dev/null +++ b/packages/core/src/core/loggingContentGenerator/loggingContentGenerator.test.ts @@ -0,0 +1,371 @@ +/** + * @license + * Copyright 2025 Qwen + * SPDX-License-Identifier: Apache-2.0 + */ + +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import type { + GenerateContentParameters, + GenerateContentResponseUsageMetadata, +} from '@google/genai'; +import { GenerateContentResponse } from '@google/genai'; +import type { Config } from '../../config/config.js'; +import type { ContentGenerator } from '../contentGenerator.js'; +import { LoggingContentGenerator } from './index.js'; +import { OpenAIContentConverter } from '../openaiContentGenerator/converter.js'; +import { + logApiRequest, + logApiResponse, + logApiError, +} from '../../telemetry/loggers.js'; +import { OpenAILogger } from '../../utils/openaiLogger.js'; +import type OpenAI from 'openai'; + +vi.mock('../../telemetry/loggers.js', () => ({ + logApiRequest: vi.fn(), + logApiResponse: vi.fn(), + logApiError: vi.fn(), +})); + +vi.mock('../../utils/openaiLogger.js', () => ({ + OpenAILogger: vi.fn().mockImplementation(() => ({ + logInteraction: vi.fn().mockResolvedValue(undefined), + })), +})); + +const convertGeminiRequestToOpenAISpy = vi + .spyOn(OpenAIContentConverter.prototype, 'convertGeminiRequestToOpenAI') + .mockReturnValue([{ role: 'user', content: 'converted' }]); +const convertGeminiToolsToOpenAISpy = vi + .spyOn(OpenAIContentConverter.prototype, 'convertGeminiToolsToOpenAI') + .mockResolvedValue([{ type: 'function', function: { name: 'tool' } }]); +const convertGeminiResponseToOpenAISpy = vi + .spyOn(OpenAIContentConverter.prototype, 'convertGeminiResponseToOpenAI') + .mockReturnValue({ + id: 'openai-response', + object: 'chat.completion', + created: 123456789, + model: 'test-model', + choices: [], + } as OpenAI.Chat.ChatCompletion); + +const createConfig = (overrides: Record = {}): Config => + ({ + getContentGeneratorConfig: () => ({ + authType: 'openai', + enableOpenAILogging: false, + ...overrides, + }), + }) as Config; + +const createWrappedGenerator = ( + generateContent: ContentGenerator['generateContent'], + generateContentStream: ContentGenerator['generateContentStream'], +): ContentGenerator => + ({ + generateContent, + generateContentStream, + countTokens: vi.fn(), + embedContent: vi.fn(), + useSummarizedThinking: vi.fn().mockReturnValue(false), + }) as ContentGenerator; + +const createResponse = ( + responseId: string, + modelVersion: string, + parts: Array>, + usageMetadata?: GenerateContentResponseUsageMetadata, + finishReason?: string, +): GenerateContentResponse => { + const response = new GenerateContentResponse(); + response.responseId = responseId; + response.modelVersion = modelVersion; + response.usageMetadata = usageMetadata; + response.candidates = [ + { + content: { + role: 'model', + parts: parts as never[], + }, + finishReason: finishReason as never, + index: 0, + safetyRatings: [], + }, + ]; + return response; +}; + +describe('LoggingContentGenerator', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + afterEach(() => { + convertGeminiRequestToOpenAISpy.mockClear(); + convertGeminiToolsToOpenAISpy.mockClear(); + convertGeminiResponseToOpenAISpy.mockClear(); + }); + + it('logs request/response, normalizes thought parts, and logs OpenAI interaction', async () => { + const wrapped = createWrappedGenerator( + vi.fn().mockResolvedValue( + createResponse( + 'resp-1', + 'model-v2', + [{ text: 'ok' }], + { + promptTokenCount: 3, + candidatesTokenCount: 5, + totalTokenCount: 8, + }, + 'STOP', + ), + ), + vi.fn(), + ); + const generator = new LoggingContentGenerator( + wrapped, + createConfig({ + enableOpenAILogging: true, + openAILoggingDir: 'logs', + schemaCompliance: 'openapi_30', + }), + ); + + const request = { + model: 'test-model', + contents: [ + { + role: 'user', + parts: [ + { text: 'Hello', thought: 'internal' }, + { + functionCall: { id: 'call-1', name: 'tool', args: '{}' }, + thought: 'strip-me', + }, + null, + ], + }, + ], + config: { + temperature: 0.3, + topP: 0.9, + maxOutputTokens: 256, + presencePenalty: 0.2, + frequencyPenalty: 0.1, + tools: [ + { + functionDeclarations: [ + { name: 'tool', description: 'desc', parameters: {} }, + ], + }, + ], + }, + } as unknown as GenerateContentParameters; + + const response = await generator.generateContent(request, 'prompt-1'); + + expect(response.responseId).toBe('resp-1'); + expect(logApiRequest).toHaveBeenCalledTimes(1); + const [, requestEvent] = vi.mocked(logApiRequest).mock.calls[0]; + const loggedContents = JSON.parse(requestEvent.request_text || '[]'); + expect(loggedContents[0].parts[0]).toEqual({ + text: 'Hello\n[Thought: internal]', + }); + expect(loggedContents[0].parts[1]).toEqual({ + functionCall: { id: 'call-1', name: 'tool', args: '{}' }, + }); + + expect(logApiResponse).toHaveBeenCalledTimes(1); + const [, responseEvent] = vi.mocked(logApiResponse).mock.calls[0]; + expect(responseEvent.response_id).toBe('resp-1'); + expect(responseEvent.model).toBe('model-v2'); + expect(responseEvent.prompt_id).toBe('prompt-1'); + expect(responseEvent.input_token_count).toBe(3); + + expect(convertGeminiRequestToOpenAISpy).toHaveBeenCalledTimes(1); + expect(convertGeminiToolsToOpenAISpy).toHaveBeenCalledTimes(1); + expect(convertGeminiResponseToOpenAISpy).toHaveBeenCalledTimes(1); + + const openaiLoggerInstance = vi.mocked(OpenAILogger).mock.results[0] + ?.value as { logInteraction: ReturnType }; + expect(openaiLoggerInstance.logInteraction).toHaveBeenCalledTimes(1); + const [openaiRequest, openaiResponse, openaiError] = + openaiLoggerInstance.logInteraction.mock.calls[0]; + expect(openaiRequest).toEqual( + expect.objectContaining({ + model: 'test-model', + messages: [{ role: 'user', content: 'converted' }], + tools: [{ type: 'function', function: { name: 'tool' } }], + temperature: 0.3, + top_p: 0.9, + max_tokens: 256, + presence_penalty: 0.2, + frequency_penalty: 0.1, + }), + ); + expect(openaiResponse).toEqual({ + id: 'openai-response', + object: 'chat.completion', + created: 123456789, + model: 'test-model', + choices: [], + }); + expect(openaiError).toBeUndefined(); + }); + + it('logs errors with status code and request id, then rethrows', async () => { + const error = Object.assign(new Error('boom'), { + code: 429, + request_id: 'req-99', + type: 'rate_limit', + }); + const wrapped = createWrappedGenerator( + vi.fn().mockRejectedValue(error), + vi.fn(), + ); + const generator = new LoggingContentGenerator( + wrapped, + createConfig({ enableOpenAILogging: true }), + ); + + const request = { + model: 'test-model', + contents: 'Hello', + } as unknown as GenerateContentParameters; + + await expect( + generator.generateContent(request, 'prompt-2'), + ).rejects.toThrow('boom'); + + expect(logApiError).toHaveBeenCalledTimes(1); + const [, errorEvent] = vi.mocked(logApiError).mock.calls[0]; + expect(errorEvent.response_id).toBe('req-99'); + expect(errorEvent.status_code).toBe(429); + expect(errorEvent.error_type).toBe('rate_limit'); + expect(errorEvent.prompt_id).toBe('prompt-2'); + + const openaiLoggerInstance = vi.mocked(OpenAILogger).mock.results[0] + ?.value as { logInteraction: ReturnType }; + const [, , loggedError] = openaiLoggerInstance.logInteraction.mock.calls[0]; + expect(loggedError).toBeInstanceOf(Error); + expect((loggedError as Error).message).toBe('boom'); + }); + + it('logs streaming responses and consolidates tool calls', async () => { + const usage1 = { + promptTokenCount: 1, + } as GenerateContentResponseUsageMetadata; + const usage2 = { + promptTokenCount: 2, + candidatesTokenCount: 4, + totalTokenCount: 6, + } as GenerateContentResponseUsageMetadata; + + const response1 = createResponse( + 'resp-1', + 'model-stream', + [ + { text: 'Hello' }, + { functionCall: { id: 'call-1', name: 'tool', args: '{}' } }, + ], + usage1, + ); + const response2 = createResponse( + 'resp-2', + 'model-stream', + [ + { text: ' world' }, + { functionCall: { id: 'call-1', name: 'tool', args: '{"x":1}' } }, + { functionResponse: { name: 'tool', response: { output: 'ok' } } }, + ], + usage2, + 'STOP', + ); + + const wrapped = createWrappedGenerator( + vi.fn(), + vi.fn().mockResolvedValue( + (async function* () { + yield response1; + yield response2; + })(), + ), + ); + const generator = new LoggingContentGenerator( + wrapped, + createConfig({ enableOpenAILogging: true }), + ); + + const request = { + model: 'test-model', + contents: 'Hello', + } as unknown as GenerateContentParameters; + + const stream = await generator.generateContentStream(request, 'prompt-3'); + const seen: GenerateContentResponse[] = []; + for await (const item of stream) { + seen.push(item); + } + expect(seen).toHaveLength(2); + + expect(logApiResponse).toHaveBeenCalledTimes(1); + const [, responseEvent] = vi.mocked(logApiResponse).mock.calls[0]; + expect(responseEvent.response_id).toBe('resp-1'); + expect(responseEvent.input_token_count).toBe(2); + + expect(convertGeminiResponseToOpenAISpy).toHaveBeenCalledTimes(1); + const [consolidatedResponse] = + convertGeminiResponseToOpenAISpy.mock.calls[0]; + const consolidatedParts = + consolidatedResponse.candidates?.[0]?.content?.parts || []; + expect(consolidatedParts).toEqual([ + { text: 'Hello' }, + { functionCall: { id: 'call-1', name: 'tool', args: '{"x":1}' } }, + { text: ' world' }, + { functionResponse: { name: 'tool', response: { output: 'ok' } } }, + ]); + expect(consolidatedResponse.usageMetadata).toBe(usage2); + expect(consolidatedResponse.responseId).toBe('resp-2'); + expect(consolidatedResponse.candidates?.[0]?.finishReason).toBe('STOP'); + }); + + it('logs stream errors and skips response logging', async () => { + const response1 = createResponse('resp-1', 'model-stream', [ + { text: 'partial' }, + ]); + const streamError = new Error('stream-fail'); + const wrapped = createWrappedGenerator( + vi.fn(), + vi.fn().mockResolvedValue( + (async function* () { + yield response1; + throw streamError; + })(), + ), + ); + const generator = new LoggingContentGenerator( + wrapped, + createConfig({ enableOpenAILogging: true }), + ); + + const request = { + model: 'test-model', + contents: 'Hello', + } as unknown as GenerateContentParameters; + + const stream = await generator.generateContentStream(request, 'prompt-4'); + await expect(async () => { + for await (const _item of stream) { + // Consume stream to trigger error. + } + }).rejects.toThrow('stream-fail'); + + expect(logApiResponse).not.toHaveBeenCalled(); + expect(logApiError).toHaveBeenCalledTimes(1); + const openaiLoggerInstance = vi.mocked(OpenAILogger).mock.results[0] + ?.value as { logInteraction: ReturnType }; + expect(openaiLoggerInstance.logInteraction).toHaveBeenCalledTimes(1); + }); +}); diff --git a/packages/core/src/core/loggingContentGenerator/loggingContentGenerator.ts b/packages/core/src/core/loggingContentGenerator/loggingContentGenerator.ts new file mode 100644 index 000000000..34e9128a8 --- /dev/null +++ b/packages/core/src/core/loggingContentGenerator/loggingContentGenerator.ts @@ -0,0 +1,507 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import { + GenerateContentResponse, + type Content, + type CountTokensParameters, + type CountTokensResponse, + type EmbedContentParameters, + type EmbedContentResponse, + type GenerateContentParameters, + type GenerateContentResponseUsageMetadata, + type ContentListUnion, + type ContentUnion, + type Part, + type PartUnion, + type FinishReason, +} from '@google/genai'; +import type OpenAI from 'openai'; +import { + ApiRequestEvent, + ApiResponseEvent, + ApiErrorEvent, +} from '../../telemetry/types.js'; +import type { Config } from '../../config/config.js'; +import { + logApiError, + logApiRequest, + logApiResponse, +} from '../../telemetry/loggers.js'; +import type { ContentGenerator } from '../contentGenerator.js'; +import { isStructuredError } from '../../utils/quotaErrorDetection.js'; +import { OpenAIContentConverter } from '../openaiContentGenerator/converter.js'; +import { OpenAILogger } from '../../utils/openaiLogger.js'; + +interface StructuredError { + status: number; +} + +/** + * A decorator that wraps a ContentGenerator to add logging to API calls. + */ +export class LoggingContentGenerator implements ContentGenerator { + private openaiLogger?: OpenAILogger; + private schemaCompliance?: 'auto' | 'openapi_30'; + + constructor( + private readonly wrapped: ContentGenerator, + private readonly config: Config, + ) { + const generatorConfig = this.config.getContentGeneratorConfig(); + if (generatorConfig?.enableOpenAILogging) { + this.openaiLogger = new OpenAILogger(generatorConfig.openAILoggingDir); + this.schemaCompliance = generatorConfig.schemaCompliance; + } + } + + getWrapped(): ContentGenerator { + return this.wrapped; + } + + private logApiRequest( + contents: Content[], + model: string, + promptId: string, + ): void { + const requestText = JSON.stringify(contents); + logApiRequest( + this.config, + new ApiRequestEvent(model, promptId, requestText), + ); + } + + private _logApiResponse( + responseId: string, + durationMs: number, + model: string, + prompt_id: string, + usageMetadata?: GenerateContentResponseUsageMetadata, + responseText?: string, + ): void { + logApiResponse( + this.config, + new ApiResponseEvent( + responseId, + model, + durationMs, + prompt_id, + this.config.getContentGeneratorConfig()?.authType, + usageMetadata, + responseText, + ), + ); + } + + private _logApiError( + responseId: string | undefined, + durationMs: number, + error: unknown, + model: string, + prompt_id: string, + ): void { + const errorMessage = error instanceof Error ? error.message : String(error); + const errorType = + (error as { type?: string })?.type || + (error instanceof Error ? error.name : 'unknown'); + const errorResponseId = + (error as { requestID?: string; request_id?: string })?.requestID || + (error as { requestID?: string; request_id?: string })?.request_id || + responseId; + const errorStatus = + (error as { code?: string | number; status?: number })?.code ?? + (error as { status?: number })?.status ?? + (isStructuredError(error) + ? (error as StructuredError).status + : undefined); + + logApiError( + this.config, + new ApiErrorEvent( + errorResponseId, + model, + errorMessage, + durationMs, + prompt_id, + this.config.getContentGeneratorConfig()?.authType, + errorType, + errorStatus, + ), + ); + } + + async generateContent( + req: GenerateContentParameters, + userPromptId: string, + ): Promise { + const startTime = Date.now(); + this.logApiRequest(this.toContents(req.contents), req.model, userPromptId); + const openaiRequest = await this.buildOpenAIRequestForLogging(req); + try { + const response = await this.wrapped.generateContent(req, userPromptId); + const durationMs = Date.now() - startTime; + this._logApiResponse( + response.responseId ?? '', + durationMs, + response.modelVersion || req.model, + userPromptId, + response.usageMetadata, + JSON.stringify(response), + ); + await this.logOpenAIInteraction(openaiRequest, response); + return response; + } catch (error) { + const durationMs = Date.now() - startTime; + this._logApiError(undefined, durationMs, error, req.model, userPromptId); + await this.logOpenAIInteraction(openaiRequest, undefined, error); + throw error; + } + } + + async generateContentStream( + req: GenerateContentParameters, + userPromptId: string, + ): Promise> { + const startTime = Date.now(); + this.logApiRequest(this.toContents(req.contents), req.model, userPromptId); + const openaiRequest = await this.buildOpenAIRequestForLogging(req); + + let stream: AsyncGenerator; + try { + stream = await this.wrapped.generateContentStream(req, userPromptId); + } catch (error) { + const durationMs = Date.now() - startTime; + this._logApiError(undefined, durationMs, error, req.model, userPromptId); + await this.logOpenAIInteraction(openaiRequest, undefined, error); + throw error; + } + + return this.loggingStreamWrapper( + stream, + startTime, + userPromptId, + req.model, + openaiRequest, + ); + } + + private async *loggingStreamWrapper( + stream: AsyncGenerator, + startTime: number, + userPromptId: string, + model: string, + openaiRequest?: OpenAI.Chat.ChatCompletionCreateParams, + ): AsyncGenerator { + const responses: GenerateContentResponse[] = []; + + let lastUsageMetadata: GenerateContentResponseUsageMetadata | undefined; + try { + for await (const response of stream) { + responses.push(response); + if (response.usageMetadata) { + lastUsageMetadata = response.usageMetadata; + } + yield response; + } + // Only log successful API response if no error occurred + const durationMs = Date.now() - startTime; + this._logApiResponse( + responses[0]?.responseId ?? '', + durationMs, + responses[0]?.modelVersion || model, + userPromptId, + lastUsageMetadata, + JSON.stringify(responses), + ); + const consolidatedResponse = + this.consolidateGeminiResponsesForLogging(responses); + await this.logOpenAIInteraction(openaiRequest, consolidatedResponse); + } catch (error) { + const durationMs = Date.now() - startTime; + this._logApiError( + undefined, + durationMs, + error, + responses[0]?.modelVersion || model, + userPromptId, + ); + await this.logOpenAIInteraction(openaiRequest, undefined, error); + throw error; + } + } + + private async buildOpenAIRequestForLogging( + request: GenerateContentParameters, + ): Promise { + if (!this.openaiLogger) { + return undefined; + } + + const converter = new OpenAIContentConverter( + request.model, + this.schemaCompliance, + ); + const messages = converter.convertGeminiRequestToOpenAI(request, { + cleanOrphanToolCalls: false, + }); + + const openaiRequest: OpenAI.Chat.ChatCompletionCreateParams = { + model: request.model, + messages, + }; + + if (request.config?.tools) { + openaiRequest.tools = await converter.convertGeminiToolsToOpenAI( + request.config.tools, + ); + } + + if (request.config?.temperature !== undefined) { + openaiRequest.temperature = request.config.temperature; + } + if (request.config?.topP !== undefined) { + openaiRequest.top_p = request.config.topP; + } + if (request.config?.maxOutputTokens !== undefined) { + openaiRequest.max_tokens = request.config.maxOutputTokens; + } + if (request.config?.presencePenalty !== undefined) { + openaiRequest.presence_penalty = request.config.presencePenalty; + } + if (request.config?.frequencyPenalty !== undefined) { + openaiRequest.frequency_penalty = request.config.frequencyPenalty; + } + + return openaiRequest; + } + + private async logOpenAIInteraction( + openaiRequest: OpenAI.Chat.ChatCompletionCreateParams | undefined, + response?: GenerateContentResponse, + error?: unknown, + ): Promise { + if (!this.openaiLogger || !openaiRequest) { + return; + } + + const openaiResponse = response + ? this.convertGeminiResponseToOpenAIForLogging(response, openaiRequest) + : undefined; + + await this.openaiLogger.logInteraction( + openaiRequest, + openaiResponse, + error instanceof Error + ? error + : error + ? new Error(String(error)) + : undefined, + ); + } + + private convertGeminiResponseToOpenAIForLogging( + response: GenerateContentResponse, + openaiRequest: OpenAI.Chat.ChatCompletionCreateParams, + ): OpenAI.Chat.ChatCompletion { + const converter = new OpenAIContentConverter( + openaiRequest.model, + this.schemaCompliance, + ); + + return converter.convertGeminiResponseToOpenAI(response); + } + + private consolidateGeminiResponsesForLogging( + responses: GenerateContentResponse[], + ): GenerateContentResponse | undefined { + if (responses.length === 0) { + return undefined; + } + + const consolidated = new GenerateContentResponse(); + const combinedParts: Part[] = []; + const functionCallIndex = new Map(); + let finishReason: FinishReason | undefined; + let usageMetadata: GenerateContentResponseUsageMetadata | undefined; + + for (const response of responses) { + if (response.usageMetadata) { + usageMetadata = response.usageMetadata; + } + + const candidate = response.candidates?.[0]; + if (candidate?.finishReason) { + finishReason = candidate.finishReason; + } + + const parts = candidate?.content?.parts ?? []; + for (const part of parts as Part[]) { + if (typeof part === 'string') { + combinedParts.push({ text: part }); + continue; + } + + if ('text' in part) { + if (part.text) { + combinedParts.push({ + text: part.text, + ...(part.thought ? { thought: true } : {}), + ...(part.thoughtSignature + ? { thoughtSignature: part.thoughtSignature } + : {}), + }); + } + continue; + } + + if ('functionCall' in part && part.functionCall) { + const callKey = + part.functionCall.id || part.functionCall.name || 'tool_call'; + const existingIndex = functionCallIndex.get(callKey); + const functionPart = { functionCall: part.functionCall }; + if (existingIndex !== undefined) { + combinedParts[existingIndex] = functionPart; + } else { + functionCallIndex.set(callKey, combinedParts.length); + combinedParts.push(functionPart); + } + continue; + } + + if ('functionResponse' in part && part.functionResponse) { + combinedParts.push({ functionResponse: part.functionResponse }); + continue; + } + + combinedParts.push(part); + } + } + + const lastResponse = responses[responses.length - 1]; + const lastCandidate = lastResponse.candidates?.[0]; + + consolidated.responseId = lastResponse.responseId; + consolidated.createTime = lastResponse.createTime; + consolidated.modelVersion = lastResponse.modelVersion; + consolidated.promptFeedback = lastResponse.promptFeedback; + consolidated.usageMetadata = usageMetadata; + + consolidated.candidates = [ + { + content: { + role: lastCandidate?.content?.role || 'model', + parts: combinedParts, + }, + ...(finishReason ? { finishReason } : {}), + index: 0, + safetyRatings: lastCandidate?.safetyRatings || [], + }, + ]; + + return consolidated; + } + + async countTokens(req: CountTokensParameters): Promise { + return this.wrapped.countTokens(req); + } + + async embedContent( + req: EmbedContentParameters, + ): Promise { + return this.wrapped.embedContent(req); + } + + useSummarizedThinking(): boolean { + return this.wrapped.useSummarizedThinking(); + } + + private toContents(contents: ContentListUnion): Content[] { + if (Array.isArray(contents)) { + // it's a Content[] or a PartsUnion[] + return contents.map((c) => this.toContent(c)); + } + // it's a Content or a PartsUnion + return [this.toContent(contents)]; + } + + private toContent(content: ContentUnion): Content { + if (Array.isArray(content)) { + // it's a PartsUnion[] + return { + role: 'user', + parts: this.toParts(content), + }; + } + if (typeof content === 'string') { + // it's a string + return { + role: 'user', + parts: [{ text: content }], + }; + } + if ('parts' in content) { + // it's a Content - process parts to handle thought filtering + return { + ...content, + parts: content.parts + ? this.toParts(content.parts.filter((p) => p != null)) + : [], + }; + } + // it's a Part + return { + role: 'user', + parts: [this.toPart(content as Part)], + }; + } + + private toParts(parts: PartUnion[]): Part[] { + return parts.map((p) => this.toPart(p)); + } + + private toPart(part: PartUnion): Part { + if (typeof part === 'string') { + // it's a string + return { text: part }; + } + + // Handle thought parts for CountToken API compatibility + // The CountToken API expects parts to have certain required "oneof" fields initialized, + // but thought parts don't conform to this schema and cause API failures + if ('thought' in part && part.thought) { + const thoughtText = `[Thought: ${part.thought}]`; + + const newPart = { ...part }; + delete (newPart as Record)['thought']; + + const hasApiContent = + 'functionCall' in newPart || + 'functionResponse' in newPart || + 'inlineData' in newPart || + 'fileData' in newPart; + + if (hasApiContent) { + // It's a functionCall or other non-text part. Just strip the thought. + return newPart; + } + + // If no other valid API content, this must be a text part. + // Combine existing text (if any) with the thought, preserving other properties. + const text = (newPart as { text?: unknown }).text; + const existingText = text ? String(text) : ''; + const combinedText = existingText + ? `${existingText}\n${thoughtText}` + : thoughtText; + + return { + ...newPart, + text: combinedText, + }; + } + + return part; + } +} diff --git a/packages/core/src/core/nonInteractiveToolExecutor.test.ts b/packages/core/src/core/nonInteractiveToolExecutor.test.ts index e3b8d175d..5296310f9 100644 --- a/packages/core/src/core/nonInteractiveToolExecutor.test.ts +++ b/packages/core/src/core/nonInteractiveToolExecutor.test.ts @@ -47,7 +47,7 @@ describe('executeToolCall', () => { getDebugMode: () => false, getContentGeneratorConfig: () => ({ model: 'test-model', - authType: 'oauth-personal', + authType: 'gemini-api-key', }), getShellExecutionConfig: () => ({ terminalWidth: 90, diff --git a/packages/core/src/core/openaiContentGenerator/converter.test.ts b/packages/core/src/core/openaiContentGenerator/converter.test.ts index e29b46400..5cd6af92e 100644 --- a/packages/core/src/core/openaiContentGenerator/converter.test.ts +++ b/packages/core/src/core/openaiContentGenerator/converter.test.ts @@ -7,7 +7,13 @@ import { describe, it, expect, beforeEach } from 'vitest'; import { OpenAIContentConverter } from './converter.js'; import type { StreamingToolCallParser } from './streamingToolCallParser.js'; -import type { GenerateContentParameters, Content } from '@google/genai'; +import { + Type, + type GenerateContentParameters, + type Content, + type Tool, + type CallableTool, +} from '@google/genai'; import type OpenAI from 'openai'; describe('OpenAIContentConverter', () => { @@ -202,4 +208,338 @@ describe('OpenAIContentConverter', () => { ); }); }); + + describe('convertGeminiToolsToOpenAI', () => { + it('should convert Gemini tools with parameters field', async () => { + const geminiTools = [ + { + functionDeclarations: [ + { + name: 'get_weather', + description: 'Get weather for a location', + parameters: { + type: Type.OBJECT, + properties: { + location: { type: Type.STRING }, + }, + required: ['location'], + }, + }, + ], + }, + ] as Tool[]; + + const result = await converter.convertGeminiToolsToOpenAI(geminiTools); + + expect(result).toHaveLength(1); + expect(result[0]).toEqual({ + type: 'function', + function: { + name: 'get_weather', + description: 'Get weather for a location', + parameters: { + type: 'object', + properties: { + location: { type: 'string' }, + }, + required: ['location'], + }, + }, + }); + }); + + it('should convert MCP tools with parametersJsonSchema field', async () => { + // MCP tools use parametersJsonSchema which contains plain JSON schema (not Gemini types) + const mcpTools = [ + { + functionDeclarations: [ + { + name: 'read_file', + description: 'Read a file from disk', + parametersJsonSchema: { + type: 'object', + properties: { + path: { type: 'string' }, + }, + required: ['path'], + }, + }, + ], + }, + ] as Tool[]; + + const result = await converter.convertGeminiToolsToOpenAI(mcpTools); + + expect(result).toHaveLength(1); + expect(result[0]).toEqual({ + type: 'function', + function: { + name: 'read_file', + description: 'Read a file from disk', + parameters: { + type: 'object', + properties: { + path: { type: 'string' }, + }, + required: ['path'], + }, + }, + }); + }); + + it('should handle CallableTool by resolving tool function', async () => { + const callableTools = [ + { + tool: async () => ({ + functionDeclarations: [ + { + name: 'dynamic_tool', + description: 'A dynamically resolved tool', + parameters: { + type: Type.OBJECT, + properties: {}, + }, + }, + ], + }), + }, + ] as CallableTool[]; + + const result = await converter.convertGeminiToolsToOpenAI(callableTools); + + expect(result).toHaveLength(1); + expect(result[0].function.name).toBe('dynamic_tool'); + }); + + it('should skip functions without name or description', async () => { + const geminiTools = [ + { + functionDeclarations: [ + { + name: 'valid_tool', + description: 'A valid tool', + }, + { + name: 'missing_description', + // no description + }, + { + // no name + description: 'Missing name', + }, + ], + }, + ] as Tool[]; + + const result = await converter.convertGeminiToolsToOpenAI(geminiTools); + + expect(result).toHaveLength(1); + expect(result[0].function.name).toBe('valid_tool'); + }); + + it('should handle tools without functionDeclarations', async () => { + const emptyTools: Tool[] = [{} as Tool, { functionDeclarations: [] }]; + + const result = await converter.convertGeminiToolsToOpenAI(emptyTools); + + expect(result).toHaveLength(0); + }); + + it('should handle functions without parameters', async () => { + const geminiTools: Tool[] = [ + { + functionDeclarations: [ + { + name: 'no_params_tool', + description: 'A tool without parameters', + }, + ], + }, + ]; + + const result = await converter.convertGeminiToolsToOpenAI(geminiTools); + + expect(result).toHaveLength(1); + expect(result[0].function.parameters).toBeUndefined(); + }); + + it('should not mutate original parametersJsonSchema', async () => { + const originalSchema = { + type: 'object', + properties: { foo: { type: 'string' } }, + }; + const mcpTools: Tool[] = [ + { + functionDeclarations: [ + { + name: 'test_tool', + description: 'Test tool', + parametersJsonSchema: originalSchema, + }, + ], + } as Tool, + ]; + + const result = await converter.convertGeminiToolsToOpenAI(mcpTools); + + // Verify the result is a copy, not the same reference + expect(result[0].function.parameters).not.toBe(originalSchema); + expect(result[0].function.parameters).toEqual(originalSchema); + }); + }); + + describe('convertGeminiToolParametersToOpenAI', () => { + it('should convert type names to lowercase', () => { + const params = { + type: 'OBJECT', + properties: { + count: { type: 'INTEGER' }, + amount: { type: 'NUMBER' }, + name: { type: 'STRING' }, + }, + }; + + const result = converter.convertGeminiToolParametersToOpenAI(params); + + expect(result).toEqual({ + type: 'object', + properties: { + count: { type: 'integer' }, + amount: { type: 'number' }, + name: { type: 'string' }, + }, + }); + }); + + it('should convert string numeric constraints to numbers', () => { + const params = { + type: 'object', + properties: { + value: { + type: 'number', + minimum: '0', + maximum: '100', + multipleOf: '0.5', + }, + }, + }; + + const result = converter.convertGeminiToolParametersToOpenAI(params); + const properties = result?.['properties'] as Record; + + expect(properties?.['value']).toEqual({ + type: 'number', + minimum: 0, + maximum: 100, + multipleOf: 0.5, + }); + }); + + it('should convert string length constraints to integers', () => { + const params = { + type: 'object', + properties: { + text: { + type: 'string', + minLength: '1', + maxLength: '100', + }, + items: { + type: 'array', + minItems: '0', + maxItems: '10', + }, + }, + }; + + const result = converter.convertGeminiToolParametersToOpenAI(params); + const properties = result?.['properties'] as Record; + + expect(properties?.['text']).toEqual({ + type: 'string', + minLength: 1, + maxLength: 100, + }); + expect(properties?.['items']).toEqual({ + type: 'array', + minItems: 0, + maxItems: 10, + }); + }); + + it('should handle nested objects', () => { + const params = { + type: 'object', + properties: { + nested: { + type: 'object', + properties: { + deep: { + type: 'INTEGER', + minimum: '0', + }, + }, + }, + }, + }; + + const result = converter.convertGeminiToolParametersToOpenAI(params); + const properties = result?.['properties'] as Record; + const nested = properties?.['nested'] as Record; + const nestedProperties = nested?.['properties'] as Record< + string, + unknown + >; + + expect(nestedProperties?.['deep']).toEqual({ + type: 'integer', + minimum: 0, + }); + }); + + it('should handle arrays', () => { + const params = { + type: 'array', + items: { + type: 'INTEGER', + }, + }; + + const result = converter.convertGeminiToolParametersToOpenAI(params); + + expect(result).toEqual({ + type: 'array', + items: { + type: 'integer', + }, + }); + }); + + it('should return undefined for null or non-object input', () => { + expect( + converter.convertGeminiToolParametersToOpenAI( + null as unknown as Record, + ), + ).toBeNull(); + expect( + converter.convertGeminiToolParametersToOpenAI( + undefined as unknown as Record, + ), + ).toBeUndefined(); + }); + + it('should not mutate the original parameters', () => { + const original = { + type: 'OBJECT', + properties: { + count: { type: 'INTEGER' }, + }, + }; + const originalCopy = JSON.parse(JSON.stringify(original)); + + converter.convertGeminiToolParametersToOpenAI(original); + + expect(original).toEqual(originalCopy); + }); + }); }); diff --git a/packages/core/src/core/openaiContentGenerator/converter.ts b/packages/core/src/core/openaiContentGenerator/converter.ts index b22eb9633..79bb43365 100644 --- a/packages/core/src/core/openaiContentGenerator/converter.ts +++ b/packages/core/src/core/openaiContentGenerator/converter.ts @@ -22,6 +22,10 @@ import { GenerateContentResponse, FinishReason } from '@google/genai'; import type OpenAI from 'openai'; import { safeJsonParse } from '../../utils/safeJsonParse.js'; import { StreamingToolCallParser } from './streamingToolCallParser.js'; +import { + convertSchema, + type SchemaComplianceMode, +} from '../../utils/schemaConverter.js'; /** * Extended usage type that supports both OpenAI standard format and alternative formats @@ -80,11 +84,13 @@ interface ParsedParts { */ export class OpenAIContentConverter { private model: string; + private schemaCompliance: SchemaComplianceMode; private streamingToolCallParser: StreamingToolCallParser = new StreamingToolCallParser(); - constructor(model: string) { + constructor(model: string, schemaCompliance: SchemaComplianceMode = 'auto') { this.model = model; + this.schemaCompliance = schemaCompliance; } /** @@ -193,13 +199,11 @@ export class OpenAIContentConverter { // Handle both Gemini tools (parameters) and MCP tools (parametersJsonSchema) if (func.parametersJsonSchema) { // MCP tool format - use parametersJsonSchema directly - if (func.parametersJsonSchema) { - // Create a shallow copy to avoid mutating the original object - const paramsCopy = { - ...(func.parametersJsonSchema as Record), - }; - parameters = paramsCopy; - } + // Create a shallow copy to avoid mutating the original object + const paramsCopy = { + ...(func.parametersJsonSchema as Record), + }; + parameters = paramsCopy; } else if (func.parameters) { // Gemini tool format - convert parameters to OpenAI format parameters = this.convertGeminiToolParametersToOpenAI( @@ -207,6 +211,10 @@ export class OpenAIContentConverter { ); } + if (parameters) { + parameters = convertSchema(parameters, this.schemaCompliance); + } + openAITools.push({ type: 'function', function: { @@ -228,8 +236,9 @@ export class OpenAIContentConverter { */ convertGeminiRequestToOpenAI( request: GenerateContentParameters, + options: { cleanOrphanToolCalls: boolean } = { cleanOrphanToolCalls: true }, ): OpenAI.Chat.ChatCompletionMessageParam[] { - const messages: OpenAI.Chat.ChatCompletionMessageParam[] = []; + let messages: OpenAI.Chat.ChatCompletionMessageParam[] = []; // Handle system instruction from config this.addSystemInstructionMessage(request, messages); @@ -238,11 +247,89 @@ export class OpenAIContentConverter { this.processContents(request.contents, messages); // Clean up orphaned tool calls and merge consecutive assistant messages - const cleanedMessages = this.cleanOrphanedToolCalls(messages); - const mergedMessages = - this.mergeConsecutiveAssistantMessages(cleanedMessages); + if (options.cleanOrphanToolCalls) { + messages = this.cleanOrphanedToolCalls(messages); + } + messages = this.mergeConsecutiveAssistantMessages(messages); - return mergedMessages; + return messages; + } + + /** + * Convert Gemini response to OpenAI completion format (for logging). + */ + convertGeminiResponseToOpenAI( + response: GenerateContentResponse, + ): OpenAI.Chat.ChatCompletion { + const candidate = response.candidates?.[0]; + const parts = (candidate?.content?.parts || []) as Part[]; + const parsedParts = this.parseParts(parts); + + const message: ExtendedCompletionMessage = { + role: 'assistant', + content: parsedParts.contentParts.join('') || null, + refusal: null, + }; + + const reasoningContent = parsedParts.thoughtParts.join(''); + if (reasoningContent) { + message.reasoning_content = reasoningContent; + } + + if (parsedParts.functionCalls.length > 0) { + message.tool_calls = parsedParts.functionCalls.map((call, index) => ({ + id: call.id || `call_${index}`, + type: 'function' as const, + function: { + name: call.name || '', + arguments: JSON.stringify(call.args || {}), + }, + })); + } + + const finishReason = this.mapGeminiFinishReasonToOpenAI( + candidate?.finishReason, + ); + + const usageMetadata = response.usageMetadata; + const usage: OpenAI.CompletionUsage = { + prompt_tokens: usageMetadata?.promptTokenCount || 0, + completion_tokens: usageMetadata?.candidatesTokenCount || 0, + total_tokens: usageMetadata?.totalTokenCount || 0, + }; + + if (usageMetadata?.cachedContentTokenCount !== undefined) { + ( + usage as OpenAI.CompletionUsage & { + prompt_tokens_details?: { cached_tokens?: number }; + } + ).prompt_tokens_details = { + cached_tokens: usageMetadata.cachedContentTokenCount, + }; + } + + const createdMs = response.createTime + ? Number(response.createTime) + : Date.now(); + const createdSeconds = Number.isFinite(createdMs) + ? Math.floor(createdMs / 1000) + : Math.floor(Date.now() / 1000); + + return { + id: response.responseId || `gemini-${Date.now()}`, + object: 'chat.completion', + created: createdSeconds, + model: response.modelVersion || this.model, + choices: [ + { + index: 0, + message, + finish_reason: finishReason, + logprobs: null, + }, + ], + usage, + }; } /** @@ -828,84 +915,6 @@ export class OpenAIContentConverter { return response; } - /** - * Convert Gemini response format to OpenAI chat completion format for logging - */ - convertGeminiResponseToOpenAI( - response: GenerateContentResponse, - ): OpenAI.Chat.ChatCompletion { - const candidate = response.candidates?.[0]; - const content = candidate?.content; - - let messageContent: string | null = null; - const toolCalls: OpenAI.Chat.ChatCompletionMessageToolCall[] = []; - - if (content?.parts) { - const textParts: string[] = []; - - for (const part of content.parts) { - if ('text' in part && part.text) { - textParts.push(part.text); - } else if ('functionCall' in part && part.functionCall) { - toolCalls.push({ - id: part.functionCall.id || `call_${toolCalls.length}`, - type: 'function' as const, - function: { - name: part.functionCall.name || '', - arguments: JSON.stringify(part.functionCall.args || {}), - }, - }); - } - } - - messageContent = textParts.join('').trimEnd(); - } - - const choice: OpenAI.Chat.ChatCompletion.Choice = { - index: 0, - message: { - role: 'assistant', - content: messageContent, - refusal: null, - }, - finish_reason: this.mapGeminiFinishReasonToOpenAI( - candidate?.finishReason, - ) as OpenAI.Chat.ChatCompletion.Choice['finish_reason'], - logprobs: null, - }; - - if (toolCalls.length > 0) { - choice.message.tool_calls = toolCalls; - } - - const openaiResponse: OpenAI.Chat.ChatCompletion = { - id: response.responseId || `chatcmpl-${Date.now()}`, - object: 'chat.completion', - created: response.createTime - ? Number(response.createTime) - : Math.floor(Date.now() / 1000), - model: this.model, - choices: [choice], - }; - - // Add usage metadata if available - if (response.usageMetadata) { - openaiResponse.usage = { - prompt_tokens: response.usageMetadata.promptTokenCount || 0, - completion_tokens: response.usageMetadata.candidatesTokenCount || 0, - total_tokens: response.usageMetadata.totalTokenCount || 0, - }; - - if (response.usageMetadata.cachedContentTokenCount) { - openaiResponse.usage.prompt_tokens_details = { - cached_tokens: response.usageMetadata.cachedContentTokenCount, - }; - } - } - - return openaiResponse; - } - /** * Map OpenAI finish reasons to Gemini finish reasons */ @@ -923,29 +932,24 @@ export class OpenAIContentConverter { return mapping[openaiReason] || FinishReason.FINISH_REASON_UNSPECIFIED; } - /** - * Map Gemini finish reasons to OpenAI finish reasons - */ - private mapGeminiFinishReasonToOpenAI(geminiReason?: unknown): string { - if (!geminiReason) return 'stop'; + private mapGeminiFinishReasonToOpenAI( + geminiReason?: FinishReason, + ): 'stop' | 'length' | 'tool_calls' | 'content_filter' | 'function_call' { + if (!geminiReason) { + return 'stop'; + } switch (geminiReason) { - case 'STOP': - case 1: // FinishReason.STOP + case FinishReason.STOP: return 'stop'; - case 'MAX_TOKENS': - case 2: // FinishReason.MAX_TOKENS + case FinishReason.MAX_TOKENS: return 'length'; - case 'SAFETY': - case 3: // FinishReason.SAFETY + case FinishReason.SAFETY: return 'content_filter'; - case 'RECITATION': - case 4: // FinishReason.RECITATION - return 'content_filter'; - case 'OTHER': - case 5: // FinishReason.OTHER - return 'stop'; default: + if (geminiReason === ('RECITATION' as FinishReason)) { + return 'content_filter'; + } return 'stop'; } } diff --git a/packages/core/src/core/openaiContentGenerator/errorHandler.test.ts b/packages/core/src/core/openaiContentGenerator/errorHandler.test.ts index b54a9607e..e124d92a2 100644 --- a/packages/core/src/core/openaiContentGenerator/errorHandler.test.ts +++ b/packages/core/src/core/openaiContentGenerator/errorHandler.test.ts @@ -7,7 +7,7 @@ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; import type { GenerateContentParameters } from '@google/genai'; import { EnhancedErrorHandler } from './errorHandler.js'; -import type { RequestContext } from './telemetryService.js'; +import type { RequestContext } from './errorHandler.js'; describe('EnhancedErrorHandler', () => { let errorHandler: EnhancedErrorHandler; diff --git a/packages/core/src/core/openaiContentGenerator/errorHandler.ts b/packages/core/src/core/openaiContentGenerator/errorHandler.ts index 9091116d3..fe74a87a9 100644 --- a/packages/core/src/core/openaiContentGenerator/errorHandler.ts +++ b/packages/core/src/core/openaiContentGenerator/errorHandler.ts @@ -5,7 +5,15 @@ */ import type { GenerateContentParameters } from '@google/genai'; -import type { RequestContext } from './telemetryService.js'; + +export interface RequestContext { + userPromptId: string; + model: string; + authType: string; + startTime: number; + duration: number; + isStreaming: boolean; +} export interface ErrorHandler { handle( diff --git a/packages/core/src/core/openaiContentGenerator/index.ts b/packages/core/src/core/openaiContentGenerator/index.ts index 8559258cb..fee32a049 100644 --- a/packages/core/src/core/openaiContentGenerator/index.ts +++ b/packages/core/src/core/openaiContentGenerator/index.ts @@ -91,11 +91,4 @@ export function determineProvider( return new DefaultOpenAICompatibleProvider(contentGeneratorConfig, cliConfig); } -// Services -export { - type TelemetryService, - type RequestContext, - DefaultTelemetryService, -} from './telemetryService.js'; - export { type ErrorHandler, EnhancedErrorHandler } from './errorHandler.js'; diff --git a/packages/core/src/core/openaiContentGenerator/openaiContentGenerator.test.ts b/packages/core/src/core/openaiContentGenerator/openaiContentGenerator.test.ts index 3d1a516c4..26a0dde01 100644 --- a/packages/core/src/core/openaiContentGenerator/openaiContentGenerator.test.ts +++ b/packages/core/src/core/openaiContentGenerator/openaiContentGenerator.test.ts @@ -99,6 +99,7 @@ describe('OpenAIContentGenerator (Refactored)', () => { }, } as unknown as OpenAI), buildRequest: vi.fn().mockImplementation((req) => req), + getDefaultGenerationConfig: vi.fn().mockReturnValue({}), }; generator = new OpenAIContentGenerator( @@ -211,6 +212,7 @@ describe('OpenAIContentGenerator (Refactored)', () => { }, } as unknown as OpenAI), buildRequest: vi.fn().mockImplementation((req) => req), + getDefaultGenerationConfig: vi.fn().mockReturnValue({}), }; const testGenerator = new TestGenerator( @@ -277,6 +279,7 @@ describe('OpenAIContentGenerator (Refactored)', () => { }, } as unknown as OpenAI), buildRequest: vi.fn().mockImplementation((req) => req), + getDefaultGenerationConfig: vi.fn().mockReturnValue({}), }; const testGenerator = new TestGenerator( diff --git a/packages/core/src/core/openaiContentGenerator/openaiContentGenerator.ts b/packages/core/src/core/openaiContentGenerator/openaiContentGenerator.ts index ae1f43e5e..734ed6afb 100644 --- a/packages/core/src/core/openaiContentGenerator/openaiContentGenerator.ts +++ b/packages/core/src/core/openaiContentGenerator/openaiContentGenerator.ts @@ -11,7 +11,6 @@ import type { } from '@google/genai'; import type { PipelineConfig } from './pipeline.js'; import { ContentGenerationPipeline } from './pipeline.js'; -import { DefaultTelemetryService } from './telemetryService.js'; import { EnhancedErrorHandler } from './errorHandler.js'; import { getDefaultTokenizer } from '../../utils/request-tokenizer/index.js'; import type { ContentGeneratorConfig } from '../contentGenerator.js'; @@ -29,11 +28,6 @@ export class OpenAIContentGenerator implements ContentGenerator { cliConfig, provider, contentGeneratorConfig, - telemetryService: new DefaultTelemetryService( - cliConfig, - contentGeneratorConfig.enableOpenAILogging, - contentGeneratorConfig.openAILoggingDir, - ), errorHandler: new EnhancedErrorHandler( (error: unknown, request: GenerateContentParameters) => this.shouldSuppressErrorLogging(error, request), @@ -154,4 +148,8 @@ export class OpenAIContentGenerator implements ContentGenerator { ); } } + + useSummarizedThinking(): boolean { + return false; + } } diff --git a/packages/core/src/core/openaiContentGenerator/pipeline.test.ts b/packages/core/src/core/openaiContentGenerator/pipeline.test.ts index c92e7a792..93adcb090 100644 --- a/packages/core/src/core/openaiContentGenerator/pipeline.test.ts +++ b/packages/core/src/core/openaiContentGenerator/pipeline.test.ts @@ -15,7 +15,6 @@ import { OpenAIContentConverter } from './converter.js'; import type { Config } from '../../config/config.js'; import type { ContentGeneratorConfig, AuthType } from '../contentGenerator.js'; import type { OpenAICompatibleProvider } from './provider/index.js'; -import type { TelemetryService } from './telemetryService.js'; import type { ErrorHandler } from './errorHandler.js'; // Mock dependencies @@ -28,7 +27,6 @@ describe('ContentGenerationPipeline', () => { let mockProvider: OpenAICompatibleProvider; let mockClient: OpenAI; let mockConverter: OpenAIContentConverter; - let mockTelemetryService: TelemetryService; let mockErrorHandler: ErrorHandler; let mockContentGeneratorConfig: ContentGeneratorConfig; let mockCliConfig: Config; @@ -60,13 +58,7 @@ describe('ContentGenerationPipeline', () => { buildClient: vi.fn().mockReturnValue(mockClient), buildRequest: vi.fn().mockImplementation((req) => req), buildHeaders: vi.fn().mockReturnValue({}), - }; - - // Mock telemetry service - mockTelemetryService = { - logSuccess: vi.fn().mockResolvedValue(undefined), - logError: vi.fn().mockResolvedValue(undefined), - logStreamingSuccess: vi.fn().mockResolvedValue(undefined), + getDefaultGenerationConfig: vi.fn().mockReturnValue({}), }; // Mock error handler @@ -98,7 +90,6 @@ describe('ContentGenerationPipeline', () => { cliConfig: mockCliConfig, provider: mockProvider, contentGeneratorConfig: mockContentGeneratorConfig, - telemetryService: mockTelemetryService, errorHandler: mockErrorHandler, }; @@ -108,7 +99,10 @@ describe('ContentGenerationPipeline', () => { describe('constructor', () => { it('should initialize with correct configuration', () => { expect(mockProvider.buildClient).toHaveBeenCalled(); - expect(OpenAIContentConverter).toHaveBeenCalledWith('test-model'); + expect(OpenAIContentConverter).toHaveBeenCalledWith( + 'test-model', + undefined, + ); }); }); @@ -168,17 +162,6 @@ describe('ContentGenerationPipeline', () => { expect(mockConverter.convertOpenAIResponseToGemini).toHaveBeenCalledWith( mockOpenAIResponse, ); - expect(mockTelemetryService.logSuccess).toHaveBeenCalledWith( - expect.objectContaining({ - userPromptId, - model: 'test-model', - authType: 'openai', - isStreaming: false, - }), - mockGeminiResponse, - expect.any(Object), - mockOpenAIResponse, - ); }); it('should handle tools in request', async () => { @@ -264,16 +247,6 @@ describe('ContentGenerationPipeline', () => { 'API Error', ); - expect(mockTelemetryService.logError).toHaveBeenCalledWith( - expect.objectContaining({ - userPromptId, - model: 'test-model', - authType: 'openai', - isStreaming: false, - }), - testError, - expect.any(Object), - ); expect(mockErrorHandler.handle).toHaveBeenCalledWith( testError, expect.any(Object), @@ -372,17 +345,6 @@ describe('ContentGenerationPipeline', () => { signal: undefined, }), ); - expect(mockTelemetryService.logStreamingSuccess).toHaveBeenCalledWith( - expect.objectContaining({ - userPromptId, - model: 'test-model', - authType: 'openai', - isStreaming: true, - }), - [mockGeminiResponse1, mockGeminiResponse2], - expect.any(Object), - [mockChunk1, mockChunk2], - ); }); it('should filter empty responses', async () => { @@ -486,16 +448,6 @@ describe('ContentGenerationPipeline', () => { expect(results).toHaveLength(0); // No results due to error expect(mockConverter.resetStreamingToolCalls).toHaveBeenCalledTimes(2); // Once at start, once on error - expect(mockTelemetryService.logError).toHaveBeenCalledWith( - expect.objectContaining({ - userPromptId, - model: 'test-model', - authType: 'openai', - isStreaming: true, - }), - testError, - expect.any(Object), - ); expect(mockErrorHandler.handle).toHaveBeenCalledWith( testError, expect.any(Object), @@ -646,18 +598,6 @@ describe('ContentGenerationPipeline', () => { candidatesTokenCount: 20, totalTokenCount: 30, }); - - expect(mockTelemetryService.logStreamingSuccess).toHaveBeenCalledWith( - expect.objectContaining({ - userPromptId, - model: 'test-model', - authType: 'openai', - isStreaming: true, - }), - results, - expect.any(Object), - [mockChunk1, mockChunk2, mockChunk3], - ); }); it('should handle ideal case where last chunk has both finishReason and usageMetadata', async () => { @@ -849,18 +789,6 @@ describe('ContentGenerationPipeline', () => { candidatesTokenCount: 20, totalTokenCount: 30, }); - - expect(mockTelemetryService.logStreamingSuccess).toHaveBeenCalledWith( - expect.objectContaining({ - userPromptId, - model: 'test-model', - authType: 'openai', - isStreaming: true, - }), - results, - expect.any(Object), - [mockChunk1, mockChunk2, mockChunk3], - ); }); it('should handle providers that send finishReason and valid usage in same chunk', async () => { @@ -1114,19 +1042,6 @@ describe('ContentGenerationPipeline', () => { await pipeline.execute(request, userPromptId); // Assert - expect(mockTelemetryService.logSuccess).toHaveBeenCalledWith( - expect.objectContaining({ - userPromptId, - model: 'test-model', - authType: 'openai', - isStreaming: false, - startTime: expect.any(Number), - duration: expect.any(Number), - }), - expect.any(Object), - expect.any(Object), - expect.any(Object), - ); }); it('should create context with correct properties for streaming request', async () => { @@ -1169,19 +1084,6 @@ describe('ContentGenerationPipeline', () => { } // Assert - expect(mockTelemetryService.logStreamingSuccess).toHaveBeenCalledWith( - expect.objectContaining({ - userPromptId, - model: 'test-model', - authType: 'openai', - isStreaming: true, - startTime: expect.any(Number), - duration: expect.any(Number), - }), - expect.any(Array), - expect.any(Object), - expect.any(Array), - ); }); it('should collect all OpenAI chunks for logging even when Gemini responses are filtered', async () => { @@ -1325,22 +1227,6 @@ describe('ContentGenerationPipeline', () => { // Should only yield the final response (empty ones are filtered) expect(responses).toHaveLength(1); expect(responses[0]).toBe(finalGeminiResponse); - - // Verify telemetry was called with ALL OpenAI chunks, including the filtered ones - expect(mockTelemetryService.logStreamingSuccess).toHaveBeenCalledWith( - expect.objectContaining({ - model: 'test-model', - duration: expect.any(Number), - userPromptId: 'test-prompt-id', - authType: 'openai', - }), - [finalGeminiResponse], // Only the non-empty Gemini response - expect.objectContaining({ - model: 'test-model', - messages: [{ role: 'user', content: 'test' }], - }), - [partialToolCallChunk1, partialToolCallChunk2, finishChunk], // ALL OpenAI chunks - ); }); }); }); diff --git a/packages/core/src/core/openaiContentGenerator/pipeline.ts b/packages/core/src/core/openaiContentGenerator/pipeline.ts index c24d247f5..88ac38f6a 100644 --- a/packages/core/src/core/openaiContentGenerator/pipeline.ts +++ b/packages/core/src/core/openaiContentGenerator/pipeline.ts @@ -13,14 +13,12 @@ import type { Config } from '../../config/config.js'; import type { ContentGeneratorConfig } from '../contentGenerator.js'; import type { OpenAICompatibleProvider } from './provider/index.js'; import { OpenAIContentConverter } from './converter.js'; -import type { TelemetryService, RequestContext } from './telemetryService.js'; -import type { ErrorHandler } from './errorHandler.js'; +import type { ErrorHandler, RequestContext } from './errorHandler.js'; export interface PipelineConfig { cliConfig: Config; provider: OpenAICompatibleProvider; contentGeneratorConfig: ContentGeneratorConfig; - telemetryService: TelemetryService; errorHandler: ErrorHandler; } @@ -34,6 +32,7 @@ export class ContentGenerationPipeline { this.client = this.config.provider.buildClient(); this.converter = new OpenAIContentConverter( this.contentGeneratorConfig.model, + this.contentGeneratorConfig.schemaCompliance, ); } @@ -45,7 +44,7 @@ export class ContentGenerationPipeline { request, userPromptId, false, - async (openaiRequest, context) => { + async (openaiRequest) => { const openaiResponse = (await this.client.chat.completions.create( openaiRequest, { @@ -56,14 +55,6 @@ export class ContentGenerationPipeline { const geminiResponse = this.converter.convertOpenAIResponseToGemini(openaiResponse); - // Log success - await this.config.telemetryService.logSuccess( - context, - geminiResponse, - openaiRequest, - openaiResponse, - ); - return geminiResponse; }, ); @@ -87,12 +78,7 @@ export class ContentGenerationPipeline { )) as AsyncIterable; // Stage 2: Process stream with conversion and logging - return this.processStreamWithLogging( - stream, - context, - openaiRequest, - request, - ); + return this.processStreamWithLogging(stream, context, request); }, ); } @@ -109,11 +95,9 @@ export class ContentGenerationPipeline { private async *processStreamWithLogging( stream: AsyncIterable, context: RequestContext, - openaiRequest: OpenAI.Chat.ChatCompletionCreateParams, request: GenerateContentParameters, ): AsyncGenerator { const collectedGeminiResponses: GenerateContentResponse[] = []; - const collectedOpenAIChunks: OpenAI.Chat.ChatCompletionChunk[] = []; // Reset streaming tool calls to prevent data pollution from previous streams this.converter.resetStreamingToolCalls(); @@ -124,9 +108,6 @@ export class ContentGenerationPipeline { try { // Stage 2a: Convert and yield each chunk while preserving original for await (const chunk of stream) { - // Always collect OpenAI chunks for logging, regardless of Gemini conversion result - collectedOpenAIChunks.push(chunk); - const response = this.converter.convertOpenAIChunkToGemini(chunk); // Stage 2b: Filter empty responses to avoid downstream issues @@ -163,15 +144,8 @@ export class ContentGenerationPipeline { yield pendingFinishResponse; } - // Stage 2e: Stream completed successfully - perform logging with original OpenAI chunks + // Stage 2e: Stream completed successfully context.duration = Date.now() - context.startTime; - - await this.config.telemetryService.logStreamingSuccess( - context, - collectedGeminiResponses, - openaiRequest, - collectedOpenAIChunks, - ); } catch (error) { // Clear streaming tool calls on error to prevent data pollution this.converter.resetStreamingToolCalls(); @@ -257,7 +231,7 @@ export class ContentGenerationPipeline { const baseRequest: OpenAI.Chat.ChatCompletionCreateParams = { model: this.contentGeneratorConfig.model, messages, - ...this.buildSamplingParameters(request), + ...this.buildGenerateContentConfig(request), }; // Add streaming options if present @@ -279,19 +253,25 @@ export class ContentGenerationPipeline { return this.config.provider.buildRequest(baseRequest, userPromptId); } - private buildSamplingParameters( + private buildGenerateContentConfig( request: GenerateContentParameters, ): Record { + const defaultSamplingParams = + this.config.provider.getDefaultGenerationConfig(); const configSamplingParams = this.contentGeneratorConfig.samplingParams; // Helper function to get parameter value with priority: config > request > default const getParameterValue = ( configKey: keyof NonNullable, - requestKey: keyof NonNullable, - defaultValue?: T, + requestKey?: keyof NonNullable, ): T | undefined => { const configValue = configSamplingParams?.[configKey] as T | undefined; - const requestValue = request.config?.[requestKey] as T | undefined; + const requestValue = requestKey + ? (request.config?.[requestKey] as T | undefined) + : undefined; + const defaultValue = requestKey + ? (defaultSamplingParams[requestKey] as T) + : undefined; if (configValue !== undefined) return configValue; if (requestValue !== undefined) return requestValue; @@ -303,17 +283,13 @@ export class ContentGenerationPipeline { key: string, configKey: keyof NonNullable, requestKey?: keyof NonNullable, - defaultValue?: T, - ): Record | Record => { - const value = requestKey - ? getParameterValue(configKey, requestKey, defaultValue) - : ((configSamplingParams?.[configKey] as T | undefined) ?? - defaultValue); + ): Record => { + const value = getParameterValue(configKey, requestKey); return value !== undefined ? { [key]: value } : {}; }; - const params = { + const params: Record = { // Parameters with request fallback but no defaults ...addParameterIfDefined('temperature', 'temperature', 'temperature'), ...addParameterIfDefined('top_p', 'top_p', 'topP'), @@ -322,15 +298,36 @@ export class ContentGenerationPipeline { ...addParameterIfDefined('max_tokens', 'max_tokens', 'maxOutputTokens'), // Config-only parameters (no request fallback) - ...addParameterIfDefined('top_k', 'top_k'), + ...addParameterIfDefined('top_k', 'top_k', 'topK'), ...addParameterIfDefined('repetition_penalty', 'repetition_penalty'), - ...addParameterIfDefined('presence_penalty', 'presence_penalty'), - ...addParameterIfDefined('frequency_penalty', 'frequency_penalty'), + ...addParameterIfDefined( + 'presence_penalty', + 'presence_penalty', + 'presencePenalty', + ), + ...addParameterIfDefined( + 'frequency_penalty', + 'frequency_penalty', + 'frequencyPenalty', + ), + ...this.buildReasoningConfig(), }; return params; } + private buildReasoningConfig(): Record { + const reasoning = this.contentGeneratorConfig.reasoning; + + if (reasoning === false) { + return {}; + } + + return { + reasoning_effort: reasoning?.effort ?? 'medium', + }; + } + /** * Common error handling wrapper for execute methods */ @@ -358,13 +355,7 @@ export class ContentGenerationPipeline { return result; } catch (error) { // Use shared error handling logic - return await this.handleError( - error, - context, - request, - userPromptId, - isStreaming, - ); + return await this.handleError(error, context, request); } } @@ -376,37 +367,8 @@ export class ContentGenerationPipeline { error: unknown, context: RequestContext, request: GenerateContentParameters, - userPromptId?: string, - isStreaming?: boolean, ): Promise { context.duration = Date.now() - context.startTime; - - // Build request for logging (may fail, but we still want to log the error) - let openaiRequest: OpenAI.Chat.ChatCompletionCreateParams; - try { - if (userPromptId !== undefined && isStreaming !== undefined) { - openaiRequest = await this.buildRequest( - request, - userPromptId, - isStreaming, - ); - } else { - // For processStreamWithLogging, we don't have userPromptId/isStreaming, - // so create a minimal request - openaiRequest = { - model: this.contentGeneratorConfig.model, - messages: [], - }; - } - } catch (_buildError) { - // If we can't build the request, create a minimal one for logging - openaiRequest = { - model: this.contentGeneratorConfig.model, - messages: [], - }; - } - - await this.config.telemetryService.logError(context, error, openaiRequest); this.config.errorHandler.handle(error, context, request); } diff --git a/packages/core/src/core/openaiContentGenerator/provider/dashscope.ts b/packages/core/src/core/openaiContentGenerator/provider/dashscope.ts index 2df72221c..5658eee47 100644 --- a/packages/core/src/core/openaiContentGenerator/provider/dashscope.ts +++ b/packages/core/src/core/openaiContentGenerator/provider/dashscope.ts @@ -1,4 +1,5 @@ import OpenAI from 'openai'; +import type { GenerateContentConfig } from '@google/genai'; import type { Config } from '../../../config/config.js'; import type { ContentGeneratorConfig } from '../../contentGenerator.js'; import { AuthType } from '../../contentGenerator.js'; @@ -38,7 +39,8 @@ export class DashScopeOpenAICompatibleProvider return ( authType === AuthType.QWEN_OAUTH || baseUrl === 'https://dashscope.aliyuncs.com/compatible-mode/v1' || - baseUrl === 'https://dashscope-intl.aliyuncs.com/compatible-mode/v1' + baseUrl === 'https://dashscope-intl.aliyuncs.com/compatible-mode/v1' || + !baseUrl ); } @@ -130,14 +132,23 @@ export class DashScopeOpenAICompatibleProvider } buildMetadata(userPromptId: string): DashScopeRequestMetadata { + const channel = this.cliConfig.getChannel?.(); + return { metadata: { sessionId: this.cliConfig.getSessionId?.(), promptId: userPromptId, + ...(channel ? { channel } : {}), }, }; } + getDefaultGenerationConfig(): GenerateContentConfig { + return { + temperature: 0.3, + }; + } + /** * Add cache control flag to specified message(s) for DashScope providers */ diff --git a/packages/core/src/core/openaiContentGenerator/provider/deepseek.ts b/packages/core/src/core/openaiContentGenerator/provider/deepseek.ts index 2dd974b76..9b5fd7479 100644 --- a/packages/core/src/core/openaiContentGenerator/provider/deepseek.ts +++ b/packages/core/src/core/openaiContentGenerator/provider/deepseek.ts @@ -8,6 +8,7 @@ import type OpenAI from 'openai'; import type { Config } from '../../../config/config.js'; import type { ContentGeneratorConfig } from '../../contentGenerator.js'; import { DefaultOpenAICompatibleProvider } from './default.js'; +import type { GenerateContentConfig } from '@google/genai'; export class DeepSeekOpenAICompatibleProvider extends DefaultOpenAICompatibleProvider { constructor( @@ -76,4 +77,10 @@ export class DeepSeekOpenAICompatibleProvider extends DefaultOpenAICompatiblePro messages, }; } + + override getDefaultGenerationConfig(): GenerateContentConfig { + return { + temperature: 0, + }; + } } diff --git a/packages/core/src/core/openaiContentGenerator/provider/default.ts b/packages/core/src/core/openaiContentGenerator/provider/default.ts index 1e87aff42..c56069503 100644 --- a/packages/core/src/core/openaiContentGenerator/provider/default.ts +++ b/packages/core/src/core/openaiContentGenerator/provider/default.ts @@ -1,4 +1,5 @@ import OpenAI from 'openai'; +import type { GenerateContentConfig } from '@google/genai'; import type { Config } from '../../../config/config.js'; import type { ContentGeneratorConfig } from '../../contentGenerator.js'; import { DEFAULT_TIMEOUT, DEFAULT_MAX_RETRIES } from '../constants.js'; @@ -55,4 +56,10 @@ export class DefaultOpenAICompatibleProvider ...request, // Preserve all original parameters including sampling params }; } + + getDefaultGenerationConfig(): GenerateContentConfig { + return { + topP: 0.95, + }; + } } diff --git a/packages/core/src/core/openaiContentGenerator/provider/types.ts b/packages/core/src/core/openaiContentGenerator/provider/types.ts index ea7c434d7..6998cb5b7 100644 --- a/packages/core/src/core/openaiContentGenerator/provider/types.ts +++ b/packages/core/src/core/openaiContentGenerator/provider/types.ts @@ -1,3 +1,4 @@ +import type { GenerateContentConfig } from '@google/genai'; import type OpenAI from 'openai'; // Extended types to support cache_control for DashScope @@ -22,11 +23,13 @@ export interface OpenAICompatibleProvider { request: OpenAI.Chat.ChatCompletionCreateParams, userPromptId: string, ): OpenAI.Chat.ChatCompletionCreateParams; + getDefaultGenerationConfig(): GenerateContentConfig; } export type DashScopeRequestMetadata = { metadata: { sessionId?: string; promptId: string; + channel?: string; }; }; diff --git a/packages/core/src/core/openaiContentGenerator/telemetryService.test.ts b/packages/core/src/core/openaiContentGenerator/telemetryService.test.ts deleted file mode 100644 index 6f0f8d09a..000000000 --- a/packages/core/src/core/openaiContentGenerator/telemetryService.test.ts +++ /dev/null @@ -1,1306 +0,0 @@ -/** - * @license - * Copyright 2025 Qwen - * SPDX-License-Identifier: Apache-2.0 - */ - -import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; -import type { RequestContext } from './telemetryService.js'; -import { DefaultTelemetryService } from './telemetryService.js'; -import type { Config } from '../../config/config.js'; -import { logApiError, logApiResponse } from '../../telemetry/loggers.js'; -import { ApiErrorEvent, ApiResponseEvent } from '../../telemetry/types.js'; -import { openaiLogger } from '../../utils/openaiLogger.js'; -import type { GenerateContentResponse } from '@google/genai'; -import type OpenAI from 'openai'; - -// Mock dependencies -vi.mock('../../telemetry/loggers.js'); -vi.mock('../../utils/openaiLogger.js'); - -// Extended error interface for testing -interface ExtendedError extends Error { - requestID?: string; - type?: string; - code?: string; -} - -describe('DefaultTelemetryService', () => { - let mockConfig: Config; - let telemetryService: DefaultTelemetryService; - let mockRequestContext: RequestContext; - - beforeEach(() => { - // Create mock config - mockConfig = { - getSessionId: vi.fn().mockReturnValue('test-session-id'), - } as unknown as Config; - - // Create mock request context - mockRequestContext = { - userPromptId: 'test-prompt-id', - model: 'test-model', - authType: 'test-auth', - startTime: Date.now(), - duration: 1000, - isStreaming: false, - }; - - // Clear all mocks - vi.clearAllMocks(); - }); - - afterEach(() => { - vi.restoreAllMocks(); - }); - - describe('constructor', () => { - it('should create instance with default OpenAI logging disabled', () => { - const service = new DefaultTelemetryService(mockConfig); - expect(service).toBeInstanceOf(DefaultTelemetryService); - }); - - it('should create instance with OpenAI logging enabled when specified', () => { - const service = new DefaultTelemetryService(mockConfig, true); - expect(service).toBeInstanceOf(DefaultTelemetryService); - }); - }); - - describe('logSuccess', () => { - beforeEach(() => { - telemetryService = new DefaultTelemetryService(mockConfig, false); - }); - - it('should log API response event with complete response data', async () => { - const mockResponse: GenerateContentResponse = { - responseId: 'test-response-id', - usageMetadata: { - promptTokenCount: 100, - candidatesTokenCount: 50, - totalTokenCount: 150, - cachedContentTokenCount: 10, - thoughtsTokenCount: 5, - toolUsePromptTokenCount: 20, - }, - } as GenerateContentResponse; - - await telemetryService.logSuccess(mockRequestContext, mockResponse); - - expect(logApiResponse).toHaveBeenCalledWith( - mockConfig, - expect.objectContaining({ - response_id: 'test-response-id', - model: 'test-model', - duration_ms: 1000, - prompt_id: 'test-prompt-id', - auth_type: 'test-auth', - input_token_count: 100, - output_token_count: 50, - total_token_count: 150, - cached_content_token_count: 10, - thoughts_token_count: 5, - tool_token_count: 20, - }), - ); - }); - - it('should handle response without responseId', async () => { - const mockResponse: GenerateContentResponse = { - usageMetadata: { - promptTokenCount: 100, - candidatesTokenCount: 50, - totalTokenCount: 150, - }, - } as GenerateContentResponse; - - await telemetryService.logSuccess(mockRequestContext, mockResponse); - - expect(logApiResponse).toHaveBeenCalledWith( - mockConfig, - expect.objectContaining({ - response_id: 'unknown', - model: 'test-model', - duration_ms: 1000, - prompt_id: 'test-prompt-id', - auth_type: 'test-auth', - }), - ); - }); - - it('should handle response without usage metadata', async () => { - const mockResponse: GenerateContentResponse = { - responseId: 'test-response-id', - } as GenerateContentResponse; - - await telemetryService.logSuccess(mockRequestContext, mockResponse); - - expect(logApiResponse).toHaveBeenCalledWith( - mockConfig, - expect.objectContaining({ - response_id: 'test-response-id', - model: 'test-model', - duration_ms: 1000, - prompt_id: 'test-prompt-id', - auth_type: 'test-auth', - }), - ); - }); - - it('should not log OpenAI interaction when logging is disabled', async () => { - const mockResponse: GenerateContentResponse = { - responseId: 'test-response-id', - } as GenerateContentResponse; - - const mockOpenAIRequest = { - model: 'gpt-4', - messages: [{ role: 'user', content: 'test' }], - } as OpenAI.Chat.ChatCompletionCreateParams; - - const mockOpenAIResponse = { - id: 'test-id', - choices: [{ message: { content: 'response' } }], - } as OpenAI.Chat.ChatCompletion; - - await telemetryService.logSuccess( - mockRequestContext, - mockResponse, - mockOpenAIRequest, - mockOpenAIResponse, - ); - - expect(openaiLogger.logInteraction).not.toHaveBeenCalled(); - }); - - it('should log OpenAI interaction when logging is enabled', async () => { - telemetryService = new DefaultTelemetryService(mockConfig, true); - - const mockResponse: GenerateContentResponse = { - responseId: 'test-response-id', - } as GenerateContentResponse; - - const mockOpenAIRequest = { - model: 'gpt-4', - messages: [{ role: 'user', content: 'test' }], - } as OpenAI.Chat.ChatCompletionCreateParams; - - const mockOpenAIResponse = { - id: 'test-id', - choices: [{ message: { content: 'response' } }], - } as OpenAI.Chat.ChatCompletion; - - await telemetryService.logSuccess( - mockRequestContext, - mockResponse, - mockOpenAIRequest, - mockOpenAIResponse, - ); - - expect(openaiLogger.logInteraction).toHaveBeenCalledWith( - mockOpenAIRequest, - mockOpenAIResponse, - ); - }); - - it('should not log OpenAI interaction when request or response is missing', async () => { - telemetryService = new DefaultTelemetryService(mockConfig, true); - - const mockResponse: GenerateContentResponse = { - responseId: 'test-response-id', - } as GenerateContentResponse; - - const mockOpenAIRequest = { - model: 'gpt-4', - messages: [{ role: 'user', content: 'test' }], - } as OpenAI.Chat.ChatCompletionCreateParams; - - // Test with missing OpenAI response - await telemetryService.logSuccess( - mockRequestContext, - mockResponse, - mockOpenAIRequest, - undefined, - ); - - expect(openaiLogger.logInteraction).not.toHaveBeenCalled(); - - // Test with missing OpenAI request - await telemetryService.logSuccess( - mockRequestContext, - mockResponse, - undefined, - {} as OpenAI.Chat.ChatCompletion, - ); - - expect(openaiLogger.logInteraction).not.toHaveBeenCalled(); - }); - }); - - describe('logError', () => { - beforeEach(() => { - telemetryService = new DefaultTelemetryService(mockConfig, false); - }); - - it('should log API error event with Error instance', async () => { - const error = new Error('Test error message') as ExtendedError; - error.requestID = 'test-request-id'; - error.type = 'TestError'; - error.code = 'TEST_CODE'; - - await telemetryService.logError(mockRequestContext, error); - - expect(logApiError).toHaveBeenCalledWith( - mockConfig, - expect.objectContaining({ - response_id: 'test-request-id', - model: 'test-model', - error: 'Test error message', - duration_ms: 1000, - prompt_id: 'test-prompt-id', - auth_type: 'test-auth', - error_type: 'TestError', - status_code: 'TEST_CODE', - }), - ); - }); - - it('should handle error without requestID', async () => { - const error = new Error('Test error message'); - - await telemetryService.logError(mockRequestContext, error); - - expect(logApiError).toHaveBeenCalledWith( - mockConfig, - expect.objectContaining({ - response_id: 'unknown', - model: 'test-model', - error: 'Test error message', - duration_ms: 1000, - prompt_id: 'test-prompt-id', - auth_type: 'test-auth', - }), - ); - }); - - it('should handle non-Error objects', async () => { - const error = 'String error message'; - - await telemetryService.logError(mockRequestContext, error); - - expect(logApiError).toHaveBeenCalledWith( - mockConfig, - expect.objectContaining({ - response_id: 'unknown', - model: 'test-model', - error: 'String error message', - duration_ms: 1000, - prompt_id: 'test-prompt-id', - auth_type: 'test-auth', - }), - ); - }); - - it('should handle null/undefined errors', async () => { - await telemetryService.logError(mockRequestContext, null); - - expect(logApiError).toHaveBeenCalledWith( - mockConfig, - expect.objectContaining({ - error: 'null', - }), - ); - - await telemetryService.logError(mockRequestContext, undefined); - - expect(logApiError).toHaveBeenCalledWith( - mockConfig, - expect.objectContaining({ - error: 'undefined', - }), - ); - }); - - it('should not log OpenAI interaction when logging is disabled', async () => { - const error = new Error('Test error'); - const mockOpenAIRequest = { - model: 'gpt-4', - messages: [{ role: 'user', content: 'test' }], - } as OpenAI.Chat.ChatCompletionCreateParams; - - await telemetryService.logError( - mockRequestContext, - error, - mockOpenAIRequest, - ); - - expect(openaiLogger.logInteraction).not.toHaveBeenCalled(); - }); - - it('should log OpenAI interaction when logging is enabled', async () => { - telemetryService = new DefaultTelemetryService(mockConfig, true); - - const error = new Error('Test error'); - const mockOpenAIRequest = { - model: 'gpt-4', - messages: [{ role: 'user', content: 'test' }], - } as OpenAI.Chat.ChatCompletionCreateParams; - - await telemetryService.logError( - mockRequestContext, - error, - mockOpenAIRequest, - ); - - expect(openaiLogger.logInteraction).toHaveBeenCalledWith( - mockOpenAIRequest, - undefined, - error, - ); - }); - - it('should not log OpenAI interaction when request is missing', async () => { - telemetryService = new DefaultTelemetryService(mockConfig, true); - - const error = new Error('Test error'); - - await telemetryService.logError(mockRequestContext, error, undefined); - - expect(openaiLogger.logInteraction).not.toHaveBeenCalled(); - }); - }); - - describe('logStreamingSuccess', () => { - beforeEach(() => { - telemetryService = new DefaultTelemetryService(mockConfig, false); - }); - - it('should log streaming success with multiple responses', async () => { - const mockResponses: GenerateContentResponse[] = [ - { - responseId: 'response-1', - usageMetadata: { - promptTokenCount: 50, - candidatesTokenCount: 25, - totalTokenCount: 75, - }, - } as GenerateContentResponse, - { - responseId: 'response-2', - usageMetadata: { - promptTokenCount: 100, - candidatesTokenCount: 50, - totalTokenCount: 150, - cachedContentTokenCount: 10, - thoughtsTokenCount: 5, - toolUsePromptTokenCount: 20, - }, - } as GenerateContentResponse, - { - responseId: 'response-3', - } as GenerateContentResponse, - ]; - - await telemetryService.logStreamingSuccess( - mockRequestContext, - mockResponses, - ); - - expect(logApiResponse).toHaveBeenCalledWith( - mockConfig, - expect.objectContaining({ - response_id: 'response-3', - model: 'test-model', - duration_ms: 1000, - prompt_id: 'test-prompt-id', - auth_type: 'test-auth', - // Should use usage metadata from response-2 (last one with metadata) - input_token_count: 100, - output_token_count: 50, - total_token_count: 150, - cached_content_token_count: 10, - thoughts_token_count: 5, - tool_token_count: 20, - }), - ); - }); - - it('should handle empty responses array', async () => { - const mockResponses: GenerateContentResponse[] = []; - - await telemetryService.logStreamingSuccess( - mockRequestContext, - mockResponses, - ); - - expect(logApiResponse).toHaveBeenCalledWith( - mockConfig, - expect.objectContaining({ - response_id: 'unknown', - model: 'test-model', - duration_ms: 1000, - prompt_id: 'test-prompt-id', - auth_type: 'test-auth', - }), - ); - }); - - it('should handle responses without usage metadata', async () => { - const mockResponses: GenerateContentResponse[] = [ - { - responseId: 'response-1', - } as GenerateContentResponse, - { - responseId: 'response-2', - } as GenerateContentResponse, - ]; - - await telemetryService.logStreamingSuccess( - mockRequestContext, - mockResponses, - ); - - expect(logApiResponse).toHaveBeenCalledWith( - mockConfig, - expect.objectContaining({ - response_id: 'response-2', - model: 'test-model', - duration_ms: 1000, - prompt_id: 'test-prompt-id', - auth_type: 'test-auth', - }), - ); - }); - - it('should use the last response with usage metadata', async () => { - const mockResponses: GenerateContentResponse[] = [ - { - responseId: 'response-1', - usageMetadata: { - promptTokenCount: 50, - candidatesTokenCount: 25, - totalTokenCount: 75, - }, - } as GenerateContentResponse, - { - responseId: 'response-2', - } as GenerateContentResponse, - { - responseId: 'response-3', - usageMetadata: { - promptTokenCount: 100, - candidatesTokenCount: 50, - totalTokenCount: 150, - }, - } as GenerateContentResponse, - { - responseId: 'response-4', - } as GenerateContentResponse, - ]; - - await telemetryService.logStreamingSuccess( - mockRequestContext, - mockResponses, - ); - - expect(logApiResponse).toHaveBeenCalledWith( - mockConfig, - expect.objectContaining({ - response_id: 'response-4', - // Should use usage metadata from response-3 (last one with metadata) - input_token_count: 100, - output_token_count: 50, - total_token_count: 150, - }), - ); - }); - - it('should not log OpenAI interaction when logging is disabled', async () => { - const mockResponses: GenerateContentResponse[] = [ - { responseId: 'response-1' } as GenerateContentResponse, - ]; - - const mockOpenAIRequest = { - model: 'gpt-4', - messages: [{ role: 'user', content: 'test' }], - } as OpenAI.Chat.ChatCompletionCreateParams; - - const mockOpenAIChunks = [ - { - id: 'test-id', - choices: [{ delta: { content: 'response' } }], - } as OpenAI.Chat.ChatCompletionChunk, - ]; - - await telemetryService.logStreamingSuccess( - mockRequestContext, - mockResponses, - mockOpenAIRequest, - mockOpenAIChunks, - ); - - expect(openaiLogger.logInteraction).not.toHaveBeenCalled(); - }); - - it('should log OpenAI interaction when logging is enabled', async () => { - telemetryService = new DefaultTelemetryService(mockConfig, true); - - const mockResponses: GenerateContentResponse[] = [ - { responseId: 'response-1' } as GenerateContentResponse, - ]; - - const mockOpenAIRequest = { - model: 'gpt-4', - messages: [{ role: 'user', content: 'test' }], - } as OpenAI.Chat.ChatCompletionCreateParams; - - const mockOpenAIChunks = [ - { - id: 'test-id', - object: 'chat.completion.chunk', - created: 1234567890, - model: 'gpt-4', - choices: [ - { - index: 0, - delta: { - content: 'Hello', - reasoning_content: 'thinking ', - }, - finish_reason: null, - }, - ], - } as unknown as OpenAI.Chat.ChatCompletionChunk, - { - id: 'test-id', - object: 'chat.completion.chunk', - created: 1234567890, - model: 'gpt-4', - choices: [ - { - index: 0, - delta: { - content: ' world', - reasoning_content: 'more', - }, - finish_reason: 'stop', - }, - ], - usage: { - prompt_tokens: 10, - completion_tokens: 5, - total_tokens: 15, - }, - } as unknown as OpenAI.Chat.ChatCompletionChunk, - ]; - - await telemetryService.logStreamingSuccess( - mockRequestContext, - mockResponses, - mockOpenAIRequest, - mockOpenAIChunks, - ); - - expect(openaiLogger.logInteraction).toHaveBeenCalledWith( - mockOpenAIRequest, - expect.objectContaining({ - id: 'test-id', - object: 'chat.completion', - created: 1234567890, - model: 'gpt-4', - choices: [ - { - index: 0, - message: expect.objectContaining({ - role: 'assistant', - content: 'Hello world', - reasoning_content: 'thinking more', - }), - finish_reason: 'stop', - logprobs: null, - }, - ], - usage: { - prompt_tokens: 10, - completion_tokens: 5, - total_tokens: 15, - }, - }), - ); - }); - - it('should not log OpenAI interaction when request or chunks are missing', async () => { - telemetryService = new DefaultTelemetryService(mockConfig, true); - - const mockResponses: GenerateContentResponse[] = [ - { responseId: 'response-1' } as GenerateContentResponse, - ]; - - const mockOpenAIRequest = { - model: 'gpt-4', - messages: [{ role: 'user', content: 'test' }], - } as OpenAI.Chat.ChatCompletionCreateParams; - - // Test with missing OpenAI chunks - await telemetryService.logStreamingSuccess( - mockRequestContext, - mockResponses, - mockOpenAIRequest, - undefined, - ); - - expect(openaiLogger.logInteraction).not.toHaveBeenCalled(); - - // Test with missing OpenAI request - await telemetryService.logStreamingSuccess( - mockRequestContext, - mockResponses, - undefined, - [] as OpenAI.Chat.ChatCompletionChunk[], - ); - - expect(openaiLogger.logInteraction).not.toHaveBeenCalled(); - - // Test with empty chunks array - await telemetryService.logStreamingSuccess( - mockRequestContext, - mockResponses, - mockOpenAIRequest, - [], - ); - - expect(openaiLogger.logInteraction).not.toHaveBeenCalled(); - }); - }); - - describe('RequestContext interface', () => { - it('should have all required properties', () => { - const context: RequestContext = { - userPromptId: 'test-prompt-id', - model: 'test-model', - authType: 'test-auth', - startTime: Date.now(), - duration: 1000, - isStreaming: false, - }; - - expect(context.userPromptId).toBe('test-prompt-id'); - expect(context.model).toBe('test-model'); - expect(context.authType).toBe('test-auth'); - expect(typeof context.startTime).toBe('number'); - expect(context.duration).toBe(1000); - expect(context.isStreaming).toBe(false); - }); - - it('should support streaming context', () => { - const context: RequestContext = { - userPromptId: 'test-prompt-id', - model: 'test-model', - authType: 'test-auth', - startTime: Date.now(), - duration: 1000, - isStreaming: true, - }; - - expect(context.isStreaming).toBe(true); - }); - }); - - describe('combineOpenAIChunksForLogging', () => { - beforeEach(() => { - telemetryService = new DefaultTelemetryService(mockConfig, true); - }); - - it('should combine simple text chunks correctly', async () => { - const mockResponses: GenerateContentResponse[] = [ - { responseId: 'response-1' } as GenerateContentResponse, - ]; - - const mockOpenAIRequest = { - model: 'gpt-4', - messages: [{ role: 'user', content: 'test' }], - } as OpenAI.Chat.ChatCompletionCreateParams; - - const mockOpenAIChunks = [ - { - id: 'test-id', - object: 'chat.completion.chunk', - created: 1234567890, - model: 'gpt-4', - choices: [ - { - index: 0, - delta: { - content: 'Hello', - reasoning_content: 'thinking ', - }, - finish_reason: null, - }, - ], - } as unknown as OpenAI.Chat.ChatCompletionChunk, - { - id: 'test-id', - object: 'chat.completion.chunk', - created: 1234567890, - model: 'gpt-4', - choices: [ - { - index: 0, - delta: { - content: ' world!', - reasoning_content: 'more', - }, - finish_reason: 'stop', - }, - ], - usage: { - prompt_tokens: 10, - completion_tokens: 5, - total_tokens: 15, - }, - } as unknown as OpenAI.Chat.ChatCompletionChunk, - ]; - - await telemetryService.logStreamingSuccess( - mockRequestContext, - mockResponses, - mockOpenAIRequest, - mockOpenAIChunks, - ); - - expect(openaiLogger.logInteraction).toHaveBeenCalledWith( - mockOpenAIRequest, - expect.objectContaining({ - choices: [ - expect.objectContaining({ - message: expect.objectContaining({ - content: 'Hello world!', - reasoning_content: 'thinking more', - }), - }), - ], - }), - ); - }); - - it('should combine tool call chunks correctly', async () => { - const mockResponses: GenerateContentResponse[] = [ - { responseId: 'response-1' } as GenerateContentResponse, - ]; - - const mockOpenAIRequest = { - model: 'gpt-4', - messages: [{ role: 'user', content: 'test' }], - } as OpenAI.Chat.ChatCompletionCreateParams; - - const mockOpenAIChunks = [ - { - id: 'test-id', - object: 'chat.completion.chunk', - created: 1234567890, - model: 'gpt-4', - choices: [ - { - index: 0, - delta: { - tool_calls: [ - { - index: 0, - id: 'call_123', - type: 'function', - function: { name: 'get_weather', arguments: '' }, - }, - ], - }, - finish_reason: null, - }, - ], - } as OpenAI.Chat.ChatCompletionChunk, - { - id: 'test-id', - object: 'chat.completion.chunk', - created: 1234567890, - model: 'gpt-4', - choices: [ - { - index: 0, - delta: { - tool_calls: [ - { - index: 0, - function: { arguments: '{"location": "' }, - }, - ], - }, - finish_reason: null, - }, - ], - } as OpenAI.Chat.ChatCompletionChunk, - { - id: 'test-id', - object: 'chat.completion.chunk', - created: 1234567890, - model: 'gpt-4', - choices: [ - { - index: 0, - delta: { - tool_calls: [ - { - index: 0, - function: { arguments: 'New York"}' }, - }, - ], - }, - finish_reason: 'tool_calls', - }, - ], - usage: { - prompt_tokens: 15, - completion_tokens: 8, - total_tokens: 23, - }, - } as OpenAI.Chat.ChatCompletionChunk, - ]; - - await telemetryService.logStreamingSuccess( - mockRequestContext, - mockResponses, - mockOpenAIRequest, - mockOpenAIChunks, - ); - - expect(openaiLogger.logInteraction).toHaveBeenCalledWith( - mockOpenAIRequest, - expect.objectContaining({ - id: 'test-id', - object: 'chat.completion', - created: 1234567890, - model: 'gpt-4', - choices: [ - { - index: 0, - message: { - role: 'assistant', - content: null, - refusal: null, - tool_calls: [ - { - id: 'call_123', - type: 'function', - function: { - name: 'get_weather', - arguments: '{"location": "New York"}', - }, - }, - ], - }, - finish_reason: 'tool_calls', - logprobs: null, - }, - ], - usage: { - prompt_tokens: 15, - completion_tokens: 8, - total_tokens: 23, - }, - }), - ); - }); - - it('should handle mixed content and tool calls', async () => { - const mockResponses: GenerateContentResponse[] = [ - { responseId: 'response-1' } as GenerateContentResponse, - ]; - - const mockOpenAIRequest = { - model: 'gpt-4', - messages: [{ role: 'user', content: 'test' }], - } as OpenAI.Chat.ChatCompletionCreateParams; - - const mockOpenAIChunks = [ - { - id: 'test-id', - object: 'chat.completion.chunk', - created: 1234567890, - model: 'gpt-4', - choices: [ - { - index: 0, - delta: { content: 'Let me check the weather. ' }, - finish_reason: null, - }, - ], - } as OpenAI.Chat.ChatCompletionChunk, - { - id: 'test-id', - object: 'chat.completion.chunk', - created: 1234567890, - model: 'gpt-4', - choices: [ - { - index: 0, - delta: { - tool_calls: [ - { - index: 0, - id: 'call_456', - type: 'function', - function: { - name: 'get_weather', - arguments: '{"location": "Paris"}', - }, - }, - ], - }, - finish_reason: 'tool_calls', - }, - ], - usage: { - prompt_tokens: 20, - completion_tokens: 12, - total_tokens: 32, - }, - } as OpenAI.Chat.ChatCompletionChunk, - ]; - - await telemetryService.logStreamingSuccess( - mockRequestContext, - mockResponses, - mockOpenAIRequest, - mockOpenAIChunks, - ); - - expect(openaiLogger.logInteraction).toHaveBeenCalledWith( - mockOpenAIRequest, - expect.objectContaining({ - choices: [ - { - index: 0, - message: { - role: 'assistant', - content: 'Let me check the weather. ', - refusal: null, - tool_calls: [ - { - id: 'call_456', - type: 'function', - function: { - name: 'get_weather', - arguments: '{"location": "Paris"}', - }, - }, - ], - }, - finish_reason: 'tool_calls', - logprobs: null, - }, - ], - }), - ); - }); - - it('should handle chunks with no content or tool calls', async () => { - const mockResponses: GenerateContentResponse[] = [ - { responseId: 'response-1' } as GenerateContentResponse, - ]; - - const mockOpenAIRequest = { - model: 'gpt-4', - messages: [{ role: 'user', content: 'test' }], - } as OpenAI.Chat.ChatCompletionCreateParams; - - const mockOpenAIChunks = [ - { - id: 'test-id', - object: 'chat.completion.chunk', - created: 1234567890, - model: 'gpt-4', - choices: [ - { - index: 0, - delta: {}, - finish_reason: null, - }, - ], - } as OpenAI.Chat.ChatCompletionChunk, - { - id: 'test-id', - object: 'chat.completion.chunk', - created: 1234567890, - model: 'gpt-4', - choices: [ - { - index: 0, - delta: {}, - finish_reason: 'stop', - }, - ], - usage: { - prompt_tokens: 5, - completion_tokens: 0, - total_tokens: 5, - }, - } as OpenAI.Chat.ChatCompletionChunk, - ]; - - await telemetryService.logStreamingSuccess( - mockRequestContext, - mockResponses, - mockOpenAIRequest, - mockOpenAIChunks, - ); - - expect(openaiLogger.logInteraction).toHaveBeenCalledWith( - mockOpenAIRequest, - expect.objectContaining({ - choices: [ - { - index: 0, - message: { - role: 'assistant', - content: null, - refusal: null, - }, - finish_reason: 'stop', - logprobs: null, - }, - ], - }), - ); - }); - - it('should use default values when usage is missing', async () => { - const mockResponses: GenerateContentResponse[] = [ - { responseId: 'response-1' } as GenerateContentResponse, - ]; - - const mockOpenAIRequest = { - model: 'gpt-4', - messages: [{ role: 'user', content: 'test' }], - } as OpenAI.Chat.ChatCompletionCreateParams; - - const mockOpenAIChunks = [ - { - id: 'test-id', - object: 'chat.completion.chunk', - created: 1234567890, - model: 'gpt-4', - choices: [ - { - index: 0, - delta: { content: 'Hello' }, - finish_reason: 'stop', - }, - ], - } as OpenAI.Chat.ChatCompletionChunk, - ]; - - await telemetryService.logStreamingSuccess( - mockRequestContext, - mockResponses, - mockOpenAIRequest, - mockOpenAIChunks, - ); - - expect(openaiLogger.logInteraction).toHaveBeenCalledWith( - mockOpenAIRequest, - expect.objectContaining({ - usage: { - prompt_tokens: 0, - completion_tokens: 0, - total_tokens: 0, - }, - }), - ); - }); - - it('should use default finish_reason when missing', async () => { - const mockResponses: GenerateContentResponse[] = [ - { responseId: 'response-1' } as GenerateContentResponse, - ]; - - const mockOpenAIRequest = { - model: 'gpt-4', - messages: [{ role: 'user', content: 'test' }], - } as OpenAI.Chat.ChatCompletionCreateParams; - - const mockOpenAIChunks = [ - { - id: 'test-id', - object: 'chat.completion.chunk', - created: 1234567890, - model: 'gpt-4', - choices: [ - { - index: 0, - delta: { content: 'Hello' }, - finish_reason: null, - }, - ], - } as OpenAI.Chat.ChatCompletionChunk, - ]; - - await telemetryService.logStreamingSuccess( - mockRequestContext, - mockResponses, - mockOpenAIRequest, - mockOpenAIChunks, - ); - - expect(openaiLogger.logInteraction).toHaveBeenCalledWith( - mockOpenAIRequest, - expect.objectContaining({ - choices: [ - { - index: 0, - message: expect.any(Object), - finish_reason: 'stop', - logprobs: null, - }, - ], - }), - ); - }); - - it('should filter out empty tool calls', async () => { - const mockResponses: GenerateContentResponse[] = [ - { responseId: 'response-1' } as GenerateContentResponse, - ]; - - const mockOpenAIRequest = { - model: 'gpt-4', - messages: [{ role: 'user', content: 'test' }], - } as OpenAI.Chat.ChatCompletionCreateParams; - - const mockOpenAIChunks = [ - { - id: 'test-id', - object: 'chat.completion.chunk', - created: 1234567890, - model: 'gpt-4', - choices: [ - { - index: 0, - delta: { - tool_calls: [ - { - index: 0, - id: '', // Empty ID should be filtered out - type: 'function', - function: { name: 'test', arguments: '{}' }, - }, - { - index: 1, - id: 'call_valid', - type: 'function', - function: { name: 'valid_call', arguments: '{}' }, - }, - ], - }, - finish_reason: 'tool_calls', - }, - ], - } as OpenAI.Chat.ChatCompletionChunk, - ]; - - await telemetryService.logStreamingSuccess( - mockRequestContext, - mockResponses, - mockOpenAIRequest, - mockOpenAIChunks, - ); - - expect(openaiLogger.logInteraction).toHaveBeenCalledWith( - mockOpenAIRequest, - expect.objectContaining({ - choices: [ - { - index: 0, - message: { - role: 'assistant', - content: null, - refusal: null, - tool_calls: [ - { - id: 'call_valid', - type: 'function', - function: { - name: 'valid_call', - arguments: '{}', - }, - }, - ], - }, - finish_reason: 'tool_calls', - logprobs: null, - }, - ], - }), - ); - }); - }); - - describe('integration with telemetry events', () => { - beforeEach(() => { - telemetryService = new DefaultTelemetryService(mockConfig, false); - }); - - it('should create ApiResponseEvent with correct structure', async () => { - const mockResponse: GenerateContentResponse = { - responseId: 'test-response-id', - usageMetadata: { - promptTokenCount: 100, - candidatesTokenCount: 50, - totalTokenCount: 150, - }, - } as GenerateContentResponse; - - await telemetryService.logSuccess(mockRequestContext, mockResponse); - - expect(logApiResponse).toHaveBeenCalledWith( - mockConfig, - expect.any(ApiResponseEvent), - ); - - const mockLogApiResponse = vi.mocked(logApiResponse); - const callArgs = mockLogApiResponse.mock.calls[0]; - const event = callArgs[1] as ApiResponseEvent; - - expect(event['event.name']).toBe('api_response'); - expect(event['event.timestamp']).toBeDefined(); - expect(event.response_id).toBe('test-response-id'); - expect(event.model).toBe('test-model'); - expect(event.duration_ms).toBe(1000); - expect(event.prompt_id).toBe('test-prompt-id'); - expect(event.auth_type).toBe('test-auth'); - }); - - it('should create ApiErrorEvent with correct structure', async () => { - const error = new Error('Test error message') as ExtendedError; - error.requestID = 'test-request-id'; - error.type = 'TestError'; - error.code = 'TEST_CODE'; - - await telemetryService.logError(mockRequestContext, error); - - expect(logApiError).toHaveBeenCalledWith( - mockConfig, - expect.any(ApiErrorEvent), - ); - - const mockLogApiError = vi.mocked(logApiError); - const callArgs = mockLogApiError.mock.calls[0]; - const event = callArgs[1] as ApiErrorEvent; - - expect(event['event.name']).toBe('api_error'); - expect(event['event.timestamp']).toBeDefined(); - expect(event.response_id).toBe('test-request-id'); - expect(event.model).toBe('test-model'); - expect(event.error).toBe('Test error message'); - expect(event.duration_ms).toBe(1000); - expect(event.prompt_id).toBe('test-prompt-id'); - expect(event.auth_type).toBe('test-auth'); - expect(event.error_type).toBe('TestError'); - expect(event.status_code).toBe('TEST_CODE'); - }); - }); -}); diff --git a/packages/core/src/core/openaiContentGenerator/telemetryService.ts b/packages/core/src/core/openaiContentGenerator/telemetryService.ts deleted file mode 100644 index 66a96ad07..000000000 --- a/packages/core/src/core/openaiContentGenerator/telemetryService.ts +++ /dev/null @@ -1,275 +0,0 @@ -/** - * @license - * Copyright 2025 Qwen - * SPDX-License-Identifier: Apache-2.0 - */ - -import type { Config } from '../../config/config.js'; -import { logApiError, logApiResponse } from '../../telemetry/loggers.js'; -import { ApiErrorEvent, ApiResponseEvent } from '../../telemetry/types.js'; -import { OpenAILogger } from '../../utils/openaiLogger.js'; -import type { GenerateContentResponse } from '@google/genai'; -import type OpenAI from 'openai'; -import type { ExtendedCompletionChunkDelta } from './converter.js'; - -export interface RequestContext { - userPromptId: string; - model: string; - authType: string; - startTime: number; - duration: number; - isStreaming: boolean; -} - -export interface TelemetryService { - logSuccess( - context: RequestContext, - response: GenerateContentResponse, - openaiRequest?: OpenAI.Chat.ChatCompletionCreateParams, - openaiResponse?: OpenAI.Chat.ChatCompletion, - ): Promise; - - logError( - context: RequestContext, - error: unknown, - openaiRequest?: OpenAI.Chat.ChatCompletionCreateParams, - ): Promise; - - logStreamingSuccess( - context: RequestContext, - responses: GenerateContentResponse[], - openaiRequest?: OpenAI.Chat.ChatCompletionCreateParams, - openaiChunks?: OpenAI.Chat.ChatCompletionChunk[], - ): Promise; -} - -export class DefaultTelemetryService implements TelemetryService { - private logger: OpenAILogger; - - constructor( - private config: Config, - private enableOpenAILogging: boolean = false, - openAILoggingDir?: string, - ) { - // Always create a new logger instance to ensure correct working directory - // If no custom directory is provided, undefined will use the default path - this.logger = new OpenAILogger(openAILoggingDir); - } - - async logSuccess( - context: RequestContext, - response: GenerateContentResponse, - openaiRequest?: OpenAI.Chat.ChatCompletionCreateParams, - openaiResponse?: OpenAI.Chat.ChatCompletion, - ): Promise { - // Log API response event for UI telemetry - const responseEvent = new ApiResponseEvent( - response.responseId || 'unknown', - context.model, - context.duration, - context.userPromptId, - context.authType, - response.usageMetadata, - ); - - logApiResponse(this.config, responseEvent); - - // Log interaction if enabled - if (this.enableOpenAILogging && openaiRequest && openaiResponse) { - await this.logger.logInteraction(openaiRequest, openaiResponse); - } - } - - async logError( - context: RequestContext, - error: unknown, - openaiRequest?: OpenAI.Chat.ChatCompletionCreateParams, - ): Promise { - const errorMessage = error instanceof Error ? error.message : String(error); - - // Log API error event for UI telemetry - const errorEvent = new ApiErrorEvent( - // eslint-disable-next-line @typescript-eslint/no-explicit-any - (error as any)?.requestID || 'unknown', - context.model, - errorMessage, - context.duration, - context.userPromptId, - context.authType, - // eslint-disable-next-line @typescript-eslint/no-explicit-any - (error as any)?.type, - // eslint-disable-next-line @typescript-eslint/no-explicit-any - (error as any)?.code, - ); - logApiError(this.config, errorEvent); - - // Log error interaction if enabled - if (this.enableOpenAILogging && openaiRequest) { - await this.logger.logInteraction( - openaiRequest, - undefined, - error as Error, - ); - } - } - - async logStreamingSuccess( - context: RequestContext, - responses: GenerateContentResponse[], - openaiRequest?: OpenAI.Chat.ChatCompletionCreateParams, - openaiChunks?: OpenAI.Chat.ChatCompletionChunk[], - ): Promise { - // Get final usage metadata from the last response that has it - const finalUsageMetadata = responses - .slice() - .reverse() - .find((r) => r.usageMetadata)?.usageMetadata; - - // Log API response event for UI telemetry - const responseEvent = new ApiResponseEvent( - responses[responses.length - 1]?.responseId || 'unknown', - context.model, - context.duration, - context.userPromptId, - context.authType, - finalUsageMetadata, - ); - - logApiResponse(this.config, responseEvent); - - // Log interaction if enabled - combine chunks only when needed - if ( - this.enableOpenAILogging && - openaiRequest && - openaiChunks && - openaiChunks.length > 0 - ) { - const combinedResponse = this.combineOpenAIChunksForLogging(openaiChunks); - await this.logger.logInteraction(openaiRequest, combinedResponse); - } - } - - /** - * Combine OpenAI chunks for logging purposes - * This method consolidates all OpenAI stream chunks into a single ChatCompletion response - * for telemetry and logging purposes, avoiding unnecessary format conversions - */ - private combineOpenAIChunksForLogging( - chunks: OpenAI.Chat.ChatCompletionChunk[], - ): OpenAI.Chat.ChatCompletion { - if (chunks.length === 0) { - throw new Error('No chunks to combine'); - } - - const firstChunk = chunks[0]; - - // Combine all content from chunks - let combinedContent = ''; - const toolCalls: OpenAI.Chat.ChatCompletionMessageToolCall[] = []; - let finishReason: - | 'stop' - | 'length' - | 'tool_calls' - | 'content_filter' - | 'function_call' - | null = null; - let combinedReasoning = ''; - let usage: - | { - prompt_tokens: number; - completion_tokens: number; - total_tokens: number; - } - | undefined; - - for (const chunk of chunks) { - const choice = chunk.choices?.[0]; - if (choice) { - // Combine reasoning content - const reasoningContent = (choice.delta as ExtendedCompletionChunkDelta) - ?.reasoning_content; - if (reasoningContent) { - combinedReasoning += reasoningContent; - } - // Combine text content - if (choice.delta?.content) { - combinedContent += choice.delta.content; - } - - // Collect tool calls - if (choice.delta?.tool_calls) { - for (const toolCall of choice.delta.tool_calls) { - if (toolCall.index !== undefined) { - if (!toolCalls[toolCall.index]) { - toolCalls[toolCall.index] = { - id: toolCall.id || '', - type: toolCall.type || 'function', - function: { name: '', arguments: '' }, - }; - } - - if (toolCall.function?.name) { - toolCalls[toolCall.index].function.name += - toolCall.function.name; - } - if (toolCall.function?.arguments) { - toolCalls[toolCall.index].function.arguments += - toolCall.function.arguments; - } - } - } - } - - // Get finish reason from the last chunk - if (choice.finish_reason) { - finishReason = choice.finish_reason; - } - } - - // Get usage from the last chunk that has it - if (chunk.usage) { - usage = chunk.usage; - } - } - - // Create the combined ChatCompletion response - const message: OpenAI.Chat.ChatCompletionMessage = { - role: 'assistant', - content: combinedContent || null, - refusal: null, - }; - if (combinedReasoning) { - // Attach reasoning content if any thought tokens were streamed - (message as { reasoning_content?: string }).reasoning_content = - combinedReasoning; - } - - // Add tool calls if any - if (toolCalls.length > 0) { - message.tool_calls = toolCalls.filter((tc) => tc.id); // Filter out empty tool calls - } - - const combinedResponse: OpenAI.Chat.ChatCompletion = { - id: firstChunk.id, - object: 'chat.completion', - created: firstChunk.created, - model: firstChunk.model, - choices: [ - { - index: 0, - message, - finish_reason: finishReason || 'stop', - logprobs: null, - }, - ], - usage: usage || { - prompt_tokens: 0, - completion_tokens: 0, - total_tokens: 0, - }, - system_fingerprint: firstChunk.system_fingerprint, - }; - - return combinedResponse; - } -} diff --git a/packages/core/src/core/turn.test.ts b/packages/core/src/core/turn.test.ts index a79dad037..7d687a17b 100644 --- a/packages/core/src/core/turn.test.ts +++ b/packages/core/src/core/turn.test.ts @@ -36,13 +36,6 @@ vi.mock('../utils/errorReporting', () => ({ reportError: vi.fn(), })); -// Use the actual implementation from partUtils now that it's provided. -vi.mock('../utils/generateContentResponseUtilities', () => ({ - getResponseText: (resp: GenerateContentResponse) => - resp.candidates?.[0]?.content?.parts?.map((part) => part.text).join('') || - undefined, -})); - describe('Turn', () => { let turn: Turn; // Define a type for the mocked Chat instance for clarity @@ -156,6 +149,7 @@ describe('Turn', () => { type: GeminiEventType.Thought, value: { subject: '', description: 'reasoning...' }, }, + { type: GeminiEventType.Content, value: 'final answer' }, ]); }); diff --git a/packages/core/src/core/turn.ts b/packages/core/src/core/turn.ts index edd9b24ee..b600d3d99 100644 --- a/packages/core/src/core/turn.ts +++ b/packages/core/src/core/turn.ts @@ -27,7 +27,11 @@ import { toFriendlyError, } from '../utils/errors.js'; import type { GeminiChat } from './geminiChat.js'; -import { getThoughtText, type ThoughtSummary } from '../utils/thoughtUtils.js'; +import { + getThoughtText, + parseThought, + type ThoughtSummary, +} from '../utils/thoughtUtils.js'; // Define a structure for tools passed to the server export interface ServerTool { @@ -266,13 +270,12 @@ export class Turn { this.currentResponseId = resp.responseId; } - const thoughtPart = getThoughtText(resp); - if (thoughtPart) { + const thoughtText = getThoughtText(resp); + if (thoughtText) { yield { type: GeminiEventType.Thought, - value: { subject: '', description: thoughtPart }, + value: parseThought(thoughtText), }; - continue; } const text = getResponseText(resp); diff --git a/packages/core/src/fallback/handler.test.ts b/packages/core/src/fallback/handler.test.ts index 77c937564..f0021afda 100644 --- a/packages/core/src/fallback/handler.test.ts +++ b/packages/core/src/fallback/handler.test.ts @@ -4,36 +4,10 @@ * SPDX-License-Identifier: Apache-2.0 */ -import { - describe, - it, - expect, - vi, - beforeEach, - type Mock, - type MockInstance, - afterEach, -} from 'vitest'; +import { describe, it, expect, vi, beforeEach } from 'vitest'; import { handleFallback } from './handler.js'; import type { Config } from '../config/config.js'; import { AuthType } from '../core/contentGenerator.js'; -import { - DEFAULT_GEMINI_FLASH_MODEL, - DEFAULT_GEMINI_MODEL, -} from '../config/models.js'; -import { logFlashFallback } from '../telemetry/index.js'; -import type { FallbackModelHandler } from './types.js'; - -// Mock the telemetry logger and event class -vi.mock('../telemetry/index.js', () => ({ - logFlashFallback: vi.fn(), - FlashFallbackEvent: class {}, -})); - -const MOCK_PRO_MODEL = DEFAULT_GEMINI_MODEL; -const FALLBACK_MODEL = DEFAULT_GEMINI_FLASH_MODEL; -const AUTH_OAUTH = AuthType.LOGIN_WITH_GOOGLE; -const AUTH_API_KEY = AuthType.USE_GEMINI; const createMockConfig = (overrides: Partial = {}): Config => ({ @@ -45,174 +19,28 @@ const createMockConfig = (overrides: Partial = {}): Config => describe('handleFallback', () => { let mockConfig: Config; - let mockHandler: Mock; - let consoleErrorSpy: MockInstance; beforeEach(() => { vi.clearAllMocks(); - mockHandler = vi.fn(); - // Default setup: OAuth user, Pro model failed, handler injected - mockConfig = createMockConfig({ - fallbackModelHandler: mockHandler, - }); - consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation(() => {}); + mockConfig = createMockConfig(); }); - afterEach(() => { - consoleErrorSpy.mockRestore(); - }); - - it('should return null immediately if authType is not OAuth', async () => { + it('should return null for unknown auth types', async () => { const result = await handleFallback( mockConfig, - MOCK_PRO_MODEL, - AUTH_API_KEY, + 'test-model', + 'unknown-auth', ); expect(result).toBeNull(); - expect(mockHandler).not.toHaveBeenCalled(); - expect(mockConfig.setFallbackMode).not.toHaveBeenCalled(); }); - it('should return null if the failed model is already the fallback model', async () => { + it('should handle Qwen OAuth error', async () => { const result = await handleFallback( mockConfig, - FALLBACK_MODEL, // Failed model is Flash - AUTH_OAUTH, + 'test-model', + AuthType.QWEN_OAUTH, + new Error('unauthorized'), ); expect(result).toBeNull(); - expect(mockHandler).not.toHaveBeenCalled(); - }); - - it('should return null if no fallbackHandler is injected in config', async () => { - const configWithoutHandler = createMockConfig({ - fallbackModelHandler: undefined, - }); - const result = await handleFallback( - configWithoutHandler, - MOCK_PRO_MODEL, - AUTH_OAUTH, - ); - expect(result).toBeNull(); - }); - - describe('when handler returns "retry"', () => { - it('should activate fallback mode, log telemetry, and return true', async () => { - mockHandler.mockResolvedValue('retry'); - - const result = await handleFallback( - mockConfig, - MOCK_PRO_MODEL, - AUTH_OAUTH, - ); - - expect(result).toBe(true); - expect(mockConfig.setFallbackMode).toHaveBeenCalledWith(true); - expect(logFlashFallback).toHaveBeenCalled(); - }); - }); - - describe('when handler returns "stop"', () => { - it('should activate fallback mode, log telemetry, and return false', async () => { - mockHandler.mockResolvedValue('stop'); - - const result = await handleFallback( - mockConfig, - MOCK_PRO_MODEL, - AUTH_OAUTH, - ); - - expect(result).toBe(false); - expect(mockConfig.setFallbackMode).toHaveBeenCalledWith(true); - expect(logFlashFallback).toHaveBeenCalled(); - }); - }); - - describe('when handler returns "auth"', () => { - it('should NOT activate fallback mode and return false', async () => { - mockHandler.mockResolvedValue('auth'); - - const result = await handleFallback( - mockConfig, - MOCK_PRO_MODEL, - AUTH_OAUTH, - ); - - expect(result).toBe(false); - expect(mockConfig.setFallbackMode).not.toHaveBeenCalled(); - expect(logFlashFallback).not.toHaveBeenCalled(); - }); - }); - - describe('when handler returns an unexpected value', () => { - it('should log an error and return null', async () => { - mockHandler.mockResolvedValue(null); - - const result = await handleFallback( - mockConfig, - MOCK_PRO_MODEL, - AUTH_OAUTH, - ); - - expect(result).toBeNull(); - expect(consoleErrorSpy).toHaveBeenCalledWith( - 'Fallback UI handler failed:', - new Error( - 'Unexpected fallback intent received from fallbackModelHandler: "null"', - ), - ); - expect(mockConfig.setFallbackMode).not.toHaveBeenCalled(); - }); - }); - - it('should pass the correct context (failedModel, fallbackModel, error) to the handler', async () => { - const mockError = new Error('Quota Exceeded'); - mockHandler.mockResolvedValue('retry'); - - await handleFallback(mockConfig, MOCK_PRO_MODEL, AUTH_OAUTH, mockError); - - expect(mockHandler).toHaveBeenCalledWith( - MOCK_PRO_MODEL, - FALLBACK_MODEL, - mockError, - ); - }); - - it('should not call setFallbackMode or log telemetry if already in fallback mode', async () => { - // Setup config where fallback mode is already active - const activeFallbackConfig = createMockConfig({ - fallbackModelHandler: mockHandler, - isInFallbackMode: vi.fn(() => true), // Already active - setFallbackMode: vi.fn(), - }); - - mockHandler.mockResolvedValue('retry'); - - const result = await handleFallback( - activeFallbackConfig, - MOCK_PRO_MODEL, - AUTH_OAUTH, - ); - - // Should still return true to allow the retry (which will use the active fallback mode) - expect(result).toBe(true); - // Should still consult the handler - expect(mockHandler).toHaveBeenCalled(); - // But should not mutate state or log telemetry again - expect(activeFallbackConfig.setFallbackMode).not.toHaveBeenCalled(); - expect(logFlashFallback).not.toHaveBeenCalled(); - }); - - it('should catch errors from the handler, log an error, and return null', async () => { - const handlerError = new Error('UI interaction failed'); - mockHandler.mockRejectedValue(handlerError); - - const result = await handleFallback(mockConfig, MOCK_PRO_MODEL, AUTH_OAUTH); - - expect(result).toBeNull(); - expect(consoleErrorSpy).toHaveBeenCalledWith( - 'Fallback UI handler failed:', - handlerError, - ); - expect(mockConfig.setFallbackMode).not.toHaveBeenCalled(); }); }); diff --git a/packages/core/src/fallback/handler.ts b/packages/core/src/fallback/handler.ts index 0a8f499e5..375ce252f 100644 --- a/packages/core/src/fallback/handler.ts +++ b/packages/core/src/fallback/handler.ts @@ -6,8 +6,6 @@ import type { Config } from '../config/config.js'; import { AuthType } from '../core/contentGenerator.js'; -import { DEFAULT_GEMINI_FLASH_MODEL } from '../config/models.js'; -import { logFlashFallback, FlashFallbackEvent } from '../telemetry/index.js'; export async function handleFallback( config: Config, @@ -20,48 +18,7 @@ export async function handleFallback( return handleQwenOAuthError(error); } - // Applicability Checks - if (authType !== AuthType.LOGIN_WITH_GOOGLE) return null; - - const fallbackModel = DEFAULT_GEMINI_FLASH_MODEL; - - if (failedModel === fallbackModel) return null; - - // Consult UI Handler for Intent - const fallbackModelHandler = config.fallbackModelHandler; - if (typeof fallbackModelHandler !== 'function') return null; - - try { - // Pass the specific failed model to the UI handler. - const intent = await fallbackModelHandler( - failedModel, - fallbackModel, - error, - ); - - // Process Intent and Update State - switch (intent) { - case 'retry': - // Activate fallback mode. The NEXT retry attempt will pick this up. - activateFallbackMode(config, authType); - return true; // Signal retryWithBackoff to continue. - - case 'stop': - activateFallbackMode(config, authType); - return false; - - case 'auth': - return false; - - default: - throw new Error( - `Unexpected fallback intent received from fallbackModelHandler: "${intent}"`, - ); - } - } catch (handlerError) { - console.error('Fallback UI handler failed:', handlerError); - return null; - } + return null; } /** @@ -118,12 +75,3 @@ async function handleQwenOAuthError(error?: unknown): Promise { // For other errors, don't handle them specially return null; } - -function activateFallbackMode(config: Config, authType: string | undefined) { - if (!config.isInFallbackMode()) { - config.setFallbackMode(true); - if (authType) { - logFlashFallback(config, new FlashFallbackEvent(authType)); - } - } -} diff --git a/packages/core/src/ide/ide-client.test.ts b/packages/core/src/ide/ide-client.test.ts index ca26f78f1..72f780896 100644 --- a/packages/core/src/ide/ide-client.test.ts +++ b/packages/core/src/ide/ide-client.test.ts @@ -32,6 +32,7 @@ vi.mock('node:fs', async (importOriginal) => { ...actual.promises, readFile: vi.fn(), readdir: vi.fn(), + stat: vi.fn(), }, realpathSync: (p: string) => p, existsSync: () => false, @@ -68,6 +69,7 @@ describe('IdeClient', () => { command: 'test-ide', }); vi.mocked(os.tmpdir).mockReturnValue('/tmp'); + vi.mocked(os.homedir).mockReturnValue('/home/test'); // Mock MCP client and transports mockClient = { @@ -97,19 +99,15 @@ describe('IdeClient', () => { describe('connect', () => { it('should connect using HTTP when port is provided in config file', async () => { + process.env['QWEN_CODE_IDE_SERVER_PORT'] = '8080'; const config = { port: '8080' }; vi.mocked(fs.promises.readFile).mockResolvedValue(JSON.stringify(config)); - ( - vi.mocked(fs.promises.readdir) as Mock< - (path: fs.PathLike) => Promise - > - ).mockResolvedValue([]); const ideClient = await IdeClient.getInstance(); await ideClient.connect(); expect(fs.promises.readFile).toHaveBeenCalledWith( - path.join('/tmp', 'qwen-code-ide-server-12345.json'), + path.join('/home/test', '.qwen', 'ide', '8080.lock'), 'utf8', ); expect(StreamableHTTPClientTransport).toHaveBeenCalledWith( @@ -120,16 +118,13 @@ describe('IdeClient', () => { expect(ideClient.getConnectionStatus().status).toBe( IDEConnectionStatus.Connected, ); + delete process.env['QWEN_CODE_IDE_SERVER_PORT']; }); it('should connect using stdio when stdio config is provided in file', async () => { + process.env['QWEN_CODE_IDE_SERVER_PORT'] = '8080'; const config = { stdio: { command: 'test-cmd', args: ['--foo'] } }; vi.mocked(fs.promises.readFile).mockResolvedValue(JSON.stringify(config)); - ( - vi.mocked(fs.promises.readdir) as Mock< - (path: fs.PathLike) => Promise - > - ).mockResolvedValue([]); const ideClient = await IdeClient.getInstance(); await ideClient.connect(); @@ -142,19 +137,16 @@ describe('IdeClient', () => { expect(ideClient.getConnectionStatus().status).toBe( IDEConnectionStatus.Connected, ); + delete process.env['QWEN_CODE_IDE_SERVER_PORT']; }); it('should prioritize port over stdio when both are in config file', async () => { + process.env['QWEN_CODE_IDE_SERVER_PORT'] = '8080'; const config = { port: '8080', stdio: { command: 'test-cmd', args: ['--foo'] }, }; vi.mocked(fs.promises.readFile).mockResolvedValue(JSON.stringify(config)); - ( - vi.mocked(fs.promises.readdir) as Mock< - (path: fs.PathLike) => Promise - > - ).mockResolvedValue([]); const ideClient = await IdeClient.getInstance(); await ideClient.connect(); @@ -164,6 +156,7 @@ describe('IdeClient', () => { expect(ideClient.getConnectionStatus().status).toBe( IDEConnectionStatus.Connected, ); + delete process.env['QWEN_CODE_IDE_SERVER_PORT']; }); it('should connect using HTTP when port is provided in environment variables', async () => { @@ -263,7 +256,8 @@ describe('IdeClient', () => { }); describe('getConnectionConfigFromFile', () => { - it('should return config from the specific pid file if it exists', async () => { + it('should return config from the env port lock file if it exists', async () => { + process.env['QWEN_CODE_IDE_SERVER_PORT'] = '1234'; const config = { port: '1234', workspacePath: '/test/workspace' }; vi.mocked(fs.promises.readFile).mockResolvedValue(JSON.stringify(config)); @@ -277,18 +271,14 @@ describe('IdeClient', () => { expect(result).toEqual(config); expect(fs.promises.readFile).toHaveBeenCalledWith( - path.join('/tmp', 'qwen-code-ide-server-12345.json'), + path.join('/home/test', '.qwen', 'ide', '1234.lock'), 'utf8', ); + delete process.env['QWEN_CODE_IDE_SERVER_PORT']; }); it('should return undefined if no config files are found', async () => { vi.mocked(fs.promises.readFile).mockRejectedValue(new Error('not found')); - ( - vi.mocked(fs.promises.readdir) as Mock< - (path: fs.PathLike) => Promise - > - ).mockResolvedValue([]); const ideClient = await IdeClient.getInstance(); const result = await ( @@ -300,20 +290,15 @@ describe('IdeClient', () => { expect(result).toBeUndefined(); }); - it('should find and parse a single config file with the new naming scheme', async () => { - const config = { port: '5678', workspacePath: '/test/workspace' }; - vi.mocked(fs.promises.readFile).mockRejectedValueOnce( - new Error('not found'), - ); // For old path - ( - vi.mocked(fs.promises.readdir) as Mock< - (path: fs.PathLike) => Promise - > - ).mockResolvedValue(['qwen-code-ide-server-12345-123.json']); - vi.mocked(fs.promises.readFile).mockResolvedValue(JSON.stringify(config)); - vi.spyOn(IdeClient, 'validateWorkspacePath').mockReturnValue({ - isValid: true, - }); + it('should read legacy pid config when available', async () => { + const config = { + port: '5678', + workspacePath: '/test/workspace', + ppid: 12345, + }; + vi.mocked(fs.promises.readFile).mockResolvedValueOnce( + JSON.stringify(config), + ); const ideClient = await IdeClient.getInstance(); const result = await ( @@ -324,110 +309,18 @@ describe('IdeClient', () => { expect(result).toEqual(config); expect(fs.promises.readFile).toHaveBeenCalledWith( - path.join('/tmp/gemini/ide', 'qwen-code-ide-server-12345-123.json'), + path.join('/tmp', 'qwen-code-ide-server-12345.json'), 'utf8', ); }); - it('should filter out configs with invalid workspace paths', async () => { - const validConfig = { - port: '5678', - workspacePath: '/test/workspace', - }; - const invalidConfig = { - port: '1111', - workspacePath: '/invalid/workspace', - }; - vi.mocked(fs.promises.readFile).mockRejectedValueOnce( - new Error('not found'), - ); - ( - vi.mocked(fs.promises.readdir) as Mock< - (path: fs.PathLike) => Promise - > - ).mockResolvedValue([ - 'qwen-code-ide-server-12345-111.json', - 'qwen-code-ide-server-12345-222.json', - ]); - vi.mocked(fs.promises.readFile) - .mockResolvedValueOnce(JSON.stringify(invalidConfig)) - .mockResolvedValueOnce(JSON.stringify(validConfig)); - - const validateSpy = vi - .spyOn(IdeClient, 'validateWorkspacePath') - .mockReturnValueOnce({ isValid: false }) - .mockReturnValueOnce({ isValid: true }); - - const ideClient = await IdeClient.getInstance(); - const result = await ( - ideClient as unknown as { - getConnectionConfigFromFile: () => Promise; - } - ).getConnectionConfigFromFile(); - - expect(result).toEqual(validConfig); - expect(validateSpy).toHaveBeenCalledWith( - '/invalid/workspace', - '/test/workspace/sub-dir', - ); - expect(validateSpy).toHaveBeenCalledWith( - '/test/workspace', - '/test/workspace/sub-dir', - ); - }); - - it('should return the first valid config when multiple workspaces are valid', async () => { - const config1 = { port: '1111', workspacePath: '/test/workspace' }; - const config2 = { port: '2222', workspacePath: '/test/workspace2' }; - vi.mocked(fs.promises.readFile).mockRejectedValueOnce( - new Error('not found'), - ); - ( - vi.mocked(fs.promises.readdir) as Mock< - (path: fs.PathLike) => Promise - > - ).mockResolvedValue([ - 'qwen-code-ide-server-12345-111.json', - 'qwen-code-ide-server-12345-222.json', - ]); - vi.mocked(fs.promises.readFile) - .mockResolvedValueOnce(JSON.stringify(config1)) - .mockResolvedValueOnce(JSON.stringify(config2)); - vi.spyOn(IdeClient, 'validateWorkspacePath').mockReturnValue({ - isValid: true, - }); - - const ideClient = await IdeClient.getInstance(); - const result = await ( - ideClient as unknown as { - getConnectionConfigFromFile: () => Promise; - } - ).getConnectionConfigFromFile(); - - expect(result).toEqual(config1); - }); - - it('should prioritize the config matching the port from the environment variable', async () => { + it('should fall back to legacy port file when pid file is missing', async () => { process.env['QWEN_CODE_IDE_SERVER_PORT'] = '2222'; - const config1 = { port: '1111', workspacePath: '/test/workspace' }; const config2 = { port: '2222', workspacePath: '/test/workspace2' }; - vi.mocked(fs.promises.readFile).mockRejectedValueOnce( - new Error('not found'), - ); - ( - vi.mocked(fs.promises.readdir) as Mock< - (path: fs.PathLike) => Promise - > - ).mockResolvedValue([ - 'qwen-code-ide-server-12345-111.json', - 'qwen-code-ide-server-12345-222.json', - ]); vi.mocked(fs.promises.readFile) - .mockResolvedValueOnce(JSON.stringify(config1)) + .mockRejectedValueOnce(new Error('not found')) // ~/.qwen/ide/.lock + .mockRejectedValueOnce(new Error('not found')) // legacy pid file .mockResolvedValueOnce(JSON.stringify(config2)); - vi.spyOn(IdeClient, 'validateWorkspacePath').mockReturnValue({ - isValid: true, - }); const ideClient = await IdeClient.getInstance(); const result = await ( @@ -437,28 +330,23 @@ describe('IdeClient', () => { ).getConnectionConfigFromFile(); expect(result).toEqual(config2); + expect(fs.promises.readFile).toHaveBeenCalledWith( + path.join('/tmp', 'qwen-code-ide-server-12345.json'), + 'utf8', + ); + expect(fs.promises.readFile).toHaveBeenCalledWith( + path.join('/tmp', 'qwen-code-ide-server-2222.json'), + 'utf8', + ); delete process.env['QWEN_CODE_IDE_SERVER_PORT']; }); - it('should handle invalid JSON in one of the config files', async () => { - const validConfig = { port: '2222', workspacePath: '/test/workspace' }; - vi.mocked(fs.promises.readFile).mockRejectedValueOnce( - new Error('not found'), - ); - ( - vi.mocked(fs.promises.readdir) as Mock< - (path: fs.PathLike) => Promise - > - ).mockResolvedValue([ - 'qwen-code-ide-server-12345-111.json', - 'qwen-code-ide-server-12345-222.json', - ]); + it('should fall back to legacy config when env lock file has invalid JSON', async () => { + process.env['QWEN_CODE_IDE_SERVER_PORT'] = '3333'; + const config = { port: '1111', workspacePath: '/test/workspace' }; vi.mocked(fs.promises.readFile) .mockResolvedValueOnce('invalid json') - .mockResolvedValueOnce(JSON.stringify(validConfig)); - vi.spyOn(IdeClient, 'validateWorkspacePath').mockReturnValue({ - isValid: true, - }); + .mockResolvedValueOnce(JSON.stringify(config)); const ideClient = await IdeClient.getInstance(); const result = await ( @@ -467,96 +355,7 @@ describe('IdeClient', () => { } ).getConnectionConfigFromFile(); - expect(result).toEqual(validConfig); - }); - - it('should return undefined if readdir throws an error', async () => { - vi.mocked(fs.promises.readFile).mockRejectedValueOnce( - new Error('not found'), - ); - vi.mocked(fs.promises.readdir).mockRejectedValue( - new Error('readdir failed'), - ); - - const ideClient = await IdeClient.getInstance(); - const result = await ( - ideClient as unknown as { - getConnectionConfigFromFile: () => Promise; - } - ).getConnectionConfigFromFile(); - - expect(result).toBeUndefined(); - }); - - it('should ignore files with invalid names', async () => { - const validConfig = { port: '3333', workspacePath: '/test/workspace' }; - vi.mocked(fs.promises.readFile).mockRejectedValueOnce( - new Error('not found'), - ); - ( - vi.mocked(fs.promises.readdir) as Mock< - (path: fs.PathLike) => Promise - > - ).mockResolvedValue([ - 'qwen-code-ide-server-12345-111.json', // valid - 'not-a-config-file.txt', // invalid - 'qwen-code-ide-server-asdf.json', // invalid - ]); - vi.mocked(fs.promises.readFile).mockResolvedValueOnce( - JSON.stringify(validConfig), - ); - vi.spyOn(IdeClient, 'validateWorkspacePath').mockReturnValue({ - isValid: true, - }); - - const ideClient = await IdeClient.getInstance(); - const result = await ( - ideClient as unknown as { - getConnectionConfigFromFile: () => Promise; - } - ).getConnectionConfigFromFile(); - - expect(result).toEqual(validConfig); - expect(fs.promises.readFile).toHaveBeenCalledWith( - path.join('/tmp/gemini/ide', 'qwen-code-ide-server-12345-111.json'), - 'utf8', - ); - expect(fs.promises.readFile).not.toHaveBeenCalledWith( - path.join('/tmp/gemini/ide', 'not-a-config-file.txt'), - 'utf8', - ); - }); - - it('should match env port string to a number port in the config', async () => { - process.env['QWEN_CODE_IDE_SERVER_PORT'] = '3333'; - const config1 = { port: 1111, workspacePath: '/test/workspace' }; - const config2 = { port: 3333, workspacePath: '/test/workspace2' }; - vi.mocked(fs.promises.readFile).mockRejectedValueOnce( - new Error('not found'), - ); - ( - vi.mocked(fs.promises.readdir) as Mock< - (path: fs.PathLike) => Promise - > - ).mockResolvedValue([ - 'qwen-code-ide-server-12345-111.json', - 'qwen-code-ide-server-12345-222.json', - ]); - vi.mocked(fs.promises.readFile) - .mockResolvedValueOnce(JSON.stringify(config1)) - .mockResolvedValueOnce(JSON.stringify(config2)); - vi.spyOn(IdeClient, 'validateWorkspacePath').mockReturnValue({ - isValid: true, - }); - - const ideClient = await IdeClient.getInstance(); - const result = await ( - ideClient as unknown as { - getConnectionConfigFromFile: () => Promise; - } - ).getConnectionConfigFromFile(); - - expect(result).toEqual(config2); + expect(result).toEqual(config); delete process.env['QWEN_CODE_IDE_SERVER_PORT']; }); }); diff --git a/packages/core/src/ide/ide-client.ts b/packages/core/src/ide/ide-client.ts index b447f46ce..b216506f7 100644 --- a/packages/core/src/ide/ide-client.ts +++ b/packages/core/src/ide/ide-client.ts @@ -8,6 +8,7 @@ import * as fs from 'node:fs'; import { isSubpath } from '../utils/paths.js'; import { detectIde, type IdeInfo } from '../ide/detect-ide.js'; import { ideContextStore } from './ideContext.js'; +import { Storage } from '../config/storage.js'; import { IdeContextNotificationSchema, IdeDiffAcceptedNotificationSchema, @@ -572,98 +573,103 @@ export class IdeClient { | (ConnectionConfig & { workspacePath?: string; ideInfo?: IdeInfo }) | undefined > { - if (!this.ideProcessInfo) { - return undefined; - } - - // For backwards compatability - try { - const portFile = path.join( - os.tmpdir(), - `qwen-code-ide-server-${this.ideProcessInfo.pid}.json`, - ); - const portFileContents = await fs.promises.readFile(portFile, 'utf8'); - return JSON.parse(portFileContents); - } catch (_) { - // For newer extension versions, the file name matches the pattern - // /^qwen-code-ide-server-${pid}-\d+\.json$/. If multiple IDE - // windows are open, multiple files matching the pattern are expected to - // exist. - } - - const portFileDir = path.join(os.tmpdir(), 'gemini', 'ide'); - let portFiles; - try { - portFiles = await fs.promises.readdir(portFileDir); - } catch (e) { - logger.debug('Failed to read IDE connection directory:', e); - return undefined; - } - - if (!portFiles) { - return undefined; - } - - const fileRegex = new RegExp( - `^qwen-code-ide-server-${this.ideProcessInfo.pid}-\\d+\\.json$`, - ); - const matchingFiles = portFiles - .filter((file) => fileRegex.test(file)) - .sort(); - if (matchingFiles.length === 0) { - return undefined; - } - - let fileContents: string[]; - try { - fileContents = await Promise.all( - matchingFiles.map((file) => - fs.promises.readFile(path.join(portFileDir, file), 'utf8'), - ), - ); - } catch (e) { - logger.debug('Failed to read IDE connection config file(s):', e); - return undefined; - } - const parsedContents = fileContents.map((content) => { - try { - return JSON.parse(content); - } catch (e) { - logger.debug('Failed to parse JSON from config file: ', e); - return undefined; - } - }); - - const validWorkspaces = parsedContents.filter((content) => { - if (!content) { - return false; - } - const { isValid } = IdeClient.validateWorkspacePath( - content.workspacePath, - process.cwd(), - ); - return isValid; - }); - - if (validWorkspaces.length === 0) { - return undefined; - } - - if (validWorkspaces.length === 1) { - return validWorkspaces[0]; - } - const portFromEnv = this.getPortFromEnv(); if (portFromEnv) { - const matchingPort = validWorkspaces.find( - (content) => String(content.port) === portFromEnv, - ); - if (matchingPort) { - return matchingPort; + try { + const ideDir = Storage.getGlobalIdeDir(); + const lockFile = path.join(ideDir, `${portFromEnv}.lock`); + const lockFileContents = await fs.promises.readFile(lockFile, 'utf8'); + return JSON.parse(lockFileContents); + } catch (_) { + // Fall through to legacy discovery. } } - return validWorkspaces[0]; + // Legacy discovery for VSCode extension < v0.5.1. + return this.getLegacyConnectionConfig(portFromEnv); + } + + // Legacy connection files were written in the global temp directory. + private async getLegacyConnectionConfig( + portFromEnv?: string, + ): Promise< + | (ConnectionConfig & { workspacePath?: string; ideInfo?: IdeInfo }) + | undefined + > { + if (this.ideProcessInfo) { + try { + const portFile = path.join( + os.tmpdir(), + `qwen-code-ide-server-${this.ideProcessInfo.pid}.json`, + ); + const portFileContents = await fs.promises.readFile(portFile, 'utf8'); + return JSON.parse(portFileContents); + } catch (_) { + // For older/newer extension versions, the file name matches the pattern + // /^qwen-code-ide-server-${pid}-\d+\.json$/. If multiple IDE + // windows are open, multiple files matching the pattern are expected to + // exist. + } + } + + if (portFromEnv) { + try { + const portFile = path.join( + os.tmpdir(), + `qwen-code-ide-server-${portFromEnv}.json`, + ); + const portFileContents = await fs.promises.readFile(portFile, 'utf8'); + return JSON.parse(portFileContents); + } catch (_) { + // Ignore and fall through. + } + } + + return undefined; + } + + protected async getAllConnectionConfigs( + ideDir: string, + ): Promise< + ConnectionConfig & Array<{ workspacePath?: string; ideInfo?: IdeInfo }> + > { + const fileRegex = new RegExp('^\\d+\\.lock$'); + let lockFiles: string[]; + try { + lockFiles = (await fs.promises.readdir(ideDir)).filter((file) => + fileRegex.test(file), + ); + } catch (e) { + logger.debug('Failed to read IDE connection directory:', e); + return []; + } + + const fileContents = await Promise.all( + lockFiles.map(async (file) => { + const fullPath = path.join(ideDir, file); + try { + const stat = await fs.promises.stat(fullPath); + const content = await fs.promises.readFile(fullPath, 'utf8'); + try { + const parsed = JSON.parse(content); + return { file, mtimeMs: stat.mtimeMs, parsed }; + } catch (e) { + logger.debug('Failed to parse JSON from lock file: ', e); + return { file, mtimeMs: stat.mtimeMs, parsed: undefined }; + } + } catch (e) { + // If we can't stat/read the file, treat it as very old so it doesn't + // win ties, and skip parsing by returning undefined content. + logger.debug('Failed to read/stat IDE lock file:', e); + return { file, mtimeMs: -Infinity, parsed: undefined }; + } + }), + ); + + return fileContents + .filter(({ parsed }) => parsed !== undefined) + .sort((a, b) => b.mtimeMs - a.mtimeMs) + .map(({ parsed }) => parsed); } private createProxyAwareFetch() { diff --git a/packages/core/src/ide/process-utils.test.ts b/packages/core/src/ide/process-utils.test.ts index e6c68f144..a049406d3 100644 --- a/packages/core/src/ide/process-utils.test.ts +++ b/packages/core/src/ide/process-utils.test.ts @@ -50,7 +50,7 @@ describe('getIdeProcessInfo', () => { expect(result).toEqual({ pid: 700, command: '/usr/lib/vscode/code' }); }); - it('should return parent process info if grandparent lookup fails', async () => { + it('should return shell process info if grandparent lookup fails', async () => { (os.platform as Mock).mockReturnValue('linux'); mockedExec .mockResolvedValueOnce({ stdout: '800 /bin/bash' }) // pid 1000 -> ppid 800 (shell) @@ -63,134 +63,96 @@ describe('getIdeProcessInfo', () => { }); describe('on Windows', () => { - it('should traverse up and find the great-grandchild of the root process', async () => { + it('should return great-grandparent process using heuristic', async () => { (os.platform as Mock).mockReturnValue('win32'); - const processInfoMap = new Map([ - [ - 1000, - { - stdout: - '{"Name":"node.exe","ParentProcessId":900,"CommandLine":"node.exe"}', - }, - ], - [ - 900, - { - stdout: - '{"Name":"powershell.exe","ParentProcessId":800,"CommandLine":"powershell.exe"}', - }, - ], - [ - 800, - { - stdout: - '{"Name":"code.exe","ParentProcessId":700,"CommandLine":"code.exe"}', - }, - ], - [ - 700, - { - stdout: - '{"Name":"wininit.exe","ParentProcessId":0,"CommandLine":"wininit.exe"}', - }, - ], - ]); - mockedExec.mockImplementation((command: string) => { - const pidMatch = command.match(/ProcessId=(\d+)/); - if (pidMatch) { - const pid = parseInt(pidMatch[1], 10); - return Promise.resolve(processInfoMap.get(pid)); + + const processes = [ + { + ProcessId: 1000, + ParentProcessId: 900, + Name: 'node.exe', + CommandLine: 'node.exe', + }, + { + ProcessId: 900, + ParentProcessId: 800, + Name: 'powershell.exe', + CommandLine: 'powershell.exe', + }, + { + ProcessId: 800, + ParentProcessId: 700, + Name: 'code.exe', + CommandLine: 'code.exe', + }, + { + ProcessId: 700, + ParentProcessId: 0, + Name: 'wininit.exe', + CommandLine: 'wininit.exe', + }, + ]; + + mockedExec.mockImplementation((file: string, _args: string[]) => { + if (file === 'powershell') { + return Promise.resolve({ stdout: JSON.stringify(processes) }); } - return Promise.reject(new Error('Invalid command for mock')); + return Promise.resolve({ stdout: '' }); }); const result = await getIdeProcessInfo(); + // Process chain: 1000 (node.exe) -> 900 (powershell.exe) -> 800 (code.exe) -> 700 (wininit.exe) + // ancestors = [1000, 900, 800, 700], length = 4 + // Heuristic: return ancestors[length-3] = ancestors[1] = 900 (powershell.exe) expect(result).toEqual({ pid: 900, command: 'powershell.exe' }); }); - it('should handle non-existent process gracefully', async () => { + it('should handle empty process list gracefully', async () => { (os.platform as Mock).mockReturnValue('win32'); - mockedExec - .mockResolvedValueOnce({ stdout: '' }) // Non-existent PID returns empty due to -ErrorAction SilentlyContinue - .mockResolvedValueOnce({ - stdout: - '{"Name":"fallback.exe","ParentProcessId":0,"CommandLine":"fallback.exe"}', - }); // Fallback call + mockedExec.mockResolvedValue({ stdout: '[]' }); const result = await getIdeProcessInfo(); - expect(result).toEqual({ pid: 1000, command: 'fallback.exe' }); + // Should return current pid and empty command because process not found in map + expect(result).toEqual({ pid: 1000, command: '' }); }); it('should handle malformed JSON output gracefully', async () => { (os.platform as Mock).mockReturnValue('win32'); - mockedExec - .mockResolvedValueOnce({ stdout: '{"invalid":json}' }) // Malformed JSON - .mockResolvedValueOnce({ - stdout: - '{"Name":"fallback.exe","ParentProcessId":0,"CommandLine":"fallback.exe"}', - }); // Fallback call + mockedExec.mockResolvedValue({ stdout: '{"invalid":json}' }); const result = await getIdeProcessInfo(); - expect(result).toEqual({ pid: 1000, command: 'fallback.exe' }); + expect(result).toEqual({ pid: 1000, command: '' }); }); - it('should handle PowerShell errors without crashing the process chain', async () => { + it('should return last ancestor if chain is too short', async () => { (os.platform as Mock).mockReturnValue('win32'); - const processInfoMap = new Map([ - [1000, { stdout: '' }], // First process doesn't exist (empty due to -ErrorAction) - [ - 1001, - { - stdout: - '{"Name":"parent.exe","ParentProcessId":800,"CommandLine":"parent.exe"}', - }, - ], - [ - 800, - { - stdout: - '{"Name":"ide.exe","ParentProcessId":0,"CommandLine":"ide.exe"}', - }, - ], - ]); - // Mock the process.pid to test traversal with missing processes - Object.defineProperty(process, 'pid', { - value: 1001, - configurable: true, - }); + const processes = [ + { + ProcessId: 1000, + ParentProcessId: 900, + Name: 'node.exe', + CommandLine: 'node.exe', + }, + { + ProcessId: 900, + ParentProcessId: 0, + Name: 'explorer.exe', + CommandLine: 'explorer.exe', + }, + ]; - mockedExec.mockImplementation((command: string) => { - const pidMatch = command.match(/ProcessId=(\d+)/); - if (pidMatch) { - const pid = parseInt(pidMatch[1], 10); - return Promise.resolve(processInfoMap.get(pid) || { stdout: '' }); + mockedExec.mockImplementation((file: string, _args: string[]) => { + if (file === 'powershell') { + return Promise.resolve({ stdout: JSON.stringify(processes) }); } - return Promise.reject(new Error('Invalid command for mock')); + return Promise.resolve({ stdout: '' }); }); const result = await getIdeProcessInfo(); - // Should return the current process command since traversal continues despite missing processes - expect(result).toEqual({ pid: 1001, command: 'parent.exe' }); - - // Reset process.pid - Object.defineProperty(process, 'pid', { - value: 1000, - configurable: true, - }); - }); - - it('should handle partial JSON data with defaults', async () => { - (os.platform as Mock).mockReturnValue('win32'); - mockedExec - .mockResolvedValueOnce({ stdout: '{"Name":"partial.exe"}' }) // Missing ParentProcessId, defaults to 0 - .mockResolvedValueOnce({ - stdout: - '{"Name":"root.exe","ParentProcessId":0,"CommandLine":"root.exe"}', - }); // Get grandparent info - - const result = await getIdeProcessInfo(); - expect(result).toEqual({ pid: 1000, command: 'root.exe' }); + // ancestors = [1000, 900], length = 2 (< 3) + // Heuristic: return ancestors[length-1] = ancestors[1] = 900 (explorer.exe) + expect(result).toEqual({ pid: 900, command: 'explorer.exe' }); }); }); }); diff --git a/packages/core/src/ide/process-utils.ts b/packages/core/src/ide/process-utils.ts index 170b1df18..15337d3c4 100644 --- a/packages/core/src/ide/process-utils.ts +++ b/packages/core/src/ide/process-utils.ts @@ -4,74 +4,43 @@ * SPDX-License-Identifier: Apache-2.0 */ -import { exec } from 'node:child_process'; +import { exec, execFile } from 'node:child_process'; import { promisify } from 'node:util'; import os from 'node:os'; import path from 'node:path'; const execAsync = promisify(exec); +const execFileAsync = promisify(execFile); const MAX_TRAVERSAL_DEPTH = 32; -/** - * Fetches the parent process ID, name, and command for a given process ID. - * - * @param pid The process ID to inspect. - * @returns A promise that resolves to the parent's PID, name, and command. - */ async function getProcessInfo(pid: number): Promise<{ parentPid: number; name: string; command: string; }> { + // Only used for Unix systems (macOS and Linux) try { - const platform = os.platform(); - if (platform === 'win32') { - const powershellCommand = [ - '$p = Get-CimInstance Win32_Process', - `-Filter 'ProcessId=${pid}'`, - '-ErrorAction SilentlyContinue;', - 'if ($p) {', - '@{Name=$p.Name;ParentProcessId=$p.ParentProcessId;CommandLine=$p.CommandLine}', - '| ConvertTo-Json', - '}', - ].join(' '); - const { stdout } = await execAsync(`powershell "${powershellCommand}"`); - const output = stdout.trim(); - if (!output) return { parentPid: 0, name: '', command: '' }; - const { - Name = '', - ParentProcessId = 0, - CommandLine = '', - } = JSON.parse(output); - return { - parentPid: ParentProcessId, - name: Name, - command: CommandLine ?? '', - }; - } else { - const command = `ps -o ppid=,command= -p ${pid}`; - const { stdout } = await execAsync(command); - const trimmedStdout = stdout.trim(); - if (!trimmedStdout) { - return { parentPid: 0, name: '', command: '' }; - } - const ppidString = trimmedStdout.split(/\s+/)[0]; - const parentPid = parseInt(ppidString, 10); - const fullCommand = trimmedStdout.substring(ppidString.length).trim(); - const processName = path.basename(fullCommand.split(' ')[0]); - return { - parentPid: isNaN(parentPid) ? 1 : parentPid, - name: processName, - command: fullCommand, - }; + const command = `ps -o ppid=,command= -p ${pid}`; + const { stdout } = await execAsync(command); + const trimmedStdout = stdout.trim(); + if (!trimmedStdout) { + return { parentPid: 0, name: '', command: '' }; } + const parts = trimmedStdout.split(/\s+/); + const ppidString = parts[0]; + const parentPid = parseInt(ppidString, 10); + const fullCommand = trimmedStdout.substring(ppidString.length).trim(); + const processName = path.basename(fullCommand.split(' ')[0]); + return { + parentPid: isNaN(parentPid) ? 1 : parentPid, + name: processName, + command: fullCommand, + }; } catch (_e) { - console.debug(`Failed to get process info for pid ${pid}:`, _e); return { parentPid: 0, name: '', command: '' }; } } - /** * Finds the IDE process info on Unix-like systems. * @@ -106,15 +75,15 @@ async function getIdeProcessInfoForUnix(): Promise<{ } catch { // Ignore if getting grandparent fails, we'll just use the parent pid. } - const { command } = await getProcessInfo(idePid); - return { pid: idePid, command }; + const { command: ideCommand } = await getProcessInfo(idePid); + return { pid: idePid, command: ideCommand }; } if (parentPid <= 1) { break; // Reached the root } currentPid = parentPid; - } catch { + } catch (_e) { // Process in chain died break; } @@ -124,50 +93,104 @@ async function getIdeProcessInfoForUnix(): Promise<{ return { pid: currentPid, command }; } +interface ProcessInfo { + pid: number; + parentPid: number; + name: string; + command: string; +} + +interface RawProcessInfo { + ProcessId?: number; + ParentProcessId?: number; + Name?: string; + CommandLine?: string; +} + /** - * Finds the IDE process info on Windows. - * - * The strategy is to find the great-grandchild of the root process. - * - * @returns A promise that resolves to the PID and command of the IDE process. + * Fetches the entire process table on Windows. */ +async function getProcessTableWindows(): Promise> { + const processMap = new Map(); + try { + const powershellCommand = + 'Get-CimInstance Win32_Process | Select-Object ProcessId,ParentProcessId,Name,CommandLine | ConvertTo-Json -Compress'; + const { stdout } = await execFileAsync( + 'powershell', + ['-NoProfile', '-NonInteractive', '-Command', powershellCommand], + { maxBuffer: 10 * 1024 * 1024 }, + ); + + if (!stdout.trim()) { + return processMap; + } + + let processes: RawProcessInfo | RawProcessInfo[]; + try { + processes = JSON.parse(stdout); + } catch (_e) { + return processMap; + } + + if (!Array.isArray(processes)) { + processes = [processes]; + } + + for (const p of processes) { + if (p && typeof p.ProcessId === 'number') { + processMap.set(p.ProcessId, { + pid: p.ProcessId, + parentPid: p.ParentProcessId || 0, + name: p.Name || '', + command: p.CommandLine || '', + }); + } + } + } catch (_e) { + // Fallback or error handling if PowerShell fails + } + return processMap; +} + async function getIdeProcessInfoForWindows(): Promise<{ pid: number; command: string; }> { - let currentPid = process.pid; - let previousPid = process.pid; + // Fetch the entire process table in one go. + const processMap = await getProcessTableWindows(); - for (let i = 0; i < MAX_TRAVERSAL_DEPTH; i++) { - try { - const { parentPid } = await getProcessInfo(currentPid); + const myPid = process.pid; + const myProc = processMap.get(myPid); - if (parentPid > 0) { - try { - const { parentPid: grandParentPid } = await getProcessInfo(parentPid); - if (grandParentPid === 0) { - // We've found the grandchild of the root (`currentPid`). The IDE - // process is its child, which we've stored in `previousPid`. - const { command } = await getProcessInfo(previousPid); - return { pid: previousPid, command }; - } - } catch { - // getting grandparent failed, proceed - } - } + if (!myProc) { + // Fallback: return current process info if snapshot fails + return { pid: myPid, command: '' }; + } - if (parentPid <= 0) { - break; // Reached the root - } - previousPid = currentPid; - currentPid = parentPid; - } catch { - // Process in chain died + // Perform tree traversal in memory + const ancestors: ProcessInfo[] = []; + let curr: ProcessInfo | undefined = myProc; + + for (let i = 0; i < MAX_TRAVERSAL_DEPTH && curr; i++) { + ancestors.push(curr); + + if (curr.parentPid === 0 || !processMap.has(curr.parentPid)) { + // Parent process not in map, stop traversal break; } + curr = processMap.get(curr.parentPid); } - const { command } = await getProcessInfo(currentPid); - return { pid: currentPid, command }; + + // Use heuristic: return the great-grandparent (ancestors[length-3]) + if (ancestors.length >= 3) { + const target = ancestors[ancestors.length - 3]; + return { pid: target.pid, command: target.command }; + } else if (ancestors.length > 0) { + const target = ancestors[ancestors.length - 1]; + return { pid: target.pid, command: target.command }; + } + + return { pid: myPid, command: myProc.command }; } /** diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 738aca57d..56680403b 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -12,7 +12,6 @@ export * from './output/json-formatter.js'; // Export Core Logic export * from './core/client.js'; export * from './core/contentGenerator.js'; -export * from './core/loggingContentGenerator.js'; export * from './core/geminiChat.js'; export * from './core/logger.js'; export * from './core/prompts.js'; @@ -24,11 +23,7 @@ export * from './core/nonInteractiveToolExecutor.js'; export * from './fallback/types.js'; -export * from './code_assist/codeAssist.js'; -export * from './code_assist/oauth2.js'; export * from './qwen/qwenOAuth2.js'; -export * from './code_assist/server.js'; -export * from './code_assist/types.js'; // Export utilities export * from './utils/paths.js'; @@ -85,6 +80,9 @@ export * from './tools/tool-registry.js'; // Export subagents (Phase 1) export * from './subagents/index.js'; +// Export skills +export * from './skills/index.js'; + // Export prompt logic export * from './prompts/mcp-prompts.js'; @@ -106,6 +104,7 @@ export * from './tools/mcp-client-manager.js'; export * from './tools/mcp-tool.js'; export * from './tools/sdk-control-client-transport.js'; export * from './tools/task.js'; +export * from './tools/skill.js'; export * from './tools/todoWrite.js'; export * from './tools/exitPlanMode.js'; diff --git a/packages/core/src/mcp/constants.ts b/packages/core/src/mcp/constants.ts new file mode 100644 index 000000000..a352fc60b --- /dev/null +++ b/packages/core/src/mcp/constants.ts @@ -0,0 +1,27 @@ +/** + * @license + * Copyright 2025 Qwen + * SPDX-License-Identifier: Apache-2.0 + */ + +/** + * OAuth client name used for MCP dynamic client registration. + * This name must match the allowlist on MCP servers like Figma. + */ +export const MCP_OAUTH_CLIENT_NAME = 'Gemini CLI MCP Client'; + +/** + * OAuth client name for service account impersonation provider. + */ +export const MCP_SA_IMPERSONATION_CLIENT_NAME = + 'Gemini CLI (Service Account Impersonation)'; + +/** + * Port for OAuth redirect callback server. + */ +export const OAUTH_REDIRECT_PORT = 7777; + +/** + * Path for OAuth redirect callback. + */ +export const OAUTH_REDIRECT_PATH = '/oauth/callback'; diff --git a/packages/core/src/mcp/google-auth-provider.ts b/packages/core/src/mcp/google-auth-provider.ts index d76115622..8c858714c 100644 --- a/packages/core/src/mcp/google-auth-provider.ts +++ b/packages/core/src/mcp/google-auth-provider.ts @@ -13,6 +13,7 @@ import type { } from '@modelcontextprotocol/sdk/shared/auth.js'; import { GoogleAuth } from 'google-auth-library'; import type { MCPServerConfig } from '../config/config.js'; +import { MCP_OAUTH_CLIENT_NAME } from './constants.js'; const ALLOWED_HOSTS = [/^.+\.googleapis\.com$/, /^(.*\.)?luci\.app$/]; @@ -22,7 +23,7 @@ export class GoogleCredentialProvider implements OAuthClientProvider { // Properties required by OAuthClientProvider, with no-op values readonly redirectUrl = ''; readonly clientMetadata: OAuthClientMetadata = { - client_name: 'Gemini CLI (Google ADC)', + client_name: MCP_OAUTH_CLIENT_NAME, redirect_uris: [], grant_types: [], response_types: [], diff --git a/packages/core/src/mcp/oauth-provider.ts b/packages/core/src/mcp/oauth-provider.ts index 44f9d9c71..2b657f352 100644 --- a/packages/core/src/mcp/oauth-provider.ts +++ b/packages/core/src/mcp/oauth-provider.ts @@ -13,6 +13,11 @@ import type { OAuthToken } from './token-storage/types.js'; import { MCPOAuthTokenStorage } from './oauth-token-storage.js'; import { getErrorMessage } from '../utils/errors.js'; import { OAuthUtils } from './oauth-utils.js'; +import { + MCP_OAUTH_CLIENT_NAME, + OAUTH_REDIRECT_PORT, + OAUTH_REDIRECT_PATH, +} from './constants.js'; export const OAUTH_DISPLAY_MESSAGE_EVENT = 'oauth-display-message' as const; @@ -89,8 +94,6 @@ interface PKCEParams { state: string; } -const REDIRECT_PORT = 7777; -const REDIRECT_PATH = '/oauth/callback'; const HTTP_OK = 200; /** @@ -115,10 +118,11 @@ export class MCPOAuthProvider { config: MCPOAuthConfig, ): Promise { const redirectUri = - config.redirectUri || `http://localhost:${REDIRECT_PORT}${REDIRECT_PATH}`; + config.redirectUri || + `http://localhost:${OAUTH_REDIRECT_PORT}${OAUTH_REDIRECT_PATH}`; const registrationRequest: OAuthClientRegistrationRequest = { - client_name: 'Gemini CLI (Google ADC)', + client_name: MCP_OAUTH_CLIENT_NAME, redirect_uris: [redirectUri], grant_types: ['authorization_code', 'refresh_token'], response_types: ['code'], @@ -192,9 +196,12 @@ export class MCPOAuthProvider { const server = http.createServer( async (req: http.IncomingMessage, res: http.ServerResponse) => { try { - const url = new URL(req.url!, `http://localhost:${REDIRECT_PORT}`); + const url = new URL( + req.url!, + `http://localhost:${OAUTH_REDIRECT_PORT}`, + ); - if (url.pathname !== REDIRECT_PATH) { + if (url.pathname !== OAUTH_REDIRECT_PATH) { res.writeHead(404); res.end('Not found'); return; @@ -257,8 +264,10 @@ export class MCPOAuthProvider { ); server.on('error', reject); - server.listen(REDIRECT_PORT, () => { - console.log(`OAuth callback server listening on port ${REDIRECT_PORT}`); + server.listen(OAUTH_REDIRECT_PORT, () => { + console.log( + `OAuth callback server listening on port ${OAUTH_REDIRECT_PORT}`, + ); }); // Timeout after 5 minutes @@ -286,7 +295,8 @@ export class MCPOAuthProvider { mcpServerUrl?: string, ): string { const redirectUri = - config.redirectUri || `http://localhost:${REDIRECT_PORT}${REDIRECT_PATH}`; + config.redirectUri || + `http://localhost:${OAUTH_REDIRECT_PORT}${OAUTH_REDIRECT_PATH}`; const params = new URLSearchParams({ client_id: config.clientId!, @@ -343,7 +353,8 @@ export class MCPOAuthProvider { mcpServerUrl?: string, ): Promise { const redirectUri = - config.redirectUri || `http://localhost:${REDIRECT_PORT}${REDIRECT_PATH}`; + config.redirectUri || + `http://localhost:${OAUTH_REDIRECT_PORT}${OAUTH_REDIRECT_PATH}`; const params = new URLSearchParams({ grant_type: 'authorization_code', diff --git a/packages/core/src/mcp/sa-impersonation-provider.ts b/packages/core/src/mcp/sa-impersonation-provider.ts index e3336693d..def86591e 100644 --- a/packages/core/src/mcp/sa-impersonation-provider.ts +++ b/packages/core/src/mcp/sa-impersonation-provider.ts @@ -13,6 +13,7 @@ import type { import { GoogleAuth } from 'google-auth-library'; import type { MCPServerConfig } from '../config/config.js'; import type { OAuthClientProvider } from '@modelcontextprotocol/sdk/client/auth.js'; +import { MCP_SA_IMPERSONATION_CLIENT_NAME } from './constants.js'; const fiveMinBufferMs = 5 * 60 * 1000; @@ -32,7 +33,7 @@ export class ServiceAccountImpersonationProvider // Properties required by OAuthClientProvider, with no-op values readonly redirectUrl = ''; readonly clientMetadata: OAuthClientMetadata = { - client_name: 'Gemini CLI (Service Account Impersonation)', + client_name: MCP_SA_IMPERSONATION_CLIENT_NAME, redirect_uris: [], grant_types: [], response_types: [], diff --git a/packages/core/src/qwen/qwenOAuth2.test.ts b/packages/core/src/qwen/qwenOAuth2.test.ts index 23c262969..0c401f909 100644 --- a/packages/core/src/qwen/qwenOAuth2.test.ts +++ b/packages/core/src/qwen/qwenOAuth2.test.ts @@ -761,7 +761,6 @@ describe('getQwenOAuthClient', () => { }); it('should load cached credentials if available', async () => { - const fs = await import('node:fs'); const mockCredentials = { access_token: 'cached-token', refresh_token: 'cached-refresh', @@ -769,10 +768,6 @@ describe('getQwenOAuthClient', () => { expiry_date: Date.now() + 3600000, }; - vi.mocked(fs.promises.readFile).mockResolvedValue( - JSON.stringify(mockCredentials), - ); - // Mock SharedTokenManager to use cached credentials const mockTokenManager = { getValidCredentials: vi.fn().mockResolvedValue(mockCredentials), @@ -792,18 +787,6 @@ describe('getQwenOAuthClient', () => { }); it('should handle cached credentials refresh failure', async () => { - const fs = await import('node:fs'); - const mockCredentials = { - access_token: 'cached-token', - refresh_token: 'expired-refresh', - token_type: 'Bearer', - expiry_date: Date.now() + 3600000, // Valid expiry time so loadCachedQwenCredentials returns true - }; - - vi.mocked(fs.promises.readFile).mockResolvedValue( - JSON.stringify(mockCredentials), - ); - // Mock SharedTokenManager to fail with a specific error const mockTokenManager = { getValidCredentials: vi @@ -833,6 +816,35 @@ describe('getQwenOAuthClient', () => { SharedTokenManager.getInstance = originalGetInstance; }); + + it('should not start device flow when requireCachedCredentials is true', async () => { + // Make SharedTokenManager fail so we hit the fallback path + const mockTokenManager = { + getValidCredentials: vi + .fn() + .mockRejectedValue(new Error('No credentials')), + }; + + const originalGetInstance = SharedTokenManager.getInstance; + SharedTokenManager.getInstance = vi.fn().mockReturnValue(mockTokenManager); + + // If requireCachedCredentials is honored, device-flow network requests should not start + vi.mocked(global.fetch).mockResolvedValue({ ok: true } as Response); + + await expect( + import('./qwenOAuth2.js').then((module) => + module.getQwenOAuthClient(mockConfig, { + requireCachedCredentials: true, + }), + ), + ).rejects.toThrow( + 'No cached Qwen-OAuth credentials found. Please re-authenticate.', + ); + + expect(global.fetch).not.toHaveBeenCalled(); + + SharedTokenManager.getInstance = originalGetInstance; + }); }); describe('CredentialsClearRequiredError', () => { @@ -1574,178 +1586,6 @@ describe('Credential Caching Functions', () => { expect(updatedCredentials.access_token).toBe('new-token'); }); }); - - describe('loadCachedQwenCredentials', () => { - it('should load and validate cached credentials successfully', async () => { - const { promises: fs } = await import('node:fs'); - const mockCredentials = { - access_token: 'cached-token', - refresh_token: 'cached-refresh', - token_type: 'Bearer', - expiry_date: Date.now() + 3600000, - }; - - vi.mocked(fs.readFile).mockResolvedValue(JSON.stringify(mockCredentials)); - - // Test through getQwenOAuthClient which calls loadCachedQwenCredentials - const mockConfig = { - isBrowserLaunchSuppressed: vi.fn().mockReturnValue(true), - } as unknown as Config; - - // Make SharedTokenManager fail to test the fallback - const mockTokenManager = { - getValidCredentials: vi - .fn() - .mockRejectedValue(new Error('No cached creds')), - }; - - const originalGetInstance = SharedTokenManager.getInstance; - SharedTokenManager.getInstance = vi - .fn() - .mockReturnValue(mockTokenManager); - - // Mock successful auth flow after cache load fails - const mockAuthResponse = { - ok: true, - json: async () => ({ - device_code: 'test-device-code', - user_code: 'TEST123', - verification_uri: 'https://chat.qwen.ai/device', - verification_uri_complete: 'https://chat.qwen.ai/device?code=TEST123', - expires_in: 1800, - }), - }; - - const mockTokenResponse = { - ok: true, - json: async () => ({ - access_token: 'new-access-token', - refresh_token: 'new-refresh-token', - token_type: 'Bearer', - expires_in: 3600, - scope: 'openid profile email model.completion', - }), - }; - - global.fetch = vi - .fn() - .mockResolvedValueOnce(mockAuthResponse as Response) - .mockResolvedValue(mockTokenResponse as Response); - - try { - await import('./qwenOAuth2.js').then((module) => - module.getQwenOAuthClient(mockConfig), - ); - } catch { - // Expected to fail in test environment - } - - expect(fs.readFile).toHaveBeenCalled(); - SharedTokenManager.getInstance = originalGetInstance; - }); - - it('should handle invalid cached credentials gracefully', async () => { - const { promises: fs } = await import('node:fs'); - - // Mock file read to return invalid JSON - vi.mocked(fs.readFile).mockResolvedValue('invalid-json'); - - const mockConfig = { - isBrowserLaunchSuppressed: vi.fn().mockReturnValue(true), - } as unknown as Config; - - const mockTokenManager = { - getValidCredentials: vi - .fn() - .mockRejectedValue(new Error('No cached creds')), - }; - - const originalGetInstance = SharedTokenManager.getInstance; - SharedTokenManager.getInstance = vi - .fn() - .mockReturnValue(mockTokenManager); - - // Mock auth flow - const mockAuthResponse = { - ok: true, - json: async () => ({ - device_code: 'test-device-code', - user_code: 'TEST123', - verification_uri: 'https://chat.qwen.ai/device', - verification_uri_complete: 'https://chat.qwen.ai/device?code=TEST123', - expires_in: 1800, - }), - }; - - const mockTokenResponse = { - ok: true, - json: async () => ({ - access_token: 'new-token', - refresh_token: 'new-refresh', - token_type: 'Bearer', - expires_in: 3600, - }), - }; - - global.fetch = vi - .fn() - .mockResolvedValueOnce(mockAuthResponse as Response) - .mockResolvedValue(mockTokenResponse as Response); - - try { - await import('./qwenOAuth2.js').then((module) => - module.getQwenOAuthClient(mockConfig), - ); - } catch { - // Expected to fail in test environment - } - - SharedTokenManager.getInstance = originalGetInstance; - }); - - it('should handle file access errors', async () => { - const { promises: fs } = await import('node:fs'); - - vi.mocked(fs.readFile).mockRejectedValue(new Error('File not found')); - - const mockConfig = { - isBrowserLaunchSuppressed: vi.fn().mockReturnValue(true), - } as unknown as Config; - - const mockTokenManager = { - getValidCredentials: vi - .fn() - .mockRejectedValue(new Error('No cached creds')), - }; - - const originalGetInstance = SharedTokenManager.getInstance; - SharedTokenManager.getInstance = vi - .fn() - .mockReturnValue(mockTokenManager); - - // Mock device flow to fail quickly - const mockAuthResponse = { - ok: true, - json: async () => ({ - error: 'invalid_request', - error_description: 'Invalid request parameters', - }), - }; - - global.fetch = vi.fn().mockResolvedValue(mockAuthResponse as Response); - - // Should proceed to device flow when cache loading fails - try { - await import('./qwenOAuth2.js').then((module) => - module.getQwenOAuthClient(mockConfig), - ); - } catch { - // Expected to fail in test environment - } - - SharedTokenManager.getInstance = originalGetInstance; - }); - }); }); describe('Enhanced Error Handling and Edge Cases', () => { diff --git a/packages/core/src/qwen/qwenOAuth2.ts b/packages/core/src/qwen/qwenOAuth2.ts index c4cfa9332..1435c782d 100644 --- a/packages/core/src/qwen/qwenOAuth2.ts +++ b/packages/core/src/qwen/qwenOAuth2.ts @@ -514,26 +514,14 @@ export async function getQwenOAuthClient( } } - // If shared manager fails, check if we have cached credentials for device flow - if (await loadCachedQwenCredentials(client)) { - // We have cached credentials but they might be expired - // Try device flow instead of forcing refresh - const result = await authWithQwenDeviceFlow(client, config); - if (!result.success) { - // Use detailed error message if available, otherwise use default - const errorMessage = - result.message || 'Qwen OAuth authentication failed'; - throw new Error(errorMessage); - } - return client; - } - if (options?.requireCachedCredentials) { throw new Error( 'No cached Qwen-OAuth credentials found. Please re-authenticate.', ); } + // If we couldn't obtain valid credentials via SharedTokenManager, fall back to + // interactive device authorization (unless explicitly forbidden above). const result = await authWithQwenDeviceFlow(client, config); if (!result.success) { // Only emit timeout event if the failure reason is actually timeout @@ -689,6 +677,19 @@ async function authWithQwenDeviceFlow( // Cache the new tokens await cacheQwenCredentials(credentials); + // IMPORTANT: + // SharedTokenManager maintains an in-memory cache and throttles file checks. + // If we only write the creds file here, a subsequent `getQwenOAuthClient()` + // call in the same process (within the throttle window) may not re-read the + // updated file and could incorrectly re-trigger device auth. + // Clearing the cache forces the next call to reload from disk. + try { + SharedTokenManager.getInstance().clearCache(); + } catch { + // In unit tests we sometimes mock SharedTokenManager.getInstance() with a + // minimal stub; cache invalidation is best-effort and should not break auth. + } + // Emit auth progress success event qwenOAuth2Events.emit( QwenOAuth2Event.AuthProgress, @@ -847,27 +848,6 @@ async function authWithQwenDeviceFlow( } } -async function loadCachedQwenCredentials( - client: QwenOAuth2Client, -): Promise { - try { - const keyFile = getQwenCachedCredentialPath(); - const creds = await fs.readFile(keyFile, 'utf-8'); - const credentials = JSON.parse(creds) as QwenCredentials; - client.setCredentials(credentials); - - // Verify that the credentials are still valid - const { token } = await client.getAccessToken(); - if (!token) { - return false; - } - - return true; - } catch (_) { - return false; - } -} - async function cacheQwenCredentials(credentials: QwenCredentials) { const filePath = getQwenCachedCredentialPath(); try { @@ -913,9 +893,19 @@ export async function clearQwenCredentials(): Promise { } // Log other errors but don't throw - clearing credentials should be non-critical console.warn('Warning: Failed to clear cached Qwen credentials:', error); + } finally { + // Also clear SharedTokenManager in-memory cache to prevent stale credentials + // from being reused within the same process after the file is removed. + try { + SharedTokenManager.getInstance().clearCache(); + } catch { + // Best-effort; don't fail credential clearing if SharedTokenManager is mocked. + } } } function getQwenCachedCredentialPath(): string { return path.join(os.homedir(), QWEN_DIR, QWEN_CREDENTIAL_FILENAME); } + +export const clearCachedCredentialFile = clearQwenCredentials; diff --git a/packages/core/src/skills/index.ts b/packages/core/src/skills/index.ts new file mode 100644 index 000000000..94d5869f9 --- /dev/null +++ b/packages/core/src/skills/index.ts @@ -0,0 +1,31 @@ +/** + * @license + * Copyright 2025 Qwen + * SPDX-License-Identifier: Apache-2.0 + */ + +/** + * @fileoverview Skills feature implementation + * + * This module provides the foundation for the skills feature, which allows + * users to define reusable skill configurations that can be loaded by the + * model via a dedicated Skills tool. + * + * Skills are stored as directories in `.qwen/skills/` (project-level) or + * `~/.qwen/skills/` (user-level), with each directory containing a SKILL.md + * file with YAML frontmatter for metadata. + */ + +// Core types and interfaces +export type { + SkillConfig, + SkillLevel, + SkillValidationResult, + ListSkillsOptions, + SkillErrorCode, +} from './types.js'; + +export { SkillError } from './types.js'; + +// Main management class +export { SkillManager } from './skill-manager.js'; diff --git a/packages/core/src/skills/skill-manager.test.ts b/packages/core/src/skills/skill-manager.test.ts new file mode 100644 index 000000000..076816f86 --- /dev/null +++ b/packages/core/src/skills/skill-manager.test.ts @@ -0,0 +1,463 @@ +/** + * @license + * Copyright 2025 Qwen + * SPDX-License-Identifier: Apache-2.0 + */ + +import { vi, describe, it, expect, beforeEach, afterEach } from 'vitest'; +import * as fs from 'fs/promises'; +import * as path from 'path'; +import * as os from 'os'; +import { SkillManager } from './skill-manager.js'; +import { type SkillConfig, SkillError } from './types.js'; +import type { Config } from '../config/config.js'; +import { makeFakeConfig } from '../test-utils/config.js'; + +// Mock file system operations +vi.mock('fs/promises'); +vi.mock('os'); + +// Mock yaml parser - use vi.hoisted for proper hoisting +const mockParseYaml = vi.hoisted(() => vi.fn()); + +vi.mock('../utils/yaml-parser.js', () => ({ + parse: mockParseYaml, + stringify: vi.fn(), +})); + +describe('SkillManager', () => { + let manager: SkillManager; + let mockConfig: Config; + + beforeEach(() => { + // Create mock Config object using test utility + mockConfig = makeFakeConfig({}); + + // Mock the project root method + vi.spyOn(mockConfig, 'getProjectRoot').mockReturnValue('/test/project'); + + // Mock os.homedir + vi.mocked(os.homedir).mockReturnValue('/home/user'); + + // Reset and setup mocks + vi.clearAllMocks(); + + // Setup yaml parser mocks with sophisticated behavior + mockParseYaml.mockImplementation((yamlString: string) => { + // Handle different test cases based on YAML content + if (yamlString.includes('allowedTools:')) { + return { + name: 'test-skill', + description: 'A test skill', + allowedTools: ['read_file', 'write_file'], + }; + } + if (yamlString.includes('name: skill1')) { + return { name: 'skill1', description: 'First skill' }; + } + if (yamlString.includes('name: skill2')) { + return { name: 'skill2', description: 'Second skill' }; + } + if (yamlString.includes('name: skill3')) { + return { name: 'skill3', description: 'Third skill' }; + } + if (!yamlString.includes('name:')) { + return { description: 'A test skill' }; // Missing name case + } + if (!yamlString.includes('description:')) { + return { name: 'test-skill' }; // Missing description case + } + // Default case + return { + name: 'test-skill', + description: 'A test skill', + }; + }); + + manager = new SkillManager(mockConfig); + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + const validSkillConfig: SkillConfig = { + name: 'test-skill', + description: 'A test skill', + level: 'project', + filePath: '/test/project/.qwen/skills/test-skill/SKILL.md', + body: 'You are a helpful assistant with this skill.', + }; + + const validMarkdown = `--- +name: test-skill +description: A test skill +--- + +You are a helpful assistant with this skill. +`; + + describe('parseSkillContent', () => { + it('should parse valid markdown content', () => { + const config = manager.parseSkillContent( + validMarkdown, + validSkillConfig.filePath, + 'project', + ); + + expect(config.name).toBe('test-skill'); + expect(config.description).toBe('A test skill'); + expect(config.body).toBe('You are a helpful assistant with this skill.'); + expect(config.level).toBe('project'); + expect(config.filePath).toBe(validSkillConfig.filePath); + }); + + it('should parse content with allowedTools', () => { + const markdownWithTools = `--- +name: test-skill +description: A test skill +allowedTools: + - read_file + - write_file +--- + +You are a helpful assistant with this skill. +`; + + const config = manager.parseSkillContent( + markdownWithTools, + validSkillConfig.filePath, + 'project', + ); + + expect(config.allowedTools).toEqual(['read_file', 'write_file']); + }); + + it('should determine level from file path', () => { + const projectPath = '/test/project/.qwen/skills/test-skill/SKILL.md'; + const userPath = '/home/user/.qwen/skills/test-skill/SKILL.md'; + + const projectConfig = manager.parseSkillContent( + validMarkdown, + projectPath, + 'project', + ); + const userConfig = manager.parseSkillContent( + validMarkdown, + userPath, + 'user', + ); + + expect(projectConfig.level).toBe('project'); + expect(userConfig.level).toBe('user'); + }); + + it('should throw error for invalid frontmatter format', () => { + const invalidMarkdown = `No frontmatter here +Just content`; + + expect(() => + manager.parseSkillContent( + invalidMarkdown, + validSkillConfig.filePath, + 'project', + ), + ).toThrow(SkillError); + }); + + it('should throw error for missing name', () => { + const markdownWithoutName = `--- +description: A test skill +--- + +You are a helpful assistant. +`; + + expect(() => + manager.parseSkillContent( + markdownWithoutName, + validSkillConfig.filePath, + 'project', + ), + ).toThrow(SkillError); + }); + + it('should throw error for missing description', () => { + const markdownWithoutDescription = `--- +name: test-skill +--- + +You are a helpful assistant. +`; + + expect(() => + manager.parseSkillContent( + markdownWithoutDescription, + validSkillConfig.filePath, + 'project', + ), + ).toThrow(SkillError); + }); + }); + + describe('validateConfig', () => { + it('should validate valid configuration', () => { + const result = manager.validateConfig(validSkillConfig); + + expect(result.isValid).toBe(true); + expect(result.errors).toHaveLength(0); + }); + + it('should report error for missing name', () => { + const invalidConfig = { ...validSkillConfig, name: '' }; + const result = manager.validateConfig(invalidConfig); + + expect(result.isValid).toBe(false); + expect(result.errors).toContain('"name" cannot be empty'); + }); + + it('should report error for missing description', () => { + const invalidConfig = { ...validSkillConfig, description: '' }; + const result = manager.validateConfig(invalidConfig); + + expect(result.isValid).toBe(false); + expect(result.errors).toContain('"description" cannot be empty'); + }); + + it('should report error for invalid allowedTools type', () => { + const invalidConfig = { + ...validSkillConfig, + allowedTools: 'not-an-array' as unknown as string[], + }; + const result = manager.validateConfig(invalidConfig); + + expect(result.isValid).toBe(false); + expect(result.errors).toContain('"allowedTools" must be an array'); + }); + + it('should warn for empty body', () => { + const configWithEmptyBody = { ...validSkillConfig, body: '' }; + const result = manager.validateConfig(configWithEmptyBody); + + expect(result.isValid).toBe(true); // Still valid + expect(result.warnings).toContain('Skill body is empty'); + }); + }); + + describe('loadSkill', () => { + it('should load skill from project level first', async () => { + vi.mocked(fs.readdir).mockResolvedValue([ + { name: 'test-skill', isDirectory: () => true, isFile: () => false }, + ] as unknown as Awaited>); + vi.mocked(fs.access).mockResolvedValue(undefined); + vi.mocked(fs.readFile).mockResolvedValue(validMarkdown); + + const config = await manager.loadSkill('test-skill'); + + expect(config).toBeDefined(); + expect(config!.name).toBe('test-skill'); + }); + + it('should fall back to user level if project level fails', async () => { + vi.mocked(fs.readdir) + .mockRejectedValueOnce(new Error('Project dir not found')) // project level fails + .mockResolvedValueOnce([ + { name: 'test-skill', isDirectory: () => true, isFile: () => false }, + ] as unknown as Awaited>); // user level succeeds + vi.mocked(fs.access).mockResolvedValue(undefined); + vi.mocked(fs.readFile).mockResolvedValue(validMarkdown); + + const config = await manager.loadSkill('test-skill'); + + expect(config).toBeDefined(); + expect(config!.name).toBe('test-skill'); + }); + + it('should return null if not found at either level', async () => { + vi.mocked(fs.readdir).mockRejectedValue(new Error('Directory not found')); + + const config = await manager.loadSkill('nonexistent'); + + expect(config).toBeNull(); + }); + }); + + describe('loadSkillForRuntime', () => { + it('should load skill for runtime', async () => { + vi.mocked(fs.readdir).mockResolvedValueOnce([ + { name: 'test-skill', isDirectory: () => true, isFile: () => false }, + ] as unknown as Awaited>); + + vi.mocked(fs.access).mockResolvedValue(undefined); + vi.mocked(fs.readFile).mockResolvedValue(validMarkdown); // SKILL.md + + const config = await manager.loadSkillForRuntime('test-skill'); + + expect(config).toBeDefined(); + expect(config!.name).toBe('test-skill'); + }); + + it('should return null if skill not found', async () => { + vi.mocked(fs.readdir).mockRejectedValue(new Error('Directory not found')); + + const config = await manager.loadSkillForRuntime('nonexistent'); + + expect(config).toBeNull(); + }); + }); + + describe('listSkills', () => { + beforeEach(() => { + // Mock directory listing for skills directories (with Dirent objects) + vi.mocked(fs.readdir) + .mockResolvedValueOnce([ + { name: 'skill1', isDirectory: () => true, isFile: () => false }, + { name: 'skill2', isDirectory: () => true, isFile: () => false }, + { + name: 'not-a-dir.txt', + isDirectory: () => false, + isFile: () => true, + }, + ] as unknown as Awaited>) + .mockResolvedValueOnce([ + { name: 'skill3', isDirectory: () => true, isFile: () => false }, + { name: 'skill1', isDirectory: () => true, isFile: () => false }, + ] as unknown as Awaited>); + + vi.mocked(fs.access).mockResolvedValue(undefined); + + // Mock file reading for valid skills + vi.mocked(fs.readFile).mockImplementation((filePath) => { + const pathStr = String(filePath); + if (pathStr.includes('skill1')) { + return Promise.resolve(`--- +name: skill1 +description: First skill +--- +Skill 1 content`); + } else if (pathStr.includes('skill2')) { + return Promise.resolve(`--- +name: skill2 +description: Second skill +--- +Skill 2 content`); + } else if (pathStr.includes('skill3')) { + return Promise.resolve(`--- +name: skill3 +description: Third skill +--- +Skill 3 content`); + } + return Promise.reject(new Error('File not found')); + }); + }); + + it('should list skills from both levels', async () => { + const skills = await manager.listSkills(); + + expect(skills).toHaveLength(3); // skill1 (project takes precedence), skill2, skill3 + expect(skills.map((s) => s.name).sort()).toEqual([ + 'skill1', + 'skill2', + 'skill3', + ]); + }); + + it('should prioritize project level over user level', async () => { + const skills = await manager.listSkills(); + const skill1 = skills.find((s) => s.name === 'skill1'); + + expect(skill1!.level).toBe('project'); + }); + + it('should filter by level', async () => { + const projectSkills = await manager.listSkills({ + level: 'project', + }); + + expect(projectSkills).toHaveLength(2); // skill1, skill2 + expect(projectSkills.every((s) => s.level === 'project')).toBe(true); + }); + + it('should handle empty directories', async () => { + vi.mocked(fs.readdir).mockReset(); + vi.mocked(fs.readdir).mockResolvedValue( + [] as unknown as Awaited>, + ); + + const skills = await manager.listSkills({ force: true }); + + expect(skills).toHaveLength(0); + }); + + it('should handle directory read errors', async () => { + vi.mocked(fs.readdir).mockReset(); + vi.mocked(fs.readdir).mockRejectedValue(new Error('Directory not found')); + + const skills = await manager.listSkills({ force: true }); + + expect(skills).toHaveLength(0); + }); + }); + + describe('getSkillsBaseDir', () => { + it('should return project-level base dir', () => { + const baseDir = manager.getSkillsBaseDir('project'); + + expect(baseDir).toBe(path.join('/test/project', '.qwen', 'skills')); + }); + + it('should return user-level base dir', () => { + const baseDir = manager.getSkillsBaseDir('user'); + + expect(baseDir).toBe(path.join('/home/user', '.qwen', 'skills')); + }); + }); + + describe('change listeners', () => { + it('should notify listeners when cache is refreshed', async () => { + const listener = vi.fn(); + manager.addChangeListener(listener); + + vi.mocked(fs.readdir).mockResolvedValue( + [] as unknown as Awaited>, + ); + + await manager.refreshCache(); + + expect(listener).toHaveBeenCalled(); + }); + + it('should remove listener when cleanup function is called', async () => { + const listener = vi.fn(); + const removeListener = manager.addChangeListener(listener); + + removeListener(); + + vi.mocked(fs.readdir).mockResolvedValue( + [] as unknown as Awaited>, + ); + + await manager.refreshCache(); + + expect(listener).not.toHaveBeenCalled(); + }); + }); + + describe('parse errors', () => { + it('should track parse errors', async () => { + vi.mocked(fs.readdir).mockResolvedValue([ + { name: 'bad-skill', isDirectory: () => true, isFile: () => false }, + ] as unknown as Awaited>); + vi.mocked(fs.access).mockResolvedValue(undefined); + vi.mocked(fs.readFile).mockResolvedValue( + 'invalid content without frontmatter', + ); + + await manager.listSkills({ force: true }); + + const errors = manager.getParseErrors(); + expect(errors.size).toBeGreaterThan(0); + }); + }); +}); diff --git a/packages/core/src/skills/skill-manager.ts b/packages/core/src/skills/skill-manager.ts new file mode 100644 index 000000000..77cec15fd --- /dev/null +++ b/packages/core/src/skills/skill-manager.ts @@ -0,0 +1,452 @@ +/** + * @license + * Copyright 2025 Qwen + * SPDX-License-Identifier: Apache-2.0 + */ + +import * as fs from 'fs/promises'; +import * as path from 'path'; +import * as os from 'os'; +import { parse as parseYaml } from '../utils/yaml-parser.js'; +import type { + SkillConfig, + SkillLevel, + ListSkillsOptions, + SkillValidationResult, +} from './types.js'; +import { SkillError, SkillErrorCode } from './types.js'; +import type { Config } from '../config/config.js'; + +const QWEN_CONFIG_DIR = '.qwen'; +const SKILLS_CONFIG_DIR = 'skills'; +const SKILL_MANIFEST_FILE = 'SKILL.md'; + +/** + * Manages skill configurations stored as directories containing SKILL.md files. + * Provides discovery, parsing, validation, and caching for skills. + */ +export class SkillManager { + private skillsCache: Map | null = null; + private readonly changeListeners: Set<() => void> = new Set(); + private parseErrors: Map = new Map(); + + constructor(private readonly config: Config) {} + + /** + * Adds a listener that will be called when skills change. + * @returns A function to remove the listener. + */ + addChangeListener(listener: () => void): () => void { + this.changeListeners.add(listener); + return () => { + this.changeListeners.delete(listener); + }; + } + + /** + * Notifies all registered change listeners. + */ + private notifyChangeListeners(): void { + for (const listener of this.changeListeners) { + try { + listener(); + } catch (error) { + console.warn('Skill change listener threw an error:', error); + } + } + } + + /** + * Gets any parse errors that occurred during skill loading. + * @returns Map of skill paths to their parse errors. + */ + getParseErrors(): Map { + return new Map(this.parseErrors); + } + + /** + * Lists all available skills. + * + * @param options - Filtering options + * @returns Array of skill configurations + */ + async listSkills(options: ListSkillsOptions = {}): Promise { + const skills: SkillConfig[] = []; + const seenNames = new Set(); + + const levelsToCheck: SkillLevel[] = options.level + ? [options.level] + : ['project', 'user']; + + // Check if we should use cache or force refresh + const shouldUseCache = !options.force && this.skillsCache !== null; + + // Initialize cache if it doesn't exist or we're forcing a refresh + if (!shouldUseCache) { + await this.refreshCache(); + } + + // Collect skills from each level (project takes precedence over user) + for (const level of levelsToCheck) { + const levelSkills = this.skillsCache?.get(level) || []; + + for (const skill of levelSkills) { + // Skip if we've already seen this name (precedence: project > user) + if (seenNames.has(skill.name)) { + continue; + } + + skills.push(skill); + seenNames.add(skill.name); + } + } + + // Sort by name for consistent ordering + skills.sort((a, b) => a.name.localeCompare(b.name)); + + return skills; + } + + /** + * Loads a skill configuration by name. + * If level is specified, only searches that level. + * If level is omitted, searches project-level first, then user-level. + * + * @param name - Name of the skill to load + * @param level - Optional level to limit search to + * @returns SkillConfig or null if not found + */ + async loadSkill( + name: string, + level?: SkillLevel, + ): Promise { + if (level) { + return this.findSkillByNameAtLevel(name, level); + } + + // Try project level first + const projectSkill = await this.findSkillByNameAtLevel(name, 'project'); + if (projectSkill) { + return projectSkill; + } + + // Try user level + return this.findSkillByNameAtLevel(name, 'user'); + } + + /** + * Loads a skill with its full content, ready for runtime use. + * This includes loading additional files from the skill directory. + * + * @param name - Name of the skill to load + * @param level - Optional level to limit search to + * @returns SkillConfig or null if not found + */ + async loadSkillForRuntime( + name: string, + level?: SkillLevel, + ): Promise { + const skill = await this.loadSkill(name, level); + if (!skill) { + return null; + } + + return skill; + } + + /** + * Validates a skill configuration. + * + * @param config - Configuration to validate + * @returns Validation result + */ + validateConfig(config: Partial): SkillValidationResult { + const errors: string[] = []; + const warnings: string[] = []; + + // Check required fields + if (typeof config.name !== 'string') { + errors.push('Missing or invalid "name" field'); + } else if (config.name.trim() === '') { + errors.push('"name" cannot be empty'); + } + + if (typeof config.description !== 'string') { + errors.push('Missing or invalid "description" field'); + } else if (config.description.trim() === '') { + errors.push('"description" cannot be empty'); + } + + // Validate allowedTools if present + if (config.allowedTools !== undefined) { + if (!Array.isArray(config.allowedTools)) { + errors.push('"allowedTools" must be an array'); + } else { + for (const tool of config.allowedTools) { + if (typeof tool !== 'string') { + errors.push('"allowedTools" must contain only strings'); + break; + } + } + } + } + + // Warn if body is empty + if (!config.body || config.body.trim() === '') { + warnings.push('Skill body is empty'); + } + + return { + isValid: errors.length === 0, + errors, + warnings, + }; + } + + /** + * Refreshes the skills cache by loading all skills from disk. + */ + async refreshCache(): Promise { + const skillsCache = new Map(); + this.parseErrors.clear(); + + const levels: SkillLevel[] = ['project', 'user']; + + for (const level of levels) { + const levelSkills = await this.listSkillsAtLevel(level); + skillsCache.set(level, levelSkills); + } + + this.skillsCache = skillsCache; + this.notifyChangeListeners(); + } + + /** + * Parses a SKILL.md file and returns the configuration. + * + * @param filePath - Path to the SKILL.md file + * @param level - Storage level + * @returns SkillConfig + * @throws SkillError if parsing fails + */ + parseSkillFile(filePath: string, level: SkillLevel): Promise { + return this.parseSkillFileInternal(filePath, level); + } + + /** + * Internal implementation of skill file parsing. + */ + private async parseSkillFileInternal( + filePath: string, + level: SkillLevel, + ): Promise { + let content: string; + + try { + content = await fs.readFile(filePath, 'utf8'); + } catch (error) { + const skillError = new SkillError( + `Failed to read skill file: ${error instanceof Error ? error.message : 'Unknown error'}`, + SkillErrorCode.FILE_ERROR, + ); + this.parseErrors.set(filePath, skillError); + throw skillError; + } + + return this.parseSkillContent(content, filePath, level); + } + + /** + * Parses skill content from a string. + * + * @param content - File content + * @param filePath - File path for error reporting + * @param level - Storage level + * @returns SkillConfig + * @throws SkillError if parsing fails + */ + parseSkillContent( + content: string, + filePath: string, + level: SkillLevel, + ): SkillConfig { + try { + // Split frontmatter and content + const frontmatterRegex = /^---\n([\s\S]*?)\n---\n([\s\S]*)$/; + const match = content.match(frontmatterRegex); + + if (!match) { + throw new Error('Invalid format: missing YAML frontmatter'); + } + + const [, frontmatterYaml, body] = match; + + // Parse YAML frontmatter + const frontmatter = parseYaml(frontmatterYaml) as Record; + + // Extract required fields + const nameRaw = frontmatter['name']; + const descriptionRaw = frontmatter['description']; + + if (nameRaw == null || nameRaw === '') { + throw new Error('Missing "name" in frontmatter'); + } + + if (descriptionRaw == null || descriptionRaw === '') { + throw new Error('Missing "description" in frontmatter'); + } + + // Convert to strings + const name = String(nameRaw); + const description = String(descriptionRaw); + + // Extract optional fields + const allowedToolsRaw = frontmatter['allowedTools'] as + | unknown[] + | undefined; + let allowedTools: string[] | undefined; + + if (allowedToolsRaw !== undefined) { + if (Array.isArray(allowedToolsRaw)) { + allowedTools = allowedToolsRaw.map(String); + } else { + throw new Error('"allowedTools" must be an array'); + } + } + + const config: SkillConfig = { + name, + description, + allowedTools, + level, + filePath, + body: body.trim(), + }; + + // Validate the parsed configuration + const validation = this.validateConfig(config); + if (!validation.isValid) { + throw new Error(`Validation failed: ${validation.errors.join(', ')}`); + } + + return config; + } catch (error) { + const skillError = new SkillError( + `Failed to parse skill file: ${error instanceof Error ? error.message : 'Unknown error'}`, + SkillErrorCode.PARSE_ERROR, + ); + this.parseErrors.set(filePath, skillError); + throw skillError; + } + } + + /** + * Gets the base directory for skills at a specific level. + * + * @param level - Storage level + * @returns Absolute directory path + */ + getSkillsBaseDir(level: SkillLevel): string { + const baseDir = + level === 'project' + ? path.join( + this.config.getProjectRoot(), + QWEN_CONFIG_DIR, + SKILLS_CONFIG_DIR, + ) + : path.join(os.homedir(), QWEN_CONFIG_DIR, SKILLS_CONFIG_DIR); + + return baseDir; + } + + /** + * Lists skills at a specific level. + * + * @param level - Storage level to scan + * @returns Array of skill configurations + */ + private async listSkillsAtLevel(level: SkillLevel): Promise { + const projectRoot = this.config.getProjectRoot(); + const homeDir = os.homedir(); + const isHomeDirectory = path.resolve(projectRoot) === path.resolve(homeDir); + + // If project level is requested but project root is same as home directory, + // return empty array to avoid conflicts between project and global skills + if (level === 'project' && isHomeDirectory) { + return []; + } + + const baseDir = this.getSkillsBaseDir(level); + + try { + const entries = await fs.readdir(baseDir, { withFileTypes: true }); + const skills: SkillConfig[] = []; + + for (const entry of entries) { + // Only process directories (each skill is a directory) + if (!entry.isDirectory()) continue; + + const skillDir = path.join(baseDir, entry.name); + const skillManifest = path.join(skillDir, SKILL_MANIFEST_FILE); + + try { + // Check if SKILL.md exists + await fs.access(skillManifest); + + const config = await this.parseSkillFileInternal( + skillManifest, + level, + ); + skills.push(config); + } catch (error) { + // Skip directories without valid SKILL.md + if (error instanceof SkillError) { + // Parse error was already recorded + console.warn( + `Failed to parse skill at ${skillDir}: ${error.message}`, + ); + } + continue; + } + } + + return skills; + } catch (_error) { + // Directory doesn't exist or can't be read + return []; + } + } + + /** + * Finds a skill by name at a specific level. + * + * @param name - Name of the skill to find + * @param level - Storage level to search + * @returns SkillConfig or null if not found + */ + private async findSkillByNameAtLevel( + name: string, + level: SkillLevel, + ): Promise { + await this.ensureLevelCache(level); + + const levelSkills = this.skillsCache?.get(level) || []; + + // Find the skill with matching name + return levelSkills.find((skill) => skill.name === name) || null; + } + + /** + * Ensures the cache is populated for a specific level without loading other levels. + */ + private async ensureLevelCache(level: SkillLevel): Promise { + if (!this.skillsCache) { + this.skillsCache = new Map(); + } + + if (!this.skillsCache.has(level)) { + const levelSkills = await this.listSkillsAtLevel(level); + this.skillsCache.set(level, levelSkills); + } + } +} diff --git a/packages/core/src/skills/types.ts b/packages/core/src/skills/types.ts new file mode 100644 index 000000000..75dfe014a --- /dev/null +++ b/packages/core/src/skills/types.ts @@ -0,0 +1,105 @@ +/** + * @license + * Copyright 2025 Qwen + * SPDX-License-Identifier: Apache-2.0 + */ + +/** + * Represents the storage level for a skill configuration. + * - 'project': Stored in `.qwen/skills/` within the project directory + * - 'user': Stored in `~/.qwen/skills/` in the user's home directory + */ +export type SkillLevel = 'project' | 'user'; + +/** + * Core configuration for a skill as stored in SKILL.md files. + * Each skill directory contains a SKILL.md file with YAML frontmatter + * containing metadata, followed by markdown content describing the skill. + */ +export interface SkillConfig { + /** Unique name identifier for the skill */ + name: string; + + /** Human-readable description of what this skill provides */ + description: string; + + /** + * Optional list of tool names that this skill is allowed to use. + * For v1, this is informational only (no gating). + */ + allowedTools?: string[]; + + /** + * Storage level - determines where the configuration file is stored + */ + level: SkillLevel; + + /** + * Absolute path to the skill directory containing SKILL.md + */ + filePath: string; + + /** + * The markdown body content from SKILL.md (after the frontmatter) + */ + body: string; +} + +/** + * Runtime configuration for a skill when it's being actively used. + * Extends SkillConfig with additional runtime-specific fields. + */ +export type SkillRuntimeConfig = SkillConfig; + +/** + * Result of a validation operation on a skill configuration. + */ +export interface SkillValidationResult { + /** Whether the configuration is valid */ + isValid: boolean; + + /** Array of error messages if validation failed */ + errors: string[]; + + /** Array of warning messages (non-blocking issues) */ + warnings: string[]; +} + +/** + * Options for listing skills. + */ +export interface ListSkillsOptions { + /** Filter by storage level */ + level?: SkillLevel; + + /** Force refresh from disk, bypassing cache. Defaults to false. */ + force?: boolean; +} + +/** + * Error thrown when a skill operation fails. + */ +export class SkillError extends Error { + constructor( + message: string, + readonly code: SkillErrorCode, + readonly skillName?: string, + ) { + super(message); + this.name = 'SkillError'; + } +} + +/** + * Error codes for skill operations. + */ +export const SkillErrorCode = { + NOT_FOUND: 'NOT_FOUND', + INVALID_CONFIG: 'INVALID_CONFIG', + INVALID_NAME: 'INVALID_NAME', + FILE_ERROR: 'FILE_ERROR', + PARSE_ERROR: 'PARSE_ERROR', +} as const; + +export type SkillErrorCode = + (typeof SkillErrorCode)[keyof typeof SkillErrorCode]; diff --git a/packages/core/src/subagents/index.ts b/packages/core/src/subagents/index.ts index 5560b4fdf..17c62a200 100644 --- a/packages/core/src/subagents/index.ts +++ b/packages/core/src/subagents/index.ts @@ -58,6 +58,7 @@ export type { SubAgentStartEvent, SubAgentRoundEvent, SubAgentStreamTextEvent, + SubAgentUsageEvent, SubAgentToolCallEvent, SubAgentToolResultEvent, SubAgentFinishEvent, diff --git a/packages/core/src/subagents/subagent-events.ts b/packages/core/src/subagents/subagent-events.ts index 3c93112dd..1f7933087 100644 --- a/packages/core/src/subagents/subagent-events.ts +++ b/packages/core/src/subagents/subagent-events.ts @@ -10,7 +10,7 @@ import type { ToolConfirmationOutcome, ToolResultDisplay, } from '../tools/tools.js'; -import type { Part } from '@google/genai'; +import type { Part, GenerateContentResponseUsageMetadata } from '@google/genai'; export type SubAgentEvent = | 'start' @@ -20,6 +20,7 @@ export type SubAgentEvent = | 'tool_call' | 'tool_result' | 'tool_waiting_approval' + | 'usage_metadata' | 'finish' | 'error'; @@ -31,6 +32,7 @@ export enum SubAgentEventType { TOOL_CALL = 'tool_call', TOOL_RESULT = 'tool_result', TOOL_WAITING_APPROVAL = 'tool_waiting_approval', + USAGE_METADATA = 'usage_metadata', FINISH = 'finish', ERROR = 'error', } @@ -57,6 +59,14 @@ export interface SubAgentStreamTextEvent { timestamp: number; } +export interface SubAgentUsageEvent { + subagentId: string; + round: number; + usage: GenerateContentResponseUsageMetadata; + durationMs?: number; + timestamp: number; +} + export interface SubAgentToolCallEvent { subagentId: string; round: number; diff --git a/packages/core/src/subagents/subagent-statistics.test.ts b/packages/core/src/subagents/subagent-statistics.test.ts index 5b4ae3c62..39ba70aa4 100644 --- a/packages/core/src/subagents/subagent-statistics.test.ts +++ b/packages/core/src/subagents/subagent-statistics.test.ts @@ -50,6 +50,15 @@ describe('SubagentStatistics', () => { expect(summary.outputTokens).toBe(600); expect(summary.totalTokens).toBe(1800); }); + + it('should track thought and cached tokens', () => { + stats.recordTokens(100, 50, 10, 5); + + const summary = stats.getSummary(); + expect(summary.thoughtTokens).toBe(10); + expect(summary.cachedTokens).toBe(5); + expect(summary.totalTokens).toBe(165); // 100 + 50 + 10 + 5 + }); }); describe('tool usage statistics', () => { @@ -93,14 +102,14 @@ describe('SubagentStatistics', () => { stats.start(baseTime); stats.setRounds(2); stats.recordToolCall('file_read', true, 100); - stats.recordTokens(1000, 500); + stats.recordTokens(1000, 500, 20, 10); const result = stats.formatCompact('Test task', baseTime + 5000); expect(result).toContain('📋 Task Completed: Test task'); expect(result).toContain('🔧 Tool Usage: 1 calls, 100.0% success'); expect(result).toContain('⏱️ Duration: 5.0s | 🔁 Rounds: 2'); - expect(result).toContain('🔢 Tokens: 1,500 (in 1000, out 500)'); + expect(result).toContain('🔢 Tokens: 1,530 (in 1000, out 500)'); }); it('should handle zero tool calls', () => { diff --git a/packages/core/src/subagents/subagent-statistics.ts b/packages/core/src/subagents/subagent-statistics.ts index 3ef120c6e..72308c633 100644 --- a/packages/core/src/subagents/subagent-statistics.ts +++ b/packages/core/src/subagents/subagent-statistics.ts @@ -23,6 +23,8 @@ export interface SubagentStatsSummary { successRate: number; inputTokens: number; outputTokens: number; + thoughtTokens: number; + cachedTokens: number; totalTokens: number; estimatedCost: number; toolUsage: ToolUsageStats[]; @@ -36,6 +38,8 @@ export class SubagentStatistics { private failedToolCalls = 0; private inputTokens = 0; private outputTokens = 0; + private thoughtTokens = 0; + private cachedTokens = 0; private toolUsage = new Map(); start(now = Date.now()) { @@ -74,9 +78,16 @@ export class SubagentStatistics { this.toolUsage.set(name, tu); } - recordTokens(input: number, output: number) { + recordTokens( + input: number, + output: number, + thought: number = 0, + cached: number = 0, + ) { this.inputTokens += Math.max(0, input || 0); this.outputTokens += Math.max(0, output || 0); + this.thoughtTokens += Math.max(0, thought || 0); + this.cachedTokens += Math.max(0, cached || 0); } getSummary(now = Date.now()): SubagentStatsSummary { @@ -86,7 +97,11 @@ export class SubagentStatistics { totalToolCalls > 0 ? (this.successfulToolCalls / totalToolCalls) * 100 : 0; - const totalTokens = this.inputTokens + this.outputTokens; + const totalTokens = + this.inputTokens + + this.outputTokens + + this.thoughtTokens + + this.cachedTokens; const estimatedCost = this.inputTokens * 3e-5 + this.outputTokens * 6e-5; return { rounds: this.rounds, @@ -97,6 +112,8 @@ export class SubagentStatistics { successRate, inputTokens: this.inputTokens, outputTokens: this.outputTokens, + thoughtTokens: this.thoughtTokens, + cachedTokens: this.cachedTokens, totalTokens, estimatedCost, toolUsage: Array.from(this.toolUsage.values()), @@ -116,8 +133,12 @@ export class SubagentStatistics { `⏱️ Duration: ${this.fmtDuration(stats.totalDurationMs)} | 🔁 Rounds: ${stats.rounds}`, ]; if (typeof stats.totalTokens === 'number') { + const parts = [ + `in ${stats.inputTokens ?? 0}`, + `out ${stats.outputTokens ?? 0}`, + ]; lines.push( - `🔢 Tokens: ${stats.totalTokens.toLocaleString()}${stats.inputTokens || stats.outputTokens ? ` (in ${stats.inputTokens ?? 0}, out ${stats.outputTokens ?? 0})` : ''}`, + `🔢 Tokens: ${stats.totalTokens.toLocaleString()}${parts.length ? ` (${parts.join(', ')})` : ''}`, ); } return lines.join('\n'); @@ -152,8 +173,12 @@ export class SubagentStatistics { `🔧 Tools: ${stats.totalToolCalls} calls, ${sr.toFixed(1)}% success (${stats.successfulToolCalls} ok, ${stats.failedToolCalls} failed)`, ); if (typeof stats.totalTokens === 'number') { + const parts = [ + `in ${stats.inputTokens ?? 0}`, + `out ${stats.outputTokens ?? 0}`, + ]; lines.push( - `🔢 Tokens: ${stats.totalTokens.toLocaleString()} (in ${stats.inputTokens ?? 0}, out ${stats.outputTokens ?? 0})`, + `🔢 Tokens: ${stats.totalTokens.toLocaleString()} (${parts.join(', ')})`, ); } if (stats.toolUsage && stats.toolUsage.length) { diff --git a/packages/core/src/subagents/subagent.test.ts b/packages/core/src/subagents/subagent.test.ts index 256fb44da..742813cdb 100644 --- a/packages/core/src/subagents/subagent.test.ts +++ b/packages/core/src/subagents/subagent.test.ts @@ -69,6 +69,8 @@ async function createMockConfig( targetDir: '.', debugMode: false, cwd: process.cwd(), + // Avoid writing any chat recording records from tests (e.g. via tool-call telemetry). + chatRecording: false, }; const config = new Config(configParams); await config.initialize(); diff --git a/packages/core/src/subagents/subagent.ts b/packages/core/src/subagents/subagent.ts index 885e8ca6f..39e43e54f 100644 --- a/packages/core/src/subagents/subagent.ts +++ b/packages/core/src/subagents/subagent.ts @@ -41,6 +41,7 @@ import type { SubAgentToolResultEvent, SubAgentStreamTextEvent, SubAgentErrorEvent, + SubAgentUsageEvent, } from './subagent-events.js'; import { type SubAgentEventEmitter, @@ -369,6 +370,7 @@ export class SubAgentScope { }, }; + const roundStreamStart = Date.now(); const responseStream = await chat.sendMessageStream( this.modelConfig.model || this.runtimeContext.getModel() || @@ -439,10 +441,19 @@ export class SubAgentScope { if (lastUsage) { const inTok = Number(lastUsage.promptTokenCount || 0); const outTok = Number(lastUsage.candidatesTokenCount || 0); - if (isFinite(inTok) || isFinite(outTok)) { + const thoughtTok = Number(lastUsage.thoughtsTokenCount || 0); + const cachedTok = Number(lastUsage.cachedContentTokenCount || 0); + if ( + isFinite(inTok) || + isFinite(outTok) || + isFinite(thoughtTok) || + isFinite(cachedTok) + ) { this.stats.recordTokens( isFinite(inTok) ? inTok : 0, isFinite(outTok) ? outTok : 0, + isFinite(thoughtTok) ? thoughtTok : 0, + isFinite(cachedTok) ? cachedTok : 0, ); // mirror legacy fields for compatibility this.executionStats.inputTokens = @@ -453,11 +464,20 @@ export class SubAgentScope { (isFinite(outTok) ? outTok : 0); this.executionStats.totalTokens = (this.executionStats.inputTokens || 0) + - (this.executionStats.outputTokens || 0); + (this.executionStats.outputTokens || 0) + + (isFinite(thoughtTok) ? thoughtTok : 0) + + (isFinite(cachedTok) ? cachedTok : 0); this.executionStats.estimatedCost = (this.executionStats.inputTokens || 0) * 3e-5 + (this.executionStats.outputTokens || 0) * 6e-5; } + this.eventEmitter?.emit(SubAgentEventType.USAGE_METADATA, { + subagentId: this.subagentId, + round: turnCounter, + usage: lastUsage, + durationMs: Date.now() - roundStreamStart, + timestamp: Date.now(), + } as SubAgentUsageEvent); } if (functionCalls.length > 0) { diff --git a/packages/core/src/telemetry/clearcut-logger/clearcut-logger.test.ts b/packages/core/src/telemetry/clearcut-logger/clearcut-logger.test.ts index 3ece605f2..4dd037205 100644 --- a/packages/core/src/telemetry/clearcut-logger/clearcut-logger.test.ts +++ b/packages/core/src/telemetry/clearcut-logger/clearcut-logger.test.ts @@ -30,7 +30,6 @@ import { ToolCallEvent, } from '../types.js'; import { GIT_COMMIT_INFO, CLI_VERSION } from '../../generated/git-commit.js'; -import { UserAccountManager } from '../../utils/userAccountManager.js'; import { InstallationManager } from '../../utils/installationManager.js'; import { safeJsonStringify } from '../../utils/safeJsonStringify.js'; @@ -90,10 +89,8 @@ expect.extend({ }, }); -vi.mock('../../utils/userAccountManager.js'); vi.mock('../../utils/installationManager.js'); -const mockUserAccount = vi.mocked(UserAccountManager.prototype); const mockInstallMgr = vi.mocked(InstallationManager.prototype); // TODO(richieforeman): Consider moving this to test setup globally. @@ -128,11 +125,7 @@ describe('ClearcutLogger', () => { vi.unstubAllEnvs(); }); - function setup({ - config = {} as Partial, - lifetimeGoogleAccounts = 1, - cachedGoogleAccount = 'test@google.com', - } = {}) { + function setup({ config = {} as Partial } = {}) { server.resetHandlers( http.post(CLEARCUT_URL, () => HttpResponse.text(EXAMPLE_RESPONSE)), ); @@ -146,10 +139,6 @@ describe('ClearcutLogger', () => { }); ClearcutLogger.clearInstance(); - mockUserAccount.getCachedGoogleAccount.mockReturnValue(cachedGoogleAccount); - mockUserAccount.getLifetimeGoogleAccounts.mockReturnValue( - lifetimeGoogleAccounts, - ); mockInstallMgr.getInstallationId = vi .fn() .mockReturnValue('test-installation-id'); @@ -195,19 +184,6 @@ describe('ClearcutLogger', () => { }); describe('createLogEvent', () => { - it('logs the total number of google accounts', () => { - const { logger } = setup({ - lifetimeGoogleAccounts: 9001, - }); - - const event = logger?.createLogEvent(EventNames.API_ERROR, []); - - expect(event?.event_metadata[0]).toContainEqual({ - gemini_cli_key: EventMetadataKey.GEMINI_CLI_GOOGLE_ACCOUNTS_COUNT, - value: '9001', - }); - }); - it('logs the current surface from a github action', () => { const { logger } = setup({}); @@ -251,7 +227,6 @@ describe('ClearcutLogger', () => { // Define expected values const session_id = 'test-session-id'; const auth_type = AuthType.USE_GEMINI; - const google_accounts = 123; const surface = 'ide-1234'; const cli_version = CLI_VERSION; const git_commit_hash = GIT_COMMIT_INFO; @@ -260,7 +235,6 @@ describe('ClearcutLogger', () => { // Setup logger with expected values const { logger, loggerConfig } = setup({ - lifetimeGoogleAccounts: google_accounts, config: {}, }); vi.spyOn(loggerConfig, 'getContentGeneratorConfig').mockReturnValue({ @@ -283,10 +257,6 @@ describe('ClearcutLogger', () => { gemini_cli_key: EventMetadataKey.GEMINI_CLI_AUTH_TYPE, value: JSON.stringify(auth_type), }, - { - gemini_cli_key: EventMetadataKey.GEMINI_CLI_GOOGLE_ACCOUNTS_COUNT, - value: `${google_accounts}`, - }, { gemini_cli_key: EventMetadataKey.GEMINI_CLI_SURFACE, value: surface, @@ -404,10 +374,14 @@ describe('ClearcutLogger', () => { vi.stubEnv(key, value); } const event = logger?.createLogEvent(EventNames.API_ERROR, []); - expect(event?.event_metadata[0][3]).toEqual({ - gemini_cli_key: EventMetadataKey.GEMINI_CLI_SURFACE, - value: expectedValue, - }); + expect(event?.event_metadata[0]).toEqual( + expect.arrayContaining([ + { + gemini_cli_key: EventMetadataKey.GEMINI_CLI_SURFACE, + value: expectedValue, + }, + ]), + ); }, ); }); diff --git a/packages/core/src/telemetry/clearcut-logger/clearcut-logger.ts b/packages/core/src/telemetry/clearcut-logger/clearcut-logger.ts index 7ca1f6709..5c75d0c2a 100644 --- a/packages/core/src/telemetry/clearcut-logger/clearcut-logger.ts +++ b/packages/core/src/telemetry/clearcut-logger/clearcut-logger.ts @@ -34,7 +34,6 @@ import type { import { EventMetadataKey } from './event-metadata-key.js'; import type { Config } from '../../config/config.js'; import { InstallationManager } from '../../utils/installationManager.js'; -import { UserAccountManager } from '../../utils/userAccountManager.js'; import { safeJsonStringify } from '../../utils/safeJsonStringify.js'; import { FixedDeque } from 'mnemonist'; import { GIT_COMMIT_INFO, CLI_VERSION } from '../../generated/git-commit.js'; @@ -157,7 +156,6 @@ export class ClearcutLogger { private sessionData: EventValue[] = []; private promptId: string = ''; private readonly installationManager: InstallationManager; - private readonly userAccountManager: UserAccountManager; /** * Queue of pending events that need to be flushed to the server. New events @@ -186,7 +184,6 @@ export class ClearcutLogger { this.events = new FixedDeque(Array, MAX_EVENTS); this.promptId = config?.getSessionId() ?? ''; this.installationManager = new InstallationManager(); - this.userAccountManager = new UserAccountManager(); } static getInstance(config?: Config): ClearcutLogger | undefined { @@ -233,14 +230,11 @@ export class ClearcutLogger { } createLogEvent(eventName: EventNames, data: EventValue[] = []): LogEvent { - const email = this.userAccountManager.getCachedGoogleAccount(); - if (eventName !== EventNames.START_SESSION) { data.push(...this.sessionData); } - const totalAccounts = this.userAccountManager.getLifetimeGoogleAccounts(); - data = this.addDefaultFields(data, totalAccounts); + data = this.addDefaultFields(data); const logEvent: LogEvent = { console_type: 'GEMINI_CLI', @@ -249,12 +243,7 @@ export class ClearcutLogger { event_metadata: [data], }; - // Should log either email or install ID, not both. See go/cloudmill-1p-oss-instrumentation#define-sessionable-id - if (email) { - logEvent.client_email = email; - } else { - logEvent.client_install_id = this.installationManager.getInstallationId(); - } + logEvent.client_install_id = this.installationManager.getInstallationId(); return logEvent; } @@ -1018,7 +1007,7 @@ export class ClearcutLogger { * Adds default fields to data, and returns a new data array. This fields * should exist on all log events. */ - addDefaultFields(data: EventValue[], totalAccounts: number): EventValue[] { + addDefaultFields(data: EventValue[]): EventValue[] { const surface = determineSurface(); const defaultLogMetadata: EventValue[] = [ @@ -1032,10 +1021,6 @@ export class ClearcutLogger { this.config?.getContentGeneratorConfig()?.authType, ), }, - { - gemini_cli_key: EventMetadataKey.GEMINI_CLI_GOOGLE_ACCOUNTS_COUNT, - value: `${totalAccounts}`, - }, { gemini_cli_key: EventMetadataKey.GEMINI_CLI_SURFACE, value: surface, diff --git a/packages/core/src/telemetry/constants.ts b/packages/core/src/telemetry/constants.ts index bc6546379..acbf56025 100644 --- a/packages/core/src/telemetry/constants.ts +++ b/packages/core/src/telemetry/constants.ts @@ -33,6 +33,7 @@ export const EVENT_MALFORMED_JSON_RESPONSE = export const EVENT_FILE_OPERATION = 'qwen-code.file_operation'; export const EVENT_MODEL_SLASH_COMMAND = 'qwen-code.slash_command.model'; export const EVENT_SUBAGENT_EXECUTION = 'qwen-code.subagent_execution'; +export const EVENT_SKILL_LAUNCH = 'qwen-code.skill_launch'; export const EVENT_AUTH = 'qwen-code.auth'; // Performance Events diff --git a/packages/core/src/telemetry/index.ts b/packages/core/src/telemetry/index.ts index 9adb2d32c..d0feb0202 100644 --- a/packages/core/src/telemetry/index.ts +++ b/packages/core/src/telemetry/index.ts @@ -44,6 +44,7 @@ export { logRipgrepFallback, logNextSpeakerCheck, logAuth, + logSkillLaunch, } from './loggers.js'; export type { SlashCommandEvent, ChatCompressionEvent } from './types.js'; export { @@ -63,6 +64,7 @@ export { RipgrepFallbackEvent, NextSpeakerCheckEvent, AuthEvent, + SkillLaunchEvent, } from './types.js'; export { makeSlashCommandEvent, makeChatCompressionEvent } from './types.js'; export type { TelemetryEvent } from './types.js'; diff --git a/packages/core/src/telemetry/loggers.test.ts b/packages/core/src/telemetry/loggers.test.ts index 1167cc6a0..ab026304a 100644 --- a/packages/core/src/telemetry/loggers.test.ts +++ b/packages/core/src/telemetry/loggers.test.ts @@ -83,7 +83,6 @@ import type { } from '@google/genai'; import { DiscoveredMCPTool } from '../tools/mcp-tool.js'; import * as uiTelemetry from './uiTelemetry.js'; -import { UserAccountManager } from '../utils/userAccountManager.js'; import { makeFakeConfig } from '../test-utils/config.js'; describe('loggers', () => { @@ -101,10 +100,6 @@ describe('loggers', () => { vi.spyOn(uiTelemetry.uiTelemetryService, 'addEvent').mockImplementation( mockUiEvent.addEvent, ); - vi.spyOn( - UserAccountManager.prototype, - 'getCachedGoogleAccount', - ).mockReturnValue('test-user@example.com'); vi.useFakeTimers(); vi.setSystemTime(new Date('2025-01-01T00:00:00.000Z')); }); @@ -188,7 +183,6 @@ describe('loggers', () => { body: 'CLI configuration loaded.', attributes: { 'session.id': 'test-session-id', - 'user.email': 'test-user@example.com', 'event.name': EVENT_CLI_CONFIG, 'event.timestamp': '2025-01-01T00:00:00.000Z', model: 'test-model', @@ -206,6 +200,8 @@ describe('loggers', () => { mcp_tools: undefined, mcp_tools_count: undefined, output_format: 'json', + skills: undefined, + subagents: undefined, }, }); }); @@ -233,7 +229,6 @@ describe('loggers', () => { body: 'User prompt. Length: 11.', attributes: { 'session.id': 'test-session-id', - 'user.email': 'test-user@example.com', 'event.name': EVENT_USER_PROMPT, 'event.timestamp': '2025-01-01T00:00:00.000Z', prompt_length: 11, @@ -255,7 +250,7 @@ describe('loggers', () => { const event = new UserPromptEvent( 11, 'prompt-id-9', - AuthType.CLOUD_SHELL, + AuthType.USE_GEMINI, 'test-prompt', ); @@ -265,12 +260,11 @@ describe('loggers', () => { body: 'User prompt. Length: 11.', attributes: { 'session.id': 'test-session-id', - 'user.email': 'test-user@example.com', 'event.name': EVENT_USER_PROMPT, 'event.timestamp': '2025-01-01T00:00:00.000Z', prompt_length: 11, prompt_id: 'prompt-id-9', - auth_type: 'cloud-shell', + auth_type: 'gemini', }, }); }); @@ -313,7 +307,7 @@ describe('loggers', () => { 'test-model', 100, 'prompt-id-1', - AuthType.LOGIN_WITH_GOOGLE, + AuthType.USE_GEMINI, usageData, 'test-response', ); @@ -324,7 +318,6 @@ describe('loggers', () => { body: 'API response from test-model. Status: 200. Duration: 100ms.', attributes: { 'session.id': 'test-session-id', - 'user.email': 'test-user@example.com', 'event.name': EVENT_API_RESPONSE, 'event.timestamp': '2025-01-01T00:00:00.000Z', [SemanticAttributes.HTTP_STATUS_CODE]: 200, @@ -340,7 +333,7 @@ describe('loggers', () => { total_token_count: 0, response_text: 'test-response', prompt_id: 'prompt-id-1', - auth_type: 'oauth-personal', + auth_type: 'gemini', }, }); @@ -386,7 +379,6 @@ describe('loggers', () => { body: 'API request to test-model.', attributes: { 'session.id': 'test-session-id', - 'user.email': 'test-user@example.com', 'event.name': EVENT_API_REQUEST, 'event.timestamp': '2025-01-01T00:00:00.000Z', model: 'test-model', @@ -405,7 +397,6 @@ describe('loggers', () => { body: 'API request to test-model.', attributes: { 'session.id': 'test-session-id', - 'user.email': 'test-user@example.com', 'event.name': EVENT_API_REQUEST, 'event.timestamp': '2025-01-01T00:00:00.000Z', model: 'test-model', @@ -430,7 +421,6 @@ describe('loggers', () => { body: 'Switching to flash as Fallback.', attributes: { 'session.id': 'test-session-id', - 'user.email': 'test-user@example.com', 'event.name': EVENT_FLASH_FALLBACK, 'event.timestamp': '2025-01-01T00:00:00.000Z', auth_type: 'vertex-ai', @@ -465,7 +455,6 @@ describe('loggers', () => { expect(emittedEvent.attributes).toEqual( expect.objectContaining({ 'session.id': 'test-session-id', - 'user.email': 'test-user@example.com', 'event.name': EVENT_RIPGREP_FALLBACK, error: 'ripgrep is not available', }), @@ -484,7 +473,6 @@ describe('loggers', () => { expect(emittedEvent.attributes).toEqual( expect.objectContaining({ 'session.id': 'test-session-id', - 'user.email': 'test-user@example.com', 'event.name': EVENT_RIPGREP_FALLBACK, error: 'rg not found', }), @@ -598,7 +586,6 @@ describe('loggers', () => { body: 'Tool call: test-function. Decision: accept. Success: true. Duration: 100ms.', attributes: { 'session.id': 'test-session-id', - 'user.email': 'test-user@example.com', 'event.name': EVENT_TOOL_CALL, 'event.timestamp': '2025-01-01T00:00:00.000Z', function_name: 'test-function', @@ -682,7 +669,6 @@ describe('loggers', () => { body: 'Tool call: test-function. Decision: reject. Success: false. Duration: 100ms.', attributes: { 'session.id': 'test-session-id', - 'user.email': 'test-user@example.com', 'event.name': EVENT_TOOL_CALL, 'event.timestamp': '2025-01-01T00:00:00.000Z', function_name: 'test-function', @@ -759,7 +745,6 @@ describe('loggers', () => { body: 'Tool call: test-function. Decision: modify. Success: true. Duration: 100ms.', attributes: { 'session.id': 'test-session-id', - 'user.email': 'test-user@example.com', 'event.name': EVENT_TOOL_CALL, 'event.timestamp': '2025-01-01T00:00:00.000Z', function_name: 'test-function', @@ -835,7 +820,6 @@ describe('loggers', () => { body: 'Tool call: test-function. Success: true. Duration: 100ms.', attributes: { 'session.id': 'test-session-id', - 'user.email': 'test-user@example.com', 'event.name': EVENT_TOOL_CALL, 'event.timestamp': '2025-01-01T00:00:00.000Z', function_name: 'test-function', @@ -910,7 +894,6 @@ describe('loggers', () => { body: 'Tool call: test-function. Success: false. Duration: 100ms.', attributes: { 'session.id': 'test-session-id', - 'user.email': 'test-user@example.com', 'event.name': EVENT_TOOL_CALL, 'event.timestamp': '2025-01-01T00:00:00.000Z', function_name: 'test-function', @@ -999,7 +982,6 @@ describe('loggers', () => { body: 'Tool call: mock_mcp_tool. Success: true. Duration: 100ms.', attributes: { 'session.id': 'test-session-id', - 'user.email': 'test-user@example.com', 'event.name': EVENT_TOOL_CALL, 'event.timestamp': '2025-01-01T00:00:00.000Z', function_name: 'mock_mcp_tool', @@ -1047,7 +1029,6 @@ describe('loggers', () => { body: 'Malformed JSON response from test-model.', attributes: { 'session.id': 'test-session-id', - 'user.email': 'test-user@example.com', 'event.name': EVENT_MALFORMED_JSON_RESPONSE, 'event.timestamp': '2025-01-01T00:00:00.000Z', model: 'test-model', @@ -1091,7 +1072,6 @@ describe('loggers', () => { body: 'File operation: read. Lines: 10.', attributes: { 'session.id': 'test-session-id', - 'user.email': 'test-user@example.com', 'event.name': EVENT_FILE_OPERATION, 'event.timestamp': '2025-01-01T00:00:00.000Z', tool_name: 'test-tool', @@ -1137,7 +1117,6 @@ describe('loggers', () => { body: 'Tool output truncated for test-tool.', attributes: { 'session.id': 'test-session-id', - 'user.email': 'test-user@example.com', 'event.name': 'tool_output_truncated', 'event.timestamp': '2025-01-01T00:00:00.000Z', eventName: 'tool_output_truncated', @@ -1184,7 +1163,6 @@ describe('loggers', () => { body: 'Installed extension vscode', attributes: { 'session.id': 'test-session-id', - 'user.email': 'test-user@example.com', 'event.name': EVENT_EXTENSION_INSTALL, 'event.timestamp': '2025-01-01T00:00:00.000Z', extension_name: 'vscode', @@ -1223,7 +1201,6 @@ describe('loggers', () => { body: 'Uninstalled extension vscode', attributes: { 'session.id': 'test-session-id', - 'user.email': 'test-user@example.com', 'event.name': EVENT_EXTENSION_UNINSTALL, 'event.timestamp': '2025-01-01T00:00:00.000Z', extension_name: 'vscode', @@ -1260,7 +1237,6 @@ describe('loggers', () => { body: 'Enabled extension vscode', attributes: { 'session.id': 'test-session-id', - 'user.email': 'test-user@example.com', 'event.name': EVENT_EXTENSION_ENABLE, 'event.timestamp': '2025-01-01T00:00:00.000Z', extension_name: 'vscode', @@ -1297,7 +1273,6 @@ describe('loggers', () => { body: 'Disabled extension vscode', attributes: { 'session.id': 'test-session-id', - 'user.email': 'test-user@example.com', 'event.name': EVENT_EXTENSION_DISABLE, 'event.timestamp': '2025-01-01T00:00:00.000Z', extension_name: 'vscode', diff --git a/packages/core/src/telemetry/loggers.ts b/packages/core/src/telemetry/loggers.ts index b7039a556..16b4dc5a7 100644 --- a/packages/core/src/telemetry/loggers.ts +++ b/packages/core/src/telemetry/loggers.ts @@ -9,7 +9,6 @@ import { logs } from '@opentelemetry/api-logs'; import { SemanticAttributes } from '@opentelemetry/semantic-conventions'; import type { Config } from '../config/config.js'; import { safeJsonStringify } from '../utils/safeJsonStringify.js'; -import { UserAccountManager } from '../utils/userAccountManager.js'; import { EVENT_API_ERROR, EVENT_API_CANCEL, @@ -38,6 +37,7 @@ import { EVENT_MALFORMED_JSON_RESPONSE, EVENT_INVALID_CHUNK, EVENT_AUTH, + EVENT_SKILL_LAUNCH, } from './constants.js'; import { recordApiErrorMetrics, @@ -85,6 +85,7 @@ import type { MalformedJsonResponseEvent, InvalidChunkEvent, AuthEvent, + SkillLaunchEvent, } from './types.js'; import type { UiEvent } from './uiTelemetry.js'; import { uiTelemetryService } from './uiTelemetry.js'; @@ -93,11 +94,8 @@ const shouldLogUserPrompts = (config: Config): boolean => config.getTelemetryLogPromptsEnabled(); function getCommonAttributes(config: Config): LogAttributes { - const userAccountManager = new UserAccountManager(); - const email = userAccountManager.getCachedGoogleAccount(); return { 'session.id': config.getSessionId(), - ...(email && { 'user.email': email }), }; } @@ -127,6 +125,8 @@ export function logStartSession( mcp_tools: event.mcp_tools, mcp_tools_count: event.mcp_tools_count, output_format: event.output_format, + skills: event.skills, + subagents: event.subagents, }; const logger = logs.getLogger(SERVICE_NAME); @@ -869,3 +869,21 @@ export function logAuth(config: Config, event: AuthEvent): void { }; logger.emit(logRecord); } + +export function logSkillLaunch(config: Config, event: SkillLaunchEvent): void { + if (!isTelemetrySdkInitialized()) return; + + const attributes: LogAttributes = { + ...getCommonAttributes(config), + ...event, + 'event.name': EVENT_SKILL_LAUNCH, + 'event.timestamp': new Date().toISOString(), + }; + + const logger = logs.getLogger(SERVICE_NAME); + const logRecord: LogRecord = { + body: `Skill launch: ${event.skill_name}. Success: ${event.success}.`, + attributes, + }; + logger.emit(logRecord); +} diff --git a/packages/core/src/telemetry/qwen-logger/qwen-logger.ts b/packages/core/src/telemetry/qwen-logger/qwen-logger.ts index b6a97a2e9..e63511df8 100644 --- a/packages/core/src/telemetry/qwen-logger/qwen-logger.ts +++ b/packages/core/src/telemetry/qwen-logger/qwen-logger.ts @@ -38,6 +38,7 @@ import type { ModelSlashCommandEvent, ExtensionDisableEvent, AuthEvent, + SkillLaunchEvent, RipgrepFallbackEvent, EndSessionEvent, } from '../types.js'; @@ -249,6 +250,9 @@ export class QwenLogger { authType === AuthType.USE_OPENAI ? this.config?.getContentGeneratorConfig().baseUrl || '' : '', + ...(this.config?.getChannel?.() + ? { channel: this.config.getChannel() } + : {}), }, _v: `qwen-code@${version}`, } as RumPayload; @@ -388,6 +392,8 @@ export class QwenLogger { telemetry_enabled: event.telemetry_enabled, telemetry_log_user_prompts_enabled: event.telemetry_log_user_prompts_enabled, + skills: event.skills, + subagents: event.subagents, }, }); @@ -824,6 +830,18 @@ export class QwenLogger { this.flushIfNeeded(); } + logSkillLaunchEvent(event: SkillLaunchEvent): void { + const rumEvent = this.createActionEvent('misc', 'skill_launch', { + properties: { + skill_name: event.skill_name, + success: event.success ? 1 : 0, + }, + }); + + this.enqueueLogEvent(rumEvent); + this.flushIfNeeded(); + } + logChatCompressionEvent(event: ChatCompressionEvent): void { const rumEvent = this.createActionEvent('misc', 'chat_compression', { properties: { diff --git a/packages/core/src/telemetry/types.ts b/packages/core/src/telemetry/types.ts index cfe4a2a0f..4158c9053 100644 --- a/packages/core/src/telemetry/types.ts +++ b/packages/core/src/telemetry/types.ts @@ -18,6 +18,9 @@ import { import type { FileOperation } from './metrics.js'; export { ToolCallDecision }; import type { OutputFormat } from '../output/types.js'; +import { ToolNames } from '../tools/tool-names.js'; +import type { SkillTool } from '../tools/skill.js'; +import type { TaskTool } from '../tools/task.js'; export interface BaseTelemetryEvent { 'event.name': string; @@ -47,6 +50,8 @@ export class StartSessionEvent implements BaseTelemetryEvent { mcp_tools_count?: number; mcp_tools?: string; output_format: OutputFormat; + skills?: string; + subagents?: string; constructor(config: Config) { const generatorConfig = config.getContentGeneratorConfig(); @@ -79,6 +84,7 @@ export class StartSessionEvent implements BaseTelemetryEvent { config.getFileFilteringRespectGitIgnore(); this.mcp_servers_count = mcpServers ? Object.keys(mcpServers).length : 0; this.output_format = config.getOutputFormat(); + if (toolRegistry) { const mcpTools = toolRegistry .getAllTools() @@ -87,6 +93,22 @@ export class StartSessionEvent implements BaseTelemetryEvent { this.mcp_tools = mcpTools .map((tool) => (tool as DiscoveredMCPTool).name) .join(','); + + const skillTool = toolRegistry.getTool(ToolNames.SKILL) as + | SkillTool + | undefined; + const skillNames = skillTool?.getAvailableSkillNames?.(); + if (skillNames && skillNames.length > 0) { + this.skills = skillNames.join(','); + } + + const taskTool = toolRegistry.getTool(ToolNames.TASK) as + | TaskTool + | undefined; + const subagentNames = taskTool?.getAvailableSubagentNames?.(); + if (subagentNames && subagentNames.length > 0) { + this.subagents = subagentNames.join(','); + } } } } @@ -721,6 +743,20 @@ export class AuthEvent implements BaseTelemetryEvent { } } +export class SkillLaunchEvent implements BaseTelemetryEvent { + 'event.name': 'skill_launch'; + 'event.timestamp': string; + skill_name: string; + success: boolean; + + constructor(skill_name: string, success: boolean) { + this['event.name'] = 'skill_launch'; + this['event.timestamp'] = new Date().toISOString(); + this.skill_name = skill_name; + this.success = success; + } +} + export type TelemetryEvent = | StartSessionEvent | EndSessionEvent @@ -749,7 +785,8 @@ export type TelemetryEvent = | ExtensionUninstallEvent | ToolOutputTruncatedEvent | ModelSlashCommandEvent - | AuthEvent; + | AuthEvent + | SkillLaunchEvent; export class ExtensionDisableEvent implements BaseTelemetryEvent { 'event.name': 'extension_disable'; diff --git a/packages/core/src/telemetry/uiTelemetry.ts b/packages/core/src/telemetry/uiTelemetry.ts index 9a257e5a6..0f8f2146c 100644 --- a/packages/core/src/telemetry/uiTelemetry.ts +++ b/packages/core/src/telemetry/uiTelemetry.ts @@ -23,6 +23,12 @@ export type UiEvent = | (ApiErrorEvent & { 'event.name': typeof EVENT_API_ERROR }) | (ToolCallEvent & { 'event.name': typeof EVENT_TOOL_CALL }); +export { + EVENT_API_ERROR, + EVENT_API_RESPONSE, + EVENT_TOOL_CALL, +} from './constants.js'; + export interface ToolCallStats { count: number; success: number; diff --git a/packages/core/src/tools/glob.test.ts b/packages/core/src/tools/glob.test.ts index 3729c2515..b6a04c35f 100644 --- a/packages/core/src/tools/glob.test.ts +++ b/packages/core/src/tools/glob.test.ts @@ -198,6 +198,52 @@ describe('GlobTool', () => { ); }); + it('should find files even if workspace path casing differs from glob results (Windows/macOS)', async () => { + // Only relevant for Windows and macOS + if (process.platform !== 'win32' && process.platform !== 'darwin') { + return; + } + + let mismatchedRootDir = tempRootDir; + + if (process.platform === 'win32') { + // 1. Create a path with mismatched casing for the workspace root + // e.g., if tempRootDir is "C:\Users\...", make it "c:\Users\..." + const drive = path.parse(tempRootDir).root; + if (!drive || !drive.match(/^[A-Z]:\\/)) { + // Skip if we can't determine/manipulate the drive letter easily + return; + } + + const lowerDrive = drive.toLowerCase(); + mismatchedRootDir = lowerDrive + tempRootDir.substring(drive.length); + } else { + // macOS: change the casing of the path + if (tempRootDir === tempRootDir.toLowerCase()) { + mismatchedRootDir = tempRootDir.toUpperCase(); + } else { + mismatchedRootDir = tempRootDir.toLowerCase(); + } + } + + // 2. Create a new GlobTool instance with this mismatched root + const mismatchedConfig = { + ...mockConfig, + getTargetDir: () => mismatchedRootDir, + getWorkspaceContext: () => + createMockWorkspaceContext(mismatchedRootDir), + } as unknown as Config; + + const mismatchedGlobTool = new GlobTool(mismatchedConfig); + + // 3. Execute search + const params: GlobToolParams = { pattern: '*.txt' }; + const invocation = mismatchedGlobTool.build(params); + const result = await invocation.execute(abortSignal); + + expect(result.llmContent).toContain('Found 2 file(s)'); + }); + it('should return error if path is outside workspace', async () => { // Bypassing validation to test execute method directly vi.spyOn(globTool, 'validateToolParams').mockReturnValue(null); diff --git a/packages/core/src/tools/glob.ts b/packages/core/src/tools/glob.ts index 29b6cf864..a3b4a5d5a 100644 --- a/packages/core/src/tools/glob.ts +++ b/packages/core/src/tools/glob.ts @@ -134,12 +134,21 @@ class GlobToolInvocation extends BaseToolInvocation< this.getFileFilteringOptions(), ); + const normalizePathForComparison = (p: string) => + process.platform === 'win32' || process.platform === 'darwin' + ? p.toLowerCase() + : p; + const filteredAbsolutePaths = new Set( - filteredPaths.map((p) => path.resolve(this.config.getTargetDir(), p)), + filteredPaths.map((p) => + normalizePathForComparison( + path.resolve(this.config.getTargetDir(), p), + ), + ), ); const filteredEntries = allEntries.filter((entry) => - filteredAbsolutePaths.has(entry.fullpath()), + filteredAbsolutePaths.has(normalizePathForComparison(entry.fullpath())), ); if (!filteredEntries || filteredEntries.length === 0) { diff --git a/packages/core/src/tools/ls.test.ts b/packages/core/src/tools/ls.test.ts index 8eea72cb7..3a3f3a0d9 100644 --- a/packages/core/src/tools/ls.test.ts +++ b/packages/core/src/tools/ls.test.ts @@ -31,6 +31,8 @@ describe('LSTool', () => { tempSecondaryDir, ]); + const userSkillsBase = path.join(os.homedir(), '.qwen', 'skills'); + mockConfig = { getTargetDir: () => tempRootDir, getWorkspaceContext: () => mockWorkspaceContext, @@ -39,6 +41,9 @@ describe('LSTool', () => { respectGitIgnore: true, respectQwenIgnore: true, }), + storage: { + getUserSkillsDir: () => userSkillsBase, + }, } as unknown as Config; lsTool = new LSTool(mockConfig); @@ -288,7 +293,7 @@ describe('LSTool', () => { }; const invocation = lsTool.build(params); const description = invocation.getDescription(); - const expected = path.relative(tempRootDir, params.path); + const expected = path.resolve(params.path); expect(description).toBe(expected); }); }); diff --git a/packages/core/src/tools/ls.ts b/packages/core/src/tools/ls.ts index 2aefe4439..6310a6827 100644 --- a/packages/core/src/tools/ls.ts +++ b/packages/core/src/tools/ls.ts @@ -9,6 +9,7 @@ import path from 'node:path'; import type { ToolInvocation, ToolResult } from './tools.js'; import { BaseDeclarativeTool, BaseToolInvocation, Kind } from './tools.js'; import { makeRelative, shortenPath } from '../utils/paths.js'; +import { isSubpath } from '../utils/paths.js'; import type { Config } from '../config/config.js'; import { DEFAULT_FILE_FILTERING_OPTIONS } from '../config/constants.js'; import { ToolErrorType } from './tool-error.js'; @@ -311,8 +312,14 @@ export class LSTool extends BaseDeclarativeTool { return `Path must be absolute: ${params.path}`; } + const userSkillsBase = this.config.storage.getUserSkillsDir(); + const isUnderUserSkills = isSubpath(userSkillsBase, params.path); + const workspaceContext = this.config.getWorkspaceContext(); - if (!workspaceContext.isPathWithinWorkspace(params.path)) { + if ( + !workspaceContext.isPathWithinWorkspace(params.path) && + !isUnderUserSkills + ) { const directories = workspaceContext.getDirectories(); return `Path must be within one of the workspace directories: ${directories.join( ', ', diff --git a/packages/core/src/tools/mcp-client.test.ts b/packages/core/src/tools/mcp-client.test.ts index a8a94484c..fd9f9d503 100644 --- a/packages/core/src/tools/mcp-client.test.ts +++ b/packages/core/src/tools/mcp-client.test.ts @@ -217,9 +217,9 @@ describe('mcp-client', () => { false, ); - expect(transport).toEqual( - new StreamableHTTPClientTransport(new URL('http://test-server'), {}), - ); + expect(transport).toBeInstanceOf(StreamableHTTPClientTransport); + // eslint-disable-next-line @typescript-eslint/no-explicit-any + expect((transport as any)._url).toEqual(new URL('http://test-server')); }); it('with headers', async () => { @@ -232,13 +232,13 @@ describe('mcp-client', () => { false, ); - expect(transport).toEqual( - new StreamableHTTPClientTransport(new URL('http://test-server'), { - requestInit: { - headers: { Authorization: 'derp' }, - }, - }), - ); + expect(transport).toBeInstanceOf(StreamableHTTPClientTransport); + // eslint-disable-next-line @typescript-eslint/no-explicit-any + expect((transport as any)._url).toEqual(new URL('http://test-server')); + // eslint-disable-next-line @typescript-eslint/no-explicit-any + expect((transport as any)._requestInit?.headers).toEqual({ + Authorization: 'derp', + }); }); }); @@ -251,9 +251,9 @@ describe('mcp-client', () => { }, false, ); - expect(transport).toEqual( - new SSEClientTransport(new URL('http://test-server'), {}), - ); + expect(transport).toBeInstanceOf(SSEClientTransport); + // eslint-disable-next-line @typescript-eslint/no-explicit-any + expect((transport as any)._url).toEqual(new URL('http://test-server')); }); it('with headers', async () => { @@ -266,13 +266,13 @@ describe('mcp-client', () => { false, ); - expect(transport).toEqual( - new SSEClientTransport(new URL('http://test-server'), { - requestInit: { - headers: { Authorization: 'derp' }, - }, - }), - ); + expect(transport).toBeInstanceOf(SSEClientTransport); + // eslint-disable-next-line @typescript-eslint/no-explicit-any + expect((transport as any)._url).toEqual(new URL('http://test-server')); + // eslint-disable-next-line @typescript-eslint/no-explicit-any + expect((transport as any)._requestInit?.headers).toEqual({ + Authorization: 'derp', + }); }); }); diff --git a/packages/core/src/tools/read-file.test.ts b/packages/core/src/tools/read-file.test.ts index aaca99236..01568eed9 100644 --- a/packages/core/src/tools/read-file.test.ts +++ b/packages/core/src/tools/read-file.test.ts @@ -40,6 +40,7 @@ describe('ReadFileTool', () => { getWorkspaceContext: () => createMockWorkspaceContext(tempRootDir), storage: { getProjectTempDir: () => path.join(tempRootDir, '.temp'), + getUserSkillsDir: () => path.join(os.homedir(), '.qwen', 'skills'), }, getTruncateToolOutputThreshold: () => 2500, getTruncateToolOutputLines: () => 500, diff --git a/packages/core/src/tools/read-file.ts b/packages/core/src/tools/read-file.ts index e4b41c237..6bd0ddb64 100644 --- a/packages/core/src/tools/read-file.ts +++ b/packages/core/src/tools/read-file.ts @@ -20,6 +20,7 @@ import { FileOperation } from '../telemetry/metrics.js'; 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'; /** * Parameters for the ReadFile tool @@ -183,15 +184,20 @@ export class ReadFileTool extends BaseDeclarativeTool< const workspaceContext = this.config.getWorkspaceContext(); const projectTempDir = this.config.storage.getProjectTempDir(); + const userSkillsDir = this.config.storage.getUserSkillsDir(); const resolvedFilePath = path.resolve(filePath); - const resolvedProjectTempDir = path.resolve(projectTempDir); - const isWithinTempDir = - resolvedFilePath.startsWith(resolvedProjectTempDir + path.sep) || - resolvedFilePath === resolvedProjectTempDir; + const isWithinTempDir = isSubpath(projectTempDir, resolvedFilePath); + const isWithinUserSkills = isSubpath(userSkillsDir, resolvedFilePath); - if (!workspaceContext.isPathWithinWorkspace(filePath) && !isWithinTempDir) { + if ( + !workspaceContext.isPathWithinWorkspace(filePath) && + !isWithinTempDir && + !isWithinUserSkills + ) { const directories = workspaceContext.getDirectories(); - return `File path must be within one of the workspace directories: ${directories.join(', ')} or within the project temp directory: ${projectTempDir}`; + return `File path must be within one of the workspace directories: ${directories.join( + ', ', + )} or within the project temp directory: ${projectTempDir}`; } if (params.offset !== undefined && params.offset < 0) { return 'Offset must be a non-negative number'; diff --git a/packages/core/src/tools/shell.test.ts b/packages/core/src/tools/shell.test.ts index 3484c53b2..eb8a17418 100644 --- a/packages/core/src/tools/shell.test.ts +++ b/packages/core/src/tools/shell.test.ts @@ -608,6 +608,36 @@ describe('ShellTool', () => { ); }); + it('should handle git commit with combined short flags like -am', async () => { + const command = 'git commit -am "Add feature"'; + const invocation = shellTool.build({ command, is_background: false }); + const promise = invocation.execute(mockAbortSignal); + + resolveExecutionPromise({ + rawOutput: Buffer.from(''), + output: '', + exitCode: 0, + signal: null, + error: null, + aborted: false, + pid: 12345, + executionMethod: 'child_process', + }); + + await promise; + + expect(mockShellExecutionService).toHaveBeenCalledWith( + expect.stringContaining( + 'Co-authored-by: Qwen-Coder ', + ), + expect.any(String), + expect.any(Function), + mockAbortSignal, + false, + {}, + ); + }); + it('should not modify non-git commands', async () => { const command = 'npm install'; const invocation = shellTool.build({ command, is_background: false }); @@ -768,6 +798,69 @@ describe('ShellTool', () => { {}, ); }); + + it('should add co-author when git commit is prefixed with cd command', async () => { + const command = 'cd /tmp/test && git commit -m "Test commit"'; + const invocation = shellTool.build({ command, is_background: false }); + const promise = invocation.execute(mockAbortSignal); + + resolveExecutionPromise({ + rawOutput: Buffer.from(''), + output: '', + exitCode: 0, + signal: null, + error: null, + aborted: false, + pid: 12345, + executionMethod: 'child_process', + }); + + await promise; + + expect(mockShellExecutionService).toHaveBeenCalledWith( + expect.stringContaining( + 'Co-authored-by: Qwen-Coder ', + ), + expect.any(String), + expect.any(Function), + mockAbortSignal, + false, + {}, + ); + }); + + it('should add co-author to git commit with multi-line message', async () => { + const command = `git commit -m "Fix bug + +This is a detailed description +spanning multiple lines"`; + const invocation = shellTool.build({ command, is_background: false }); + const promise = invocation.execute(mockAbortSignal); + + resolveExecutionPromise({ + rawOutput: Buffer.from(''), + output: '', + exitCode: 0, + signal: null, + error: null, + aborted: false, + pid: 12345, + executionMethod: 'child_process', + }); + + await promise; + + expect(mockShellExecutionService).toHaveBeenCalledWith( + expect.stringContaining( + 'Co-authored-by: Qwen-Coder ', + ), + expect.any(String), + expect.any(Function), + mockAbortSignal, + false, + {}, + ); + }); }); }); diff --git a/packages/core/src/tools/shell.ts b/packages/core/src/tools/shell.ts index 194de4e86..c223d0e5f 100644 --- a/packages/core/src/tools/shell.ts +++ b/packages/core/src/tools/shell.ts @@ -401,13 +401,14 @@ export class ShellToolInvocation extends BaseToolInvocation< private addCoAuthorToGitCommit(command: string): string { // Check if co-author feature is enabled const gitCoAuthorSettings = this.config.getGitCoAuthor(); + if (!gitCoAuthorSettings.enabled) { return command; } - // Check if this is a git commit command - const gitCommitPattern = /^git\s+commit/; - if (!gitCommitPattern.test(command.trim())) { + // Check if this is a git commit command (anywhere in the command, e.g., after "cd /path &&") + const gitCommitPattern = /\bgit\s+commit\b/; + if (!gitCommitPattern.test(command)) { return command; } @@ -416,15 +417,27 @@ export class ShellToolInvocation extends BaseToolInvocation< Co-authored-by: ${gitCoAuthorSettings.name} <${gitCoAuthorSettings.email}>`; - // Handle different git commit patterns - // Match -m "message" or -m 'message' - const messagePattern = /(-m\s+)(['"])((?:\\.|[^\\])*?)(\2)/; - const match = command.match(messagePattern); + // Handle different git commit patterns: + // Match -m "message" or -m 'message', including combined flags like -am + // Use separate patterns to avoid ReDoS (catastrophic backtracking) + // + // Pattern breakdown: + // -[a-zA-Z]*m matches -m, -am, -nm, etc. (combined short flags) + // \s+ matches whitespace after the flag + // [^"\\] matches any char except double-quote and backslash + // \\. matches escape sequences like \" or \\ + // (?:...|...)* matches normal chars or escapes, repeated + const doubleQuotePattern = /(-[a-zA-Z]*m\s+)"((?:[^"\\]|\\.)*)"/; + const singleQuotePattern = /(-[a-zA-Z]*m\s+)'((?:[^'\\]|\\.)*)'/; + const doubleMatch = command.match(doubleQuotePattern); + const singleMatch = command.match(singleQuotePattern); + const match = doubleMatch ?? singleMatch; + const quote = doubleMatch ? '"' : "'"; if (match) { - const [fullMatch, prefix, quote, existingMessage, closingQuote] = match; + const [fullMatch, prefix, existingMessage] = match; const newMessage = existingMessage + coAuthor; - const replacement = prefix + quote + newMessage + closingQuote; + const replacement = prefix + quote + newMessage + quote; return command.replace(fullMatch, replacement); } diff --git a/packages/core/src/tools/skill.test.ts b/packages/core/src/tools/skill.test.ts new file mode 100644 index 000000000..da0e9a195 --- /dev/null +++ b/packages/core/src/tools/skill.test.ts @@ -0,0 +1,442 @@ +/** + * @license + * Copyright 2025 Qwen + * SPDX-License-Identifier: Apache-2.0 + */ + +import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; +import { SkillTool, type SkillParams } from './skill.js'; +import type { PartListUnion } from '@google/genai'; +import type { ToolResultDisplay } from './tools.js'; +import type { Config } from '../config/config.js'; +import { SkillManager } from '../skills/skill-manager.js'; +import type { SkillConfig } from '../skills/types.js'; +import { partToString } from '../utils/partUtils.js'; + +// Type for accessing protected methods in tests +type SkillToolWithProtectedMethods = SkillTool & { + createInvocation: (params: SkillParams) => { + execute: ( + signal?: AbortSignal, + updateOutput?: (output: ToolResultDisplay) => void, + ) => Promise<{ + llmContent: PartListUnion; + returnDisplay: ToolResultDisplay; + }>; + getDescription: () => string; + shouldConfirmExecute: () => Promise; + }; +}; + +// Mock dependencies +vi.mock('../skills/skill-manager.js'); +vi.mock('../telemetry/index.js', () => ({ + logSkillLaunch: vi.fn(), + SkillLaunchEvent: class { + constructor( + public skill_name: string, + public success: boolean, + ) {} + }, +})); + +const MockedSkillManager = vi.mocked(SkillManager); + +describe('SkillTool', () => { + let config: Config; + let skillTool: SkillTool; + let mockSkillManager: SkillManager; + let changeListeners: Array<() => void>; + + const mockSkills: SkillConfig[] = [ + { + name: 'code-review', + description: 'Specialized skill for reviewing code quality', + level: 'project', + filePath: '/project/.qwen/skills/code-review/SKILL.md', + body: 'Review code for quality and best practices.', + }, + { + name: 'testing', + description: 'Skill for writing and running tests', + level: 'user', + filePath: '/home/user/.qwen/skills/testing/SKILL.md', + body: 'Help write comprehensive tests.', + allowedTools: ['read_file', 'write_file', 'shell'], + }, + ]; + + beforeEach(async () => { + // Setup fake timers + vi.useFakeTimers(); + + // Create mock config + config = { + getProjectRoot: vi.fn().mockReturnValue('/test/project'), + getSessionId: vi.fn().mockReturnValue('test-session-id'), + getSkillManager: vi.fn(), + getGeminiClient: vi.fn().mockReturnValue(undefined), + } as unknown as Config; + + changeListeners = []; + + // Setup SkillManager mock + mockSkillManager = { + listSkills: vi.fn().mockResolvedValue(mockSkills), + loadSkill: vi.fn(), + loadSkillForRuntime: vi.fn(), + addChangeListener: vi.fn((listener: () => void) => { + changeListeners.push(listener); + return () => { + const index = changeListeners.indexOf(listener); + if (index >= 0) { + changeListeners.splice(index, 1); + } + }; + }), + getParseErrors: vi.fn().mockReturnValue(new Map()), + } as unknown as SkillManager; + + MockedSkillManager.mockImplementation(() => mockSkillManager); + + // Make config return the mock SkillManager + vi.mocked(config.getSkillManager).mockReturnValue(mockSkillManager); + + // Create SkillTool instance + skillTool = new SkillTool(config); + + // Allow async initialization to complete + await vi.runAllTimersAsync(); + }); + + afterEach(() => { + vi.useRealTimers(); + vi.clearAllMocks(); + }); + + describe('initialization', () => { + it('should initialize with correct name and properties', () => { + expect(skillTool.name).toBe('skill'); + expect(skillTool.displayName).toBe('Skill'); + expect(skillTool.kind).toBe('read'); + }); + + it('should load available skills during initialization', () => { + expect(mockSkillManager.listSkills).toHaveBeenCalled(); + }); + + it('should subscribe to skill manager changes', () => { + expect(mockSkillManager.addChangeListener).toHaveBeenCalledTimes(1); + }); + + it('should update description with available skills', () => { + expect(skillTool.description).toContain('code-review'); + expect(skillTool.description).toContain( + 'Specialized skill for reviewing code quality', + ); + expect(skillTool.description).toContain('testing'); + expect(skillTool.description).toContain( + 'Skill for writing and running tests', + ); + }); + + it('should handle empty skills list gracefully', async () => { + vi.mocked(mockSkillManager.listSkills).mockResolvedValue([]); + + const emptySkillTool = new SkillTool(config); + await vi.runAllTimersAsync(); + + expect(emptySkillTool.description).toContain( + 'No skills are currently configured', + ); + }); + + it('should handle skill loading errors gracefully', async () => { + vi.mocked(mockSkillManager.listSkills).mockRejectedValue( + new Error('Loading failed'), + ); + + const consoleSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}); + + new SkillTool(config); + await vi.runAllTimersAsync(); + + expect(consoleSpy).toHaveBeenCalledWith( + 'Failed to load skills for Skills tool:', + expect.any(Error), + ); + consoleSpy.mockRestore(); + }); + }); + + describe('schema generation', () => { + it('should expose static schema without dynamic enums', () => { + const schema = skillTool.schema; + const properties = schema.parametersJsonSchema as { + properties: { + skill: { + type: string; + description: string; + enum?: string[]; + }; + }; + }; + expect(properties.properties.skill.type).toBe('string'); + expect(properties.properties.skill.description).toBe( + 'The skill name (no arguments). E.g., "pdf" or "xlsx"', + ); + expect(properties.properties.skill.enum).toBeUndefined(); + }); + + it('should keep schema static even when no skills available', async () => { + vi.mocked(mockSkillManager.listSkills).mockResolvedValue([]); + + const emptySkillTool = new SkillTool(config); + await vi.runAllTimersAsync(); + + const schema = emptySkillTool.schema; + const properties = schema.parametersJsonSchema as { + properties: { + skill: { + type: string; + description: string; + enum?: string[]; + }; + }; + }; + expect(properties.properties.skill.type).toBe('string'); + expect(properties.properties.skill.description).toBe( + 'The skill name (no arguments). E.g., "pdf" or "xlsx"', + ); + expect(properties.properties.skill.enum).toBeUndefined(); + }); + }); + + describe('validateToolParams', () => { + it('should validate valid parameters', () => { + const result = skillTool.validateToolParams({ skill: 'code-review' }); + expect(result).toBeNull(); + }); + + it('should reject empty skill', () => { + const result = skillTool.validateToolParams({ skill: '' }); + expect(result).toBe('Parameter "skill" must be a non-empty string.'); + }); + + it('should reject non-existent skill', () => { + const result = skillTool.validateToolParams({ + skill: 'non-existent', + }); + expect(result).toBe( + 'Skill "non-existent" not found. Available skills: code-review, testing', + ); + }); + + it('should show appropriate message when no skills available', async () => { + vi.mocked(mockSkillManager.listSkills).mockResolvedValue([]); + + const emptySkillTool = new SkillTool(config); + await vi.runAllTimersAsync(); + + const result = emptySkillTool.validateToolParams({ + skill: 'non-existent', + }); + expect(result).toBe( + 'Skill "non-existent" not found. No skills are currently available.', + ); + }); + }); + + describe('refreshSkills', () => { + it('should refresh when change listener fires', async () => { + const newSkills: SkillConfig[] = [ + { + name: 'new-skill', + description: 'A brand new skill', + level: 'project', + filePath: '/project/.qwen/skills/new-skill/SKILL.md', + body: 'New skill content.', + }, + ]; + + vi.mocked(mockSkillManager.listSkills).mockResolvedValueOnce(newSkills); + + const listener = changeListeners[0]; + expect(listener).toBeDefined(); + + listener?.(); + await vi.runAllTimersAsync(); + + expect(skillTool.description).toContain('new-skill'); + expect(skillTool.description).toContain('A brand new skill'); + }); + + it('should refresh available skills and update description', async () => { + const newSkills: SkillConfig[] = [ + { + name: 'test-skill', + description: 'A test skill', + level: 'project', + filePath: '/project/.qwen/skills/test-skill/SKILL.md', + body: 'Test content.', + }, + ]; + + vi.mocked(mockSkillManager.listSkills).mockResolvedValue(newSkills); + + await skillTool.refreshSkills(); + + expect(skillTool.description).toContain('test-skill'); + expect(skillTool.description).toContain('A test skill'); + }); + }); + + describe('SkillToolInvocation', () => { + const mockRuntimeConfig: SkillConfig = { + ...mockSkills[0], + }; + + beforeEach(() => { + vi.mocked(mockSkillManager.loadSkillForRuntime).mockResolvedValue( + mockRuntimeConfig, + ); + }); + + it('should execute skill load successfully', async () => { + const params: SkillParams = { + skill: 'code-review', + }; + + const invocation = ( + skillTool as SkillToolWithProtectedMethods + ).createInvocation(params); + const result = await invocation.execute(); + + expect(mockSkillManager.loadSkillForRuntime).toHaveBeenCalledWith( + 'code-review', + ); + + const llmText = partToString(result.llmContent); + expect(llmText).toContain( + 'Base directory for this skill: /project/.qwen/skills/code-review', + ); + expect(llmText.trim()).toContain( + 'Review code for quality and best practices.', + ); + + expect(result.returnDisplay).toBe('Launching skill: code-review'); + }); + + it('should include allowedTools in result when present', async () => { + const skillWithTools: SkillConfig = { + ...mockSkills[1], + }; + vi.mocked(mockSkillManager.loadSkillForRuntime).mockResolvedValue( + skillWithTools, + ); + + const params: SkillParams = { + skill: 'testing', + }; + + const invocation = ( + skillTool as SkillToolWithProtectedMethods + ).createInvocation(params); + const result = await invocation.execute(); + + const llmText = partToString(result.llmContent); + expect(llmText).toContain('testing'); + // Base description is omitted from llmContent; ensure body is present. + expect(llmText).toContain('Help write comprehensive tests.'); + + expect(result.returnDisplay).toBe('Launching skill: testing'); + }); + + it('should handle skill not found error', async () => { + vi.mocked(mockSkillManager.loadSkillForRuntime).mockResolvedValue(null); + + const params: SkillParams = { + skill: 'non-existent', + }; + + const invocation = ( + skillTool as SkillToolWithProtectedMethods + ).createInvocation(params); + const result = await invocation.execute(); + + const llmText = partToString(result.llmContent); + expect(llmText).toContain('Skill "non-existent" not found'); + }); + + it('should handle execution errors gracefully', async () => { + vi.mocked(mockSkillManager.loadSkillForRuntime).mockRejectedValue( + new Error('Loading failed'), + ); + + const consoleSpy = vi + .spyOn(console, 'error') + .mockImplementation(() => {}); + + const params: SkillParams = { + skill: 'code-review', + }; + + const invocation = ( + skillTool as SkillToolWithProtectedMethods + ).createInvocation(params); + const result = await invocation.execute(); + + const llmText = partToString(result.llmContent); + expect(llmText).toContain('Failed to load skill'); + expect(llmText).toContain('Loading failed'); + + consoleSpy.mockRestore(); + }); + + it('should not require confirmation', async () => { + const params: SkillParams = { + skill: 'code-review', + }; + + const invocation = ( + skillTool as SkillToolWithProtectedMethods + ).createInvocation(params); + const shouldConfirm = await invocation.shouldConfirmExecute(); + + expect(shouldConfirm).toBe(false); + }); + + it('should provide correct description', () => { + const params: SkillParams = { + skill: 'code-review', + }; + + const invocation = ( + skillTool as SkillToolWithProtectedMethods + ).createInvocation(params); + const description = invocation.getDescription(); + + expect(description).toBe('Launching skill: "code-review"'); + }); + + it('should handle skill without additional files', async () => { + vi.mocked(mockSkillManager.loadSkillForRuntime).mockResolvedValue( + mockSkills[0], + ); + + const params: SkillParams = { + skill: 'code-review', + }; + + const invocation = ( + skillTool as SkillToolWithProtectedMethods + ).createInvocation(params); + const result = await invocation.execute(); + + const llmText = partToString(result.llmContent); + expect(llmText).not.toContain('## Additional Files'); + + expect(result.returnDisplay).toBe('Launching skill: code-review'); + }); + }); +}); diff --git a/packages/core/src/tools/skill.ts b/packages/core/src/tools/skill.ts new file mode 100644 index 000000000..d0a1fce69 --- /dev/null +++ b/packages/core/src/tools/skill.ts @@ -0,0 +1,264 @@ +/** + * @license + * Copyright 2025 Qwen + * SPDX-License-Identifier: Apache-2.0 + */ + +import { BaseDeclarativeTool, BaseToolInvocation, Kind } from './tools.js'; +import { ToolNames, ToolDisplayNames } from './tool-names.js'; +import type { ToolResult, ToolResultDisplay } from './tools.js'; +import type { Config } from '../config/config.js'; +import type { SkillManager } from '../skills/skill-manager.js'; +import type { SkillConfig } from '../skills/types.js'; +import { logSkillLaunch, SkillLaunchEvent } from '../telemetry/index.js'; +import path from 'path'; + +export interface SkillParams { + skill: string; +} + +/** + * Skill tool that enables the model to access skill definitions. + * The tool dynamically loads available skills and includes them in its description + * for the model to choose from. + */ +export class SkillTool extends BaseDeclarativeTool { + static readonly Name: string = ToolNames.SKILL; + + private skillManager: SkillManager; + private availableSkills: SkillConfig[] = []; + + constructor(private readonly config: Config) { + // Initialize with a basic schema first + const initialSchema = { + type: 'object', + properties: { + skill: { + type: 'string', + description: 'The skill name (no arguments). E.g., "pdf" or "xlsx"', + }, + }, + required: ['skill'], + additionalProperties: false, + $schema: 'http://json-schema.org/draft-07/schema#', + }; + + super( + SkillTool.Name, + ToolDisplayNames.SKILL, + 'Execute a skill within the main conversation. Loading available skills...', // Initial description + Kind.Read, + initialSchema, + true, // isOutputMarkdown + false, // canUpdateOutput + ); + + this.skillManager = config.getSkillManager(); + this.skillManager.addChangeListener(() => { + void this.refreshSkills(); + }); + + // Initialize the tool asynchronously + this.refreshSkills(); + } + + /** + * Asynchronously initializes the tool by loading available skills + * and updating the description and schema. + */ + async refreshSkills(): Promise { + try { + this.availableSkills = await this.skillManager.listSkills(); + this.updateDescriptionAndSchema(); + } catch (error) { + console.warn('Failed to load skills for Skills tool:', error); + this.availableSkills = []; + this.updateDescriptionAndSchema(); + } finally { + // Update the client with the new tools + const geminiClient = this.config.getGeminiClient(); + if (geminiClient && geminiClient.isInitialized()) { + await geminiClient.setTools(); + } + } + } + + /** + * Updates the tool's description and schema based on available skills. + */ + private updateDescriptionAndSchema(): void { + let skillDescriptions = ''; + if (this.availableSkills.length === 0) { + skillDescriptions = + 'No skills are currently configured. Skills can be created by adding directories with SKILL.md files to .qwen/skills/ or ~/.qwen/skills/.'; + } else { + skillDescriptions = this.availableSkills + .map( + (skill) => ` + +${skill.name} + + +${skill.description} (${skill.level}) + + +${skill.level} + +`, + ) + .join('\n'); + } + + const baseDescription = `Execute a skill within the main conversation + + +When users ask you to perform tasks, check if any of the available skills below can help complete the task more effectively. Skills provide specialized capabilities and domain knowledge. + +How to invoke: +- Use this tool with the skill name only (no arguments) +- Examples: + - \`skill: "pdf"\` - invoke the pdf skill + - \`skill: "xlsx"\` - invoke the xlsx skill + - \`skill: "ms-office-suite:pdf"\` - invoke using fully qualified name + +Important: +- When a skill is relevant, you must invoke this tool IMMEDIATELY as your first action +- NEVER just announce or mention a skill in your text response without actually calling this tool +- This is a BLOCKING REQUIREMENT: invoke the relevant Skill tool BEFORE generating any other response about the task +- Only use skills listed in below +- Do not invoke a skill that is already running +- Do not use this tool for built-in CLI commands (like /help, /clear, etc.) + + + +${skillDescriptions} + +`; + // Update description using object property assignment + (this as { description: string }).description = baseDescription; + } + + override validateToolParams(params: SkillParams): string | null { + // Validate required fields + if ( + !params.skill || + typeof params.skill !== 'string' || + params.skill.trim() === '' + ) { + return 'Parameter "skill" must be a non-empty string.'; + } + + // Validate that the skill exists + const skillExists = this.availableSkills.some( + (skill) => skill.name === params.skill, + ); + + if (!skillExists) { + const availableNames = this.availableSkills.map((s) => s.name); + if (availableNames.length === 0) { + return `Skill "${params.skill}" not found. No skills are currently available.`; + } + return `Skill "${params.skill}" not found. Available skills: ${availableNames.join(', ')}`; + } + + return null; + } + + protected createInvocation(params: SkillParams) { + return new SkillToolInvocation(this.config, this.skillManager, params); + } + + getAvailableSkillNames(): string[] { + return this.availableSkills.map((skill) => skill.name); + } +} + +class SkillToolInvocation extends BaseToolInvocation { + constructor( + private readonly config: Config, + private readonly skillManager: SkillManager, + params: SkillParams, + ) { + super(params); + } + + getDescription(): string { + return `Launching skill: "${this.params.skill}"`; + } + + override async shouldConfirmExecute(): Promise { + // Skill loading is a read-only operation, no confirmation needed + return false; + } + + async execute( + _signal?: AbortSignal, + _updateOutput?: (output: ToolResultDisplay) => void, + ): Promise { + try { + // Load the skill with runtime config (includes additional files) + const skill = await this.skillManager.loadSkillForRuntime( + this.params.skill, + ); + + if (!skill) { + // Log failed skill launch + logSkillLaunch( + this.config, + new SkillLaunchEvent(this.params.skill, false), + ); + + // Get parse errors if any + const parseErrors = this.skillManager.getParseErrors(); + const errorMessages: string[] = []; + + for (const [filePath, error] of parseErrors) { + if (filePath.includes(this.params.skill)) { + errorMessages.push(`Parse error at ${filePath}: ${error.message}`); + } + } + + const errorDetail = + errorMessages.length > 0 + ? `\nErrors:\n${errorMessages.join('\n')}` + : ''; + + return { + llmContent: `Skill "${this.params.skill}" not found.${errorDetail}`, + returnDisplay: `Skill "${this.params.skill}" not found.${errorDetail}`, + }; + } + + // Log successful skill launch + logSkillLaunch( + this.config, + new SkillLaunchEvent(this.params.skill, true), + ); + + const baseDir = path.dirname(skill.filePath); + + // Build markdown content for LLM (show base dir, then body) + const llmContent = `Base directory for this skill: ${baseDir}\n\n${skill.body}\n`; + + return { + llmContent: [{ text: llmContent }], + returnDisplay: `Launching skill: ${skill.name}`, + }; + } catch (error) { + const errorMessage = + error instanceof Error ? error.message : String(error); + console.error(`[SkillsTool] Error launching skill: ${errorMessage}`); + + // Log failed skill launch + logSkillLaunch( + this.config, + new SkillLaunchEvent(this.params.skill, false), + ); + + return { + llmContent: `Failed to load skill "${this.params.skill}": ${errorMessage}`, + returnDisplay: `Failed to load skill "${this.params.skill}": ${errorMessage}`, + }; + } + } +} diff --git a/packages/core/src/tools/task.ts b/packages/core/src/tools/task.ts index 67f03f5f2..e8fd64d57 100644 --- a/packages/core/src/tools/task.ts +++ b/packages/core/src/tools/task.ts @@ -252,6 +252,10 @@ assistant: "I'm going to use the Task tool to launch the with the greeting-respo protected createInvocation(params: TaskParams) { return new TaskToolInvocation(this.config, this.subagentManager, params); } + + getAvailableSubagentNames(): string[] { + return this.availableSubagents.map((subagent) => subagent.name); + } } class TaskToolInvocation extends BaseToolInvocation { diff --git a/packages/core/src/tools/tool-names.ts b/packages/core/src/tools/tool-names.ts index 22be9c1b3..8cd1de541 100644 --- a/packages/core/src/tools/tool-names.ts +++ b/packages/core/src/tools/tool-names.ts @@ -20,6 +20,7 @@ export const ToolNames = { TODO_WRITE: 'todo_write', MEMORY: 'save_memory', TASK: 'task', + SKILL: 'skill', EXIT_PLAN_MODE: 'exit_plan_mode', WEB_FETCH: 'web_fetch', WEB_SEARCH: 'web_search', @@ -42,6 +43,7 @@ export const ToolDisplayNames = { TODO_WRITE: 'TodoWrite', MEMORY: 'SaveMemory', TASK: 'Task', + SKILL: 'Skill', EXIT_PLAN_MODE: 'ExitPlanMode', WEB_FETCH: 'WebFetch', WEB_SEARCH: 'WebSearch', diff --git a/packages/core/src/utils/errorParsing.test.ts b/packages/core/src/utils/errorParsing.test.ts index 9c71f4d89..bda1f86f9 100644 --- a/packages/core/src/utils/errorParsing.test.ts +++ b/packages/core/src/utils/errorParsing.test.ts @@ -6,9 +6,6 @@ import { describe, it, expect } from 'vitest'; import { parseAndFormatApiError } from './errorParsing.js'; -import { isProQuotaExceededError } from './quotaErrorDetection.js'; -import { DEFAULT_GEMINI_FLASH_MODEL } from '../config/models.js'; -import { UserTierId } from '../code_assist/types.js'; import { AuthType } from '../core/contentGenerator.js'; import type { StructuredError } from '../core/turn.js'; @@ -27,32 +24,10 @@ describe('parseAndFormatApiError', () => { it('should format a 429 API error with the default message', () => { const errorMessage = 'got status: 429 Too Many Requests. {"error":{"code":429,"message":"Rate limit exceeded","status":"RESOURCE_EXHAUSTED"}}'; - const result = parseAndFormatApiError( - errorMessage, - undefined, - undefined, - 'gemini-2.5-pro', - DEFAULT_GEMINI_FLASH_MODEL, - ); + const result = parseAndFormatApiError(errorMessage, undefined); expect(result).toContain('[API Error: Rate limit exceeded'); expect(result).toContain( - 'Possible quota limitations in place or slow response times detected. Switching to the gemini-2.5-flash model', - ); - }); - - it('should format a 429 API error with the personal message', () => { - const errorMessage = - 'got status: 429 Too Many Requests. {"error":{"code":429,"message":"Rate limit exceeded","status":"RESOURCE_EXHAUSTED"}}'; - const result = parseAndFormatApiError( - errorMessage, - AuthType.LOGIN_WITH_GOOGLE, - undefined, - 'gemini-2.5-pro', - DEFAULT_GEMINI_FLASH_MODEL, - ); - expect(result).toContain('[API Error: Rate limit exceeded'); - expect(result).toContain( - 'Possible quota limitations in place or slow response times detected. Switching to the gemini-2.5-flash model', + 'Possible quota limitations in place or slow response times detected. Please wait and try again later.', ); }); @@ -132,230 +107,4 @@ describe('parseAndFormatApiError', () => { const expected = '[API Error: An unknown error occurred.]'; expect(parseAndFormatApiError(error)).toBe(expected); }); - - it('should format a 429 API error with Pro quota exceeded message for Google auth (Free tier)', () => { - const errorMessage = - 'got status: 429 Too Many Requests. {"error":{"code":429,"message":"Quota exceeded for quota metric \'Gemini 2.5 Pro Requests\' and limit \'RequestsPerDay\' of service \'generativelanguage.googleapis.com\' for consumer \'project_number:123456789\'.","status":"RESOURCE_EXHAUSTED"}}'; - const result = parseAndFormatApiError( - errorMessage, - AuthType.LOGIN_WITH_GOOGLE, - undefined, - 'gemini-2.5-pro', - DEFAULT_GEMINI_FLASH_MODEL, - ); - expect(result).toContain( - "[API Error: Quota exceeded for quota metric 'Gemini 2.5 Pro Requests'", - ); - expect(result).toContain( - 'You have reached your daily gemini-2.5-pro quota limit', - ); - expect(result).toContain('upgrade to get higher limits'); - }); - - it('should format a regular 429 API error with standard message for Google auth', () => { - const errorMessage = - 'got status: 429 Too Many Requests. {"error":{"code":429,"message":"Rate limit exceeded","status":"RESOURCE_EXHAUSTED"}}'; - const result = parseAndFormatApiError( - errorMessage, - AuthType.LOGIN_WITH_GOOGLE, - undefined, - 'gemini-2.5-pro', - DEFAULT_GEMINI_FLASH_MODEL, - ); - expect(result).toContain('[API Error: Rate limit exceeded'); - expect(result).toContain( - 'Possible quota limitations in place or slow response times detected. Switching to the gemini-2.5-flash model', - ); - expect(result).not.toContain( - 'You have reached your daily gemini-2.5-pro quota limit', - ); - }); - - it('should format a 429 API error with generic quota exceeded message for Google auth', () => { - const errorMessage = - 'got status: 429 Too Many Requests. {"error":{"code":429,"message":"Quota exceeded for quota metric \'GenerationRequests\' and limit \'RequestsPerDay\' of service \'generativelanguage.googleapis.com\' for consumer \'project_number:123456789\'.","status":"RESOURCE_EXHAUSTED"}}'; - const result = parseAndFormatApiError( - errorMessage, - AuthType.LOGIN_WITH_GOOGLE, - undefined, - 'gemini-2.5-pro', - DEFAULT_GEMINI_FLASH_MODEL, - ); - expect(result).toContain( - "[API Error: Quota exceeded for quota metric 'GenerationRequests'", - ); - expect(result).toContain('You have reached your daily quota limit'); - expect(result).not.toContain( - 'You have reached your daily Gemini 2.5 Pro quota limit', - ); - }); - - it('should prioritize Pro quota message over generic quota message for Google auth', () => { - const errorMessage = - 'got status: 429 Too Many Requests. {"error":{"code":429,"message":"Quota exceeded for quota metric \'Gemini 2.5 Pro Requests\' and limit \'RequestsPerDay\' of service \'generativelanguage.googleapis.com\' for consumer \'project_number:123456789\'.","status":"RESOURCE_EXHAUSTED"}}'; - const result = parseAndFormatApiError( - errorMessage, - AuthType.LOGIN_WITH_GOOGLE, - undefined, - 'gemini-2.5-pro', - DEFAULT_GEMINI_FLASH_MODEL, - ); - expect(result).toContain( - "[API Error: Quota exceeded for quota metric 'Gemini 2.5 Pro Requests'", - ); - expect(result).toContain( - 'You have reached your daily gemini-2.5-pro quota limit', - ); - expect(result).not.toContain('You have reached your daily quota limit'); - }); - - it('should format a 429 API error with Pro quota exceeded message for Google auth (Standard tier)', () => { - const errorMessage = - 'got status: 429 Too Many Requests. {"error":{"code":429,"message":"Quota exceeded for quota metric \'Gemini 2.5 Pro Requests\' and limit \'RequestsPerDay\' of service \'generativelanguage.googleapis.com\' for consumer \'project_number:123456789\'.","status":"RESOURCE_EXHAUSTED"}}'; - const result = parseAndFormatApiError( - errorMessage, - AuthType.LOGIN_WITH_GOOGLE, - UserTierId.STANDARD, - 'gemini-2.5-pro', - DEFAULT_GEMINI_FLASH_MODEL, - ); - expect(result).toContain( - "[API Error: Quota exceeded for quota metric 'Gemini 2.5 Pro Requests'", - ); - expect(result).toContain( - 'You have reached your daily gemini-2.5-pro quota limit', - ); - expect(result).toContain( - 'We appreciate you for choosing Gemini Code Assist and the Gemini CLI', - ); - expect(result).not.toContain('upgrade to get higher limits'); - }); - - it('should format a 429 API error with Pro quota exceeded message for Google auth (Legacy tier)', () => { - const errorMessage = - 'got status: 429 Too Many Requests. {"error":{"code":429,"message":"Quota exceeded for quota metric \'Gemini 2.5 Pro Requests\' and limit \'RequestsPerDay\' of service \'generativelanguage.googleapis.com\' for consumer \'project_number:123456789\'.","status":"RESOURCE_EXHAUSTED"}}'; - const result = parseAndFormatApiError( - errorMessage, - AuthType.LOGIN_WITH_GOOGLE, - UserTierId.LEGACY, - 'gemini-2.5-pro', - DEFAULT_GEMINI_FLASH_MODEL, - ); - expect(result).toContain( - "[API Error: Quota exceeded for quota metric 'Gemini 2.5 Pro Requests'", - ); - expect(result).toContain( - 'You have reached your daily gemini-2.5-pro quota limit', - ); - expect(result).toContain( - 'We appreciate you for choosing Gemini Code Assist and the Gemini CLI', - ); - expect(result).not.toContain('upgrade to get higher limits'); - }); - - it('should handle different Gemini 2.5 version strings in Pro quota exceeded errors', () => { - const errorMessage25 = - 'got status: 429 Too Many Requests. {"error":{"code":429,"message":"Quota exceeded for quota metric \'Gemini 2.5 Pro Requests\' and limit \'RequestsPerDay\' of service \'generativelanguage.googleapis.com\' for consumer \'project_number:123456789\'.","status":"RESOURCE_EXHAUSTED"}}'; - const errorMessagePreview = - 'got status: 429 Too Many Requests. {"error":{"code":429,"message":"Quota exceeded for quota metric \'Gemini 2.5-preview Pro Requests\' and limit \'RequestsPerDay\' of service \'generativelanguage.googleapis.com\' for consumer \'project_number:123456789\'.","status":"RESOURCE_EXHAUSTED"}}'; - - const result25 = parseAndFormatApiError( - errorMessage25, - AuthType.LOGIN_WITH_GOOGLE, - undefined, - 'gemini-2.5-pro', - DEFAULT_GEMINI_FLASH_MODEL, - ); - const resultPreview = parseAndFormatApiError( - errorMessagePreview, - AuthType.LOGIN_WITH_GOOGLE, - undefined, - 'gemini-2.5-preview-pro', - DEFAULT_GEMINI_FLASH_MODEL, - ); - - expect(result25).toContain( - 'You have reached your daily gemini-2.5-pro quota limit', - ); - expect(resultPreview).toContain( - 'You have reached your daily gemini-2.5-preview-pro quota limit', - ); - expect(result25).toContain('upgrade to get higher limits'); - expect(resultPreview).toContain('upgrade to get higher limits'); - }); - - it('should not match non-Pro models with similar version strings', () => { - // Test that Flash models with similar version strings don't match - expect( - isProQuotaExceededError( - "Quota exceeded for quota metric 'Gemini 2.5 Flash Requests' and limit", - ), - ).toBe(false); - expect( - isProQuotaExceededError( - "Quota exceeded for quota metric 'Gemini 2.5-preview Flash Requests' and limit", - ), - ).toBe(false); - - // Test other model types - expect( - isProQuotaExceededError( - "Quota exceeded for quota metric 'Gemini 2.5 Ultra Requests' and limit", - ), - ).toBe(false); - expect( - isProQuotaExceededError( - "Quota exceeded for quota metric 'Gemini 2.5 Standard Requests' and limit", - ), - ).toBe(false); - - // Test generic quota messages - expect( - isProQuotaExceededError( - "Quota exceeded for quota metric 'GenerationRequests' and limit", - ), - ).toBe(false); - expect( - isProQuotaExceededError( - "Quota exceeded for quota metric 'EmbeddingRequests' and limit", - ), - ).toBe(false); - }); - - it('should format a generic quota exceeded message for Google auth (Standard tier)', () => { - const errorMessage = - 'got status: 429 Too Many Requests. {"error":{"code":429,"message":"Quota exceeded for quota metric \'GenerationRequests\' and limit \'RequestsPerDay\' of service \'generativelanguage.googleapis.com\' for consumer \'project_number:123456789\'.","status":"RESOURCE_EXHAUSTED"}}'; - const result = parseAndFormatApiError( - errorMessage, - AuthType.LOGIN_WITH_GOOGLE, - UserTierId.STANDARD, - 'gemini-2.5-pro', - DEFAULT_GEMINI_FLASH_MODEL, - ); - expect(result).toContain( - "[API Error: Quota exceeded for quota metric 'GenerationRequests'", - ); - expect(result).toContain('You have reached your daily quota limit'); - expect(result).toContain( - 'We appreciate you for choosing Gemini Code Assist and the Gemini CLI', - ); - expect(result).not.toContain('upgrade to get higher limits'); - }); - - it('should format a regular 429 API error with standard message for Google auth (Standard tier)', () => { - const errorMessage = - 'got status: 429 Too Many Requests. {"error":{"code":429,"message":"Rate limit exceeded","status":"RESOURCE_EXHAUSTED"}}'; - const result = parseAndFormatApiError( - errorMessage, - AuthType.LOGIN_WITH_GOOGLE, - UserTierId.STANDARD, - 'gemini-2.5-pro', - DEFAULT_GEMINI_FLASH_MODEL, - ); - expect(result).toContain('[API Error: Rate limit exceeded'); - expect(result).toContain( - 'We appreciate you for choosing Gemini Code Assist and the Gemini CLI', - ); - expect(result).not.toContain('upgrade to get higher limits'); - }); }); diff --git a/packages/core/src/utils/errorParsing.ts b/packages/core/src/utils/errorParsing.ts index ecfc23757..ef1c009b6 100644 --- a/packages/core/src/utils/errorParsing.ts +++ b/packages/core/src/utils/errorParsing.ts @@ -4,120 +4,36 @@ * SPDX-License-Identifier: Apache-2.0 */ -import { - isProQuotaExceededError, - isGenericQuotaExceededError, - isApiError, - isStructuredError, -} from './quotaErrorDetection.js'; -import { - DEFAULT_GEMINI_MODEL, - DEFAULT_GEMINI_FLASH_MODEL, -} from '../config/models.js'; -import { UserTierId } from '../code_assist/types.js'; +import { isApiError, isStructuredError } from './quotaErrorDetection.js'; import { AuthType } from '../core/contentGenerator.js'; // Free Tier message functions -const getRateLimitErrorMessageGoogleFree = ( - fallbackModel: string = DEFAULT_GEMINI_FLASH_MODEL, -) => - `\nPossible quota limitations in place or slow response times detected. Switching to the ${fallbackModel} model for the rest of this session.`; - -const getRateLimitErrorMessageGoogleProQuotaFree = ( - currentModel: string = DEFAULT_GEMINI_MODEL, - fallbackModel: string = DEFAULT_GEMINI_FLASH_MODEL, -) => - `\nYou have reached your daily ${currentModel} quota limit. You will be switched to the ${fallbackModel} model for the rest of this session. To increase your limits, upgrade to get higher limits at https://goo.gle/set-up-gemini-code-assist, or use /auth to switch to using a paid API key from AI Studio at https://aistudio.google.com/apikey`; - -const getRateLimitErrorMessageGoogleGenericQuotaFree = () => - `\nYou have reached your daily quota limit. To increase your limits, upgrade to get higher limits at https://goo.gle/set-up-gemini-code-assist, or use /auth to switch to using a paid API key from AI Studio at https://aistudio.google.com/apikey`; - -// Legacy/Standard Tier message functions -const getRateLimitErrorMessageGooglePaid = ( - fallbackModel: string = DEFAULT_GEMINI_FLASH_MODEL, -) => - `\nPossible quota limitations in place or slow response times detected. Switching to the ${fallbackModel} model for the rest of this session. We appreciate you for choosing Gemini Code Assist and the Gemini CLI.`; - -const getRateLimitErrorMessageGoogleProQuotaPaid = ( - currentModel: string = DEFAULT_GEMINI_MODEL, - fallbackModel: string = DEFAULT_GEMINI_FLASH_MODEL, -) => - `\nYou have reached your daily ${currentModel} quota limit. You will be switched to the ${fallbackModel} model for the rest of this session. We appreciate you for choosing Gemini Code Assist and the Gemini CLI. To continue accessing the ${currentModel} model today, consider using /auth to switch to using a paid API key from AI Studio at https://aistudio.google.com/apikey`; - -const getRateLimitErrorMessageGoogleGenericQuotaPaid = ( - currentModel: string = DEFAULT_GEMINI_MODEL, -) => - `\nYou have reached your daily quota limit. We appreciate you for choosing Gemini Code Assist and the Gemini CLI. To continue accessing the ${currentModel} model today, consider using /auth to switch to using a paid API key from AI Studio at https://aistudio.google.com/apikey`; const RATE_LIMIT_ERROR_MESSAGE_USE_GEMINI = '\nPlease wait and try again later. To increase your limits, request a quota increase through AI Studio, or switch to another /auth method'; const RATE_LIMIT_ERROR_MESSAGE_VERTEX = '\nPlease wait and try again later. To increase your limits, request a quota increase through Vertex, or switch to another /auth method'; -const getRateLimitErrorMessageDefault = ( - fallbackModel: string = DEFAULT_GEMINI_FLASH_MODEL, -) => - `\nPossible quota limitations in place or slow response times detected. Switching to the ${fallbackModel} model for the rest of this session.`; +const RATE_LIMIT_ERROR_MESSAGE_DEFAULT = + '\nPossible quota limitations in place or slow response times detected. Please wait and try again later.'; -function getRateLimitMessage( - authType?: AuthType, - error?: unknown, - userTier?: UserTierId, - currentModel?: string, - fallbackModel?: string, -): string { +function getRateLimitMessage(authType?: AuthType): string { switch (authType) { - case AuthType.LOGIN_WITH_GOOGLE: { - // Determine if user is on a paid tier (Legacy or Standard) - default to FREE if not specified - const isPaidTier = - userTier === UserTierId.LEGACY || userTier === UserTierId.STANDARD; - - if (isProQuotaExceededError(error)) { - return isPaidTier - ? getRateLimitErrorMessageGoogleProQuotaPaid( - currentModel || DEFAULT_GEMINI_MODEL, - fallbackModel, - ) - : getRateLimitErrorMessageGoogleProQuotaFree( - currentModel || DEFAULT_GEMINI_MODEL, - fallbackModel, - ); - } else if (isGenericQuotaExceededError(error)) { - return isPaidTier - ? getRateLimitErrorMessageGoogleGenericQuotaPaid( - currentModel || DEFAULT_GEMINI_MODEL, - ) - : getRateLimitErrorMessageGoogleGenericQuotaFree(); - } else { - return isPaidTier - ? getRateLimitErrorMessageGooglePaid(fallbackModel) - : getRateLimitErrorMessageGoogleFree(fallbackModel); - } - } case AuthType.USE_GEMINI: return RATE_LIMIT_ERROR_MESSAGE_USE_GEMINI; case AuthType.USE_VERTEX_AI: return RATE_LIMIT_ERROR_MESSAGE_VERTEX; default: - return getRateLimitErrorMessageDefault(fallbackModel); + return RATE_LIMIT_ERROR_MESSAGE_DEFAULT; } } export function parseAndFormatApiError( error: unknown, authType?: AuthType, - userTier?: UserTierId, - currentModel?: string, - fallbackModel?: string, ): string { if (isStructuredError(error)) { let text = `[API Error: ${error.message}]`; if (error.status === 429) { - text += getRateLimitMessage( - authType, - error, - userTier, - currentModel, - fallbackModel, - ); + text += getRateLimitMessage(authType); } return text; } @@ -146,13 +62,7 @@ export function parseAndFormatApiError( } let text = `[API Error: ${finalMessage} (Status: ${parsedError.error.status})]`; if (parsedError.error.code === 429) { - text += getRateLimitMessage( - authType, - parsedError, - userTier, - currentModel, - fallbackModel, - ); + text += getRateLimitMessage(authType); } return text; } diff --git a/packages/core/src/utils/flashFallback.test.ts b/packages/core/src/utils/flashFallback.test.ts index 7f21fe019..184cb2037 100644 --- a/packages/core/src/utils/flashFallback.test.ts +++ b/packages/core/src/utils/flashFallback.test.ts @@ -11,12 +11,9 @@ import { setSimulate429, disableSimulationAfterFallback, shouldSimulate429, - createSimulated429Error, resetRequestCounter, } from './testUtils.js'; import { DEFAULT_GEMINI_FLASH_MODEL } from '../config/models.js'; -import { retryWithBackoff } from './retry.js'; -import { AuthType } from '../core/contentGenerator.js'; // Import the new types (Assuming this test file is in packages/core/src/utils/) import type { FallbackModelHandler } from '../fallback/types.js'; @@ -61,84 +58,6 @@ describe('Retry Utility Fallback Integration', () => { expect(result).toBe('retry'); }); - // This test validates the retry utility's logic for triggering the callback. - it('should trigger onPersistent429 after 2 consecutive 429 errors for OAuth users', async () => { - let fallbackCalled = false; - // Removed fallbackModel variable as it's no longer relevant here. - - // Mock function that simulates exactly 2 429 errors, then succeeds after fallback - const mockApiCall = vi - .fn() - .mockRejectedValueOnce(createSimulated429Error()) - .mockRejectedValueOnce(createSimulated429Error()) - .mockResolvedValueOnce('success after fallback'); - - // Mock the onPersistent429 callback (this is what client.ts/geminiChat.ts provides) - const mockPersistent429Callback = vi.fn(async (_authType?: string) => { - fallbackCalled = true; - // Return true to signal retryWithBackoff to reset attempts and continue. - return true; - }); - - // Test with OAuth personal auth type, with maxAttempts = 2 to ensure fallback triggers - const result = await retryWithBackoff(mockApiCall, { - maxAttempts: 2, - initialDelayMs: 1, - maxDelayMs: 10, - shouldRetryOnError: (error: Error) => { - const status = (error as Error & { status?: number }).status; - return status === 429; - }, - onPersistent429: mockPersistent429Callback, - authType: AuthType.LOGIN_WITH_GOOGLE, - }); - - // Verify fallback mechanism was triggered - expect(fallbackCalled).toBe(true); - expect(mockPersistent429Callback).toHaveBeenCalledWith( - AuthType.LOGIN_WITH_GOOGLE, - expect.any(Error), - ); - expect(result).toBe('success after fallback'); - // Should have: 2 failures, then fallback triggered, then 1 success after retry reset - expect(mockApiCall).toHaveBeenCalledTimes(3); - }); - - it('should not trigger onPersistent429 for API key users', async () => { - let fallbackCalled = false; - - // Mock function that simulates 429 errors - const mockApiCall = vi.fn().mockRejectedValue(createSimulated429Error()); - - // Mock the callback - const mockPersistent429Callback = vi.fn(async () => { - fallbackCalled = true; - return true; - }); - - // Test with API key auth type - should not trigger fallback - try { - await retryWithBackoff(mockApiCall, { - maxAttempts: 5, - initialDelayMs: 10, - maxDelayMs: 100, - shouldRetryOnError: (error: Error) => { - const status = (error as Error & { status?: number }).status; - return status === 429; - }, - onPersistent429: mockPersistent429Callback, - authType: AuthType.USE_GEMINI, // API key auth type - }); - } catch (error) { - // Expected to throw after max attempts - expect((error as Error).message).toContain('Rate limit exceeded'); - } - - // Verify fallback was NOT triggered for API key users - expect(fallbackCalled).toBe(false); - expect(mockPersistent429Callback).not.toHaveBeenCalled(); - }); - // This test validates the test utilities themselves. it('should properly disable simulation state after fallback (Test Utility)', () => { // Enable simulation diff --git a/packages/core/src/utils/nextSpeakerChecker.test.ts b/packages/core/src/utils/nextSpeakerChecker.test.ts index 3cdb8628d..e7b7bfa30 100644 --- a/packages/core/src/utils/nextSpeakerChecker.test.ts +++ b/packages/core/src/utils/nextSpeakerChecker.test.ts @@ -61,6 +61,7 @@ describe('checkNextSpeaker', () => { generateContentStream: vi.fn(), countTokens: vi.fn(), embedContent: vi.fn(), + useSummarizedThinking: vi.fn().mockReturnValue(false), } as ContentGenerator, {} as Config, ); diff --git a/packages/core/src/utils/partUtils.ts b/packages/core/src/utils/partUtils.ts index f5195c11c..5afa60d5b 100644 --- a/packages/core/src/utils/partUtils.ts +++ b/packages/core/src/utils/partUtils.ts @@ -81,7 +81,7 @@ export function getResponseText( candidate.content.parts.length > 0 ) { return candidate.content.parts - .filter((part) => part.text) + .filter((part) => part.text && !part.thought) .map((part) => part.text) .join(''); } diff --git a/packages/core/src/utils/paths.ts b/packages/core/src/utils/paths.ts index 0bdfaf832..a4c220107 100644 --- a/packages/core/src/utils/paths.ts +++ b/packages/core/src/utils/paths.ts @@ -120,6 +120,10 @@ export function makeRelative( const resolvedTargetPath = path.resolve(targetPath); const resolvedRootDirectory = path.resolve(rootDirectory); + if (!isSubpath(resolvedRootDirectory, resolvedTargetPath)) { + return resolvedTargetPath; + } + const relativePath = path.relative(resolvedRootDirectory, resolvedTargetPath); // If the paths are the same, path.relative returns '', return '.' instead diff --git a/packages/core/src/utils/retry.test.ts b/packages/core/src/utils/retry.test.ts index 27c094590..270909696 100644 --- a/packages/core/src/utils/retry.test.ts +++ b/packages/core/src/utils/retry.test.ts @@ -285,173 +285,6 @@ describe('retryWithBackoff', () => { }); }); - describe('Flash model fallback for OAuth users', () => { - it('should trigger fallback for OAuth personal users after persistent 429 errors', async () => { - const fallbackCallback = vi.fn().mockResolvedValue('gemini-2.5-flash'); - - let fallbackOccurred = false; - const mockFn = vi.fn().mockImplementation(async () => { - if (!fallbackOccurred) { - const error: HttpError = new Error('Rate limit exceeded'); - error.status = 429; - throw error; - } - return 'success'; - }); - - const promise = retryWithBackoff(mockFn, { - maxAttempts: 3, - initialDelayMs: 100, - onPersistent429: async (authType?: string) => { - fallbackOccurred = true; - return await fallbackCallback(authType); - }, - authType: 'oauth-personal', - }); - - // Advance all timers to complete retries - await vi.runAllTimersAsync(); - - // Should succeed after fallback - await expect(promise).resolves.toBe('success'); - - // Verify callback was called with correct auth type - expect(fallbackCallback).toHaveBeenCalledWith('oauth-personal'); - - // Should retry again after fallback - expect(mockFn).toHaveBeenCalledTimes(3); // 2 initial attempts + 1 after fallback - }); - - it('should NOT trigger fallback for API key users', async () => { - const fallbackCallback = vi.fn(); - - const mockFn = vi.fn(async () => { - const error: HttpError = new Error('Rate limit exceeded'); - error.status = 429; - throw error; - }); - - const promise = retryWithBackoff(mockFn, { - maxAttempts: 3, - initialDelayMs: 100, - onPersistent429: fallbackCallback, - authType: 'gemini-api-key', - }); - - // Handle the promise properly to avoid unhandled rejections - const resultPromise = promise.catch((error) => error); - await vi.runAllTimersAsync(); - const result = await resultPromise; - - // Should fail after all retries without fallback - expect(result).toBeInstanceOf(Error); - expect(result.message).toBe('Rate limit exceeded'); - - // Callback should not be called for API key users - expect(fallbackCallback).not.toHaveBeenCalled(); - }); - - it('should reset attempt counter and continue after successful fallback', async () => { - let fallbackCalled = false; - const fallbackCallback = vi.fn().mockImplementation(async () => { - fallbackCalled = true; - return 'gemini-2.5-flash'; - }); - - const mockFn = vi.fn().mockImplementation(async () => { - if (!fallbackCalled) { - const error: HttpError = new Error('Rate limit exceeded'); - error.status = 429; - throw error; - } - return 'success'; - }); - - const promise = retryWithBackoff(mockFn, { - maxAttempts: 3, - initialDelayMs: 100, - onPersistent429: fallbackCallback, - authType: 'oauth-personal', - }); - - await vi.runAllTimersAsync(); - - await expect(promise).resolves.toBe('success'); - expect(fallbackCallback).toHaveBeenCalledOnce(); - }); - - it('should continue with original error if fallback is rejected', async () => { - const fallbackCallback = vi.fn().mockResolvedValue(null); // User rejected fallback - - const mockFn = vi.fn(async () => { - const error: HttpError = new Error('Rate limit exceeded'); - error.status = 429; - throw error; - }); - - const promise = retryWithBackoff(mockFn, { - maxAttempts: 3, - initialDelayMs: 100, - onPersistent429: fallbackCallback, - authType: 'oauth-personal', - }); - - // Handle the promise properly to avoid unhandled rejections - const resultPromise = promise.catch((error) => error); - await vi.runAllTimersAsync(); - const result = await resultPromise; - - // Should fail with original error when fallback is rejected - expect(result).toBeInstanceOf(Error); - expect(result.message).toBe('Rate limit exceeded'); - expect(fallbackCallback).toHaveBeenCalledWith( - 'oauth-personal', - expect.any(Error), - ); - }); - - it('should handle mixed error types (only count consecutive 429s)', async () => { - const fallbackCallback = vi.fn().mockResolvedValue('gemini-2.5-flash'); - let attempts = 0; - let fallbackOccurred = false; - - const mockFn = vi.fn().mockImplementation(async () => { - attempts++; - if (fallbackOccurred) { - return 'success'; - } - if (attempts === 1) { - // First attempt: 500 error (resets consecutive count) - const error: HttpError = new Error('Server error'); - error.status = 500; - throw error; - } else { - // Remaining attempts: 429 errors - const error: HttpError = new Error('Rate limit exceeded'); - error.status = 429; - throw error; - } - }); - - const promise = retryWithBackoff(mockFn, { - maxAttempts: 5, - initialDelayMs: 100, - onPersistent429: async (authType?: string) => { - fallbackOccurred = true; - return await fallbackCallback(authType); - }, - authType: 'oauth-personal', - }); - - await vi.runAllTimersAsync(); - - await expect(promise).resolves.toBe('success'); - - // Should trigger fallback after 2 consecutive 429s (attempts 2-3) - expect(fallbackCallback).toHaveBeenCalledWith('oauth-personal'); - }); - }); - describe('Qwen OAuth 429 error handling', () => { it('should retry for Qwen OAuth 429 errors that are throttling-related', async () => { const errorWith429: HttpError = new Error('Rate limit exceeded'); diff --git a/packages/core/src/utils/retry.ts b/packages/core/src/utils/retry.ts index 215833d95..9e9412af1 100644 --- a/packages/core/src/utils/retry.ts +++ b/packages/core/src/utils/retry.ts @@ -7,8 +7,6 @@ import type { GenerateContentResponse } from '@google/genai'; import { AuthType } from '../core/contentGenerator.js'; import { - isProQuotaExceededError, - isGenericQuotaExceededError, isQwenQuotaExceededError, isQwenThrottlingError, } from './quotaErrorDetection.js'; @@ -90,7 +88,6 @@ export async function retryWithBackoff( maxAttempts, initialDelayMs, maxDelayMs, - onPersistent429, authType, shouldRetryOnError, shouldRetryOnContent, @@ -123,59 +120,6 @@ export async function retryWithBackoff( } catch (error) { const errorStatus = getErrorStatus(error); - // Check for Pro quota exceeded error first - immediate fallback for OAuth users - if ( - errorStatus === 429 && - authType === AuthType.LOGIN_WITH_GOOGLE && - isProQuotaExceededError(error) && - onPersistent429 - ) { - try { - const fallbackModel = await onPersistent429(authType, error); - if (fallbackModel !== false && fallbackModel !== null) { - // Reset attempt counter and try with new model - attempt = 0; - consecutive429Count = 0; - currentDelay = initialDelayMs; - // With the model updated, we continue to the next attempt - continue; - } else { - // Fallback handler returned null/false, meaning don't continue - stop retry process - throw error; - } - } catch (fallbackError) { - // If fallback fails, continue with original error - console.warn('Fallback to Flash model failed:', fallbackError); - } - } - - // Check for generic quota exceeded error (but not Pro, which was handled above) - immediate fallback for OAuth users - if ( - errorStatus === 429 && - authType === AuthType.LOGIN_WITH_GOOGLE && - !isProQuotaExceededError(error) && - isGenericQuotaExceededError(error) && - onPersistent429 - ) { - try { - const fallbackModel = await onPersistent429(authType, error); - if (fallbackModel !== false && fallbackModel !== null) { - // Reset attempt counter and try with new model - attempt = 0; - consecutive429Count = 0; - currentDelay = initialDelayMs; - // With the model updated, we continue to the next attempt - continue; - } else { - // Fallback handler returned null/false, meaning don't continue - stop retry process - throw error; - } - } catch (fallbackError) { - // If fallback fails, continue with original error - console.warn('Fallback to Flash model failed:', fallbackError); - } - } - // Check for Qwen OAuth quota exceeded error - throw immediately without retry if (authType === AuthType.QWEN_OAUTH && isQwenQuotaExceededError(error)) { throw new Error( @@ -197,30 +141,7 @@ export async function retryWithBackoff( consecutive429Count = 0; } - // If we have persistent 429s and a fallback callback for OAuth - if ( - consecutive429Count >= 2 && - onPersistent429 && - authType === AuthType.LOGIN_WITH_GOOGLE - ) { - try { - const fallbackModel = await onPersistent429(authType, error); - if (fallbackModel !== false && fallbackModel !== null) { - // Reset attempt counter and try with new model - attempt = 0; - consecutive429Count = 0; - currentDelay = initialDelayMs; - // With the model updated, we continue to the next attempt - continue; - } else { - // Fallback handler returned null/false, meaning don't continue - stop retry process - throw error; - } - } catch (fallbackError) { - // If fallback fails, continue with original error - console.warn('Fallback to Flash model failed:', fallbackError); - } - } + console.debug('consecutive429Count', consecutive429Count); // Check if we've exhausted retries or shouldn't retry if (attempt >= maxAttempts || !shouldRetryOnError(error as Error)) { @@ -240,7 +161,7 @@ export async function retryWithBackoff( // Reset currentDelay for next potential non-429 error, or if Retry-After is not present next time currentDelay = initialDelayMs; } else { - // Fall back to exponential backoff with jitter + // Fallback to exponential backoff with jitter logRetryAttempt(attempt, error, errorStatus); // Add jitter: +/- 30% of currentDelay const jitter = currentDelay * 0.3 * (Math.random() * 2 - 1); diff --git a/packages/core/src/utils/schemaConverter.test.ts b/packages/core/src/utils/schemaConverter.test.ts new file mode 100644 index 000000000..3d36ce4d4 --- /dev/null +++ b/packages/core/src/utils/schemaConverter.test.ts @@ -0,0 +1,118 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import { describe, it, expect } from 'vitest'; +import { convertSchema } from './schemaConverter.js'; + +describe('convertSchema', () => { + describe('mode: auto (default)', () => { + it('should preserve type arrays', () => { + const input = { type: ['string', 'null'] }; + expect(convertSchema(input, 'auto')).toEqual(input); + }); + + it('should preserve items array (tuples)', () => { + const input = { + type: 'array', + items: [{ type: 'string' }, { type: 'number' }], + }; + expect(convertSchema(input, 'auto')).toEqual(input); + }); + + it('should preserve mixed enums', () => { + const input = { enum: [1, 2, '3'] }; + expect(convertSchema(input, 'auto')).toEqual(input); + }); + + it('should preserve unsupported keywords', () => { + const input = { + $schema: 'http://json-schema.org/draft-07/schema#', + exclusiveMinimum: 10, + type: 'number', + }; + expect(convertSchema(input, 'auto')).toEqual(input); + }); + }); + + describe('mode: openapi_30 (strict)', () => { + it('should convert type arrays to nullable', () => { + const input = { type: ['string', 'null'] }; + const expected = { type: 'string', nullable: true }; + expect(convertSchema(input, 'openapi_30')).toEqual(expected); + }); + + it('should fallback to first type for non-nullable arrays', () => { + const input = { type: ['string', 'number'] }; + const expected = { type: 'string' }; + expect(convertSchema(input, 'openapi_30')).toEqual(expected); + }); + + it('should convert const to enum', () => { + const input = { const: 'foo' }; + const expected = { enum: ['foo'] }; + expect(convertSchema(input, 'openapi_30')).toEqual(expected); + }); + + it('should convert exclusiveMinimum number to boolean', () => { + const input = { type: 'number', exclusiveMinimum: 10 }; + const expected = { + type: 'number', + minimum: 10, + exclusiveMinimum: true, + }; + expect(convertSchema(input, 'openapi_30')).toEqual(expected); + }); + + it('should convert nested objects recursively', () => { + const input = { + type: 'object', + properties: { + prop1: { type: ['integer', 'null'], exclusiveMaximum: 5 }, + }, + }; + const expected = { + type: 'object', + properties: { + prop1: { + type: 'integer', + nullable: true, + maximum: 5, + exclusiveMaximum: true, + }, + }, + }; + expect(convertSchema(input, 'openapi_30')).toEqual(expected); + }); + + it('should stringify enums', () => { + const input = { enum: [1, 2, '3'] }; + const expected = { enum: ['1', '2', '3'] }; + expect(convertSchema(input, 'openapi_30')).toEqual(expected); + }); + + it('should remove tuple items (array of schemas)', () => { + const input = { + type: 'array', + items: [{ type: 'string' }, { type: 'number' }], + }; + const expected = { type: 'array' }; + expect(convertSchema(input, 'openapi_30')).toEqual(expected); + }); + + it('should remove unsupported keywords', () => { + const input = { + $schema: 'http://json-schema.org/draft-07/schema#', + $id: '#foo', + type: 'string', + default: 'bar', + dependencies: { foo: ['bar'] }, + patternProperties: { '^foo': { type: 'string' } }, + }; + const expected = { type: 'string' }; + expect(convertSchema(input, 'openapi_30')).toEqual(expected); + }); + }); +}); diff --git a/packages/core/src/utils/schemaConverter.ts b/packages/core/src/utils/schemaConverter.ts new file mode 100644 index 000000000..9d8a45a7d --- /dev/null +++ b/packages/core/src/utils/schemaConverter.ts @@ -0,0 +1,135 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +/** + * Utility for converting JSON Schemas to be compatible with different LLM providers. + * Specifically focuses on downgrading modern JSON Schema (Draft 7/2020-12) to + * OpenAPI 3.0 compatible Schema Objects, which is required for Google Gemini API. + */ + +export type SchemaComplianceMode = 'auto' | 'openapi_30'; + +/** + * Converts a JSON Schema to be compatible with the specified compliance mode. + */ +export function convertSchema( + schema: Record, + mode: SchemaComplianceMode = 'auto', +): Record { + if (mode === 'openapi_30') { + return toOpenAPI30(schema); + } + + // Default ('auto') mode now does nothing. + return schema; +} + +/** + * Converts Modern JSON Schema to OpenAPI 3.0 Schema Object. + * Attempts to preserve semantics where possible through transformations. + */ +function toOpenAPI30(schema: Record): Record { + const convert = (obj: unknown): unknown => { + if (typeof obj !== 'object' || obj === null) { + return obj; + } + + if (Array.isArray(obj)) { + return obj.map(convert); + } + + const source = obj as Record; + const target: Record = {}; + + // 1. Type Handling + if (Array.isArray(source['type'])) { + const types = source['type'] as string[]; + // Handle ["string", "null"] pattern common in modern schemas + if (types.length === 2 && types.includes('null')) { + target['type'] = types.find((t) => t !== 'null'); + target['nullable'] = true; + } else { + // Fallback for other unions: take the first non-null type + // OpenAPI 3.0 doesn't support type arrays. + // Ideal fix would be anyOf, but simple fallback is safer for now. + target['type'] = types[0]; + } + } else if (source['type'] !== undefined) { + target['type'] = source['type']; + } + + // 2. Const Handling (Draft 6+) -> Enum (OpenAPI 3.0) + if (source['const'] !== undefined) { + target['enum'] = [source['const']]; + delete target['const']; + } + + // 3. Exclusive Limits (Draft 6+ number) -> (Draft 4 boolean) + // exclusiveMinimum: 10 -> minimum: 10, exclusiveMinimum: true + if (typeof source['exclusiveMinimum'] === 'number') { + target['minimum'] = source['exclusiveMinimum']; + target['exclusiveMinimum'] = true; + } + if (typeof source['exclusiveMaximum'] === 'number') { + target['maximum'] = source['exclusiveMaximum']; + target['exclusiveMaximum'] = true; + } + + // 4. Array Items (Tuple -> Single Schema) + // OpenAPI 3.0 items must be a schema object, not an array of schemas + if (Array.isArray(source['items'])) { + // Tuple support is tricky. + // Best effort: Use the first item's schema as a generic array type + // or convert to an empty object (any type) if mixed. + // For now, we'll strip it to allow validation to pass (accepts any items) + // This matches the legacy behavior but is explicit. + // Ideally, we could use `oneOf` on the items if we wanted to be stricter. + delete target['items']; + } else if ( + typeof source['items'] === 'object' && + source['items'] !== null + ) { + target['items'] = convert(source['items']); + } + + // 5. Enum Stringification + // Gemini strictly requires enums to be strings + if (Array.isArray(source['enum'])) { + target['enum'] = source['enum'].map(String); + } + + // 6. Recursively process other properties + for (const [key, value] of Object.entries(source)) { + // Skip fields we've already handled or want to remove + if ( + key === 'type' || + key === 'const' || + key === 'exclusiveMinimum' || + key === 'exclusiveMaximum' || + key === 'items' || + key === 'enum' || + key === '$schema' || + key === '$id' || + key === 'default' || // Optional: Gemini sometimes complains about defaults conflicting with types + key === 'dependencies' || + key === 'patternProperties' + ) { + continue; + } + + target[key] = convert(value); + } + + // Preserve default if it doesn't conflict (simple pass-through) + // if (source['default'] !== undefined) { + // target['default'] = source['default']; + // } + + return target; + }; + + return convert(schema) as Record; +} diff --git a/packages/core/src/utils/thoughtUtils.ts b/packages/core/src/utils/thoughtUtils.ts index 21b95532a..116d57170 100644 --- a/packages/core/src/utils/thoughtUtils.ts +++ b/packages/core/src/utils/thoughtUtils.ts @@ -29,7 +29,7 @@ export function parseThought(rawText: string): ThoughtSummary { const startIndex = rawText.indexOf(START_DELIMITER); if (startIndex === -1) { // No start delimiter found, the whole text is the description. - return { subject: '', description: rawText.trim() }; + return { subject: '', description: rawText }; } const endIndex = rawText.indexOf( @@ -39,7 +39,7 @@ export function parseThought(rawText: string): ThoughtSummary { if (endIndex === -1) { // Start delimiter found but no end delimiter, so it's not a valid subject. // Treat the entire string as the description. - return { subject: '', description: rawText.trim() }; + return { subject: '', description: rawText }; } const subject = rawText diff --git a/packages/core/src/utils/userAccountManager.test.ts b/packages/core/src/utils/userAccountManager.test.ts deleted file mode 100644 index 6a5ca7204..000000000 --- a/packages/core/src/utils/userAccountManager.test.ts +++ /dev/null @@ -1,340 +0,0 @@ -/** - * @license - * Copyright 2025 Google LLC - * SPDX-License-Identifier: Apache-2.0 - */ - -import type { Mock } from 'vitest'; -import { vi, describe, it, expect, beforeEach, afterEach } from 'vitest'; -import { UserAccountManager } from './userAccountManager.js'; -import * as fs from 'node:fs'; -import * as os from 'node:os'; -import path from 'node:path'; - -vi.mock('os', async (importOriginal) => { - const os = await importOriginal(); - return { - ...os, - homedir: vi.fn(), - }; -}); - -describe('UserAccountManager', () => { - let tempHomeDir: string; - let userAccountManager: UserAccountManager; - let accountsFile: () => string; - - beforeEach(() => { - tempHomeDir = fs.mkdtempSync( - path.join(os.tmpdir(), 'qwen-code-test-home-'), - ); - (os.homedir as Mock).mockReturnValue(tempHomeDir); - accountsFile = () => - path.join(tempHomeDir, '.qwen', 'google_accounts.json'); - userAccountManager = new UserAccountManager(); - }); - - afterEach(() => { - fs.rmSync(tempHomeDir, { recursive: true, force: true }); - vi.clearAllMocks(); - }); - - describe('cacheGoogleAccount', () => { - it('should create directory and write initial account file', async () => { - await userAccountManager.cacheGoogleAccount('test1@google.com'); - - // Verify Google Account ID was cached - expect(fs.existsSync(accountsFile())).toBe(true); - expect(fs.readFileSync(accountsFile(), 'utf-8')).toBe( - JSON.stringify({ active: 'test1@google.com', old: [] }, null, 2), - ); - }); - - it('should update active account and move previous to old', async () => { - fs.mkdirSync(path.dirname(accountsFile()), { recursive: true }); - fs.writeFileSync( - accountsFile(), - JSON.stringify( - { active: 'test2@google.com', old: ['test1@google.com'] }, - null, - 2, - ), - ); - - await userAccountManager.cacheGoogleAccount('test3@google.com'); - - expect(fs.readFileSync(accountsFile(), 'utf-8')).toBe( - JSON.stringify( - { - active: 'test3@google.com', - old: ['test1@google.com', 'test2@google.com'], - }, - null, - 2, - ), - ); - }); - - it('should not add a duplicate to the old list', async () => { - fs.mkdirSync(path.dirname(accountsFile()), { recursive: true }); - fs.writeFileSync( - accountsFile(), - JSON.stringify( - { active: 'test1@google.com', old: ['test2@google.com'] }, - null, - 2, - ), - ); - await userAccountManager.cacheGoogleAccount('test2@google.com'); - await userAccountManager.cacheGoogleAccount('test1@google.com'); - - expect(fs.readFileSync(accountsFile(), 'utf-8')).toBe( - JSON.stringify( - { active: 'test1@google.com', old: ['test2@google.com'] }, - null, - 2, - ), - ); - }); - - it('should handle corrupted JSON by starting fresh', async () => { - fs.mkdirSync(path.dirname(accountsFile()), { recursive: true }); - fs.writeFileSync(accountsFile(), 'not valid json'); - const consoleLogSpy = vi - .spyOn(console, 'log') - .mockImplementation(() => {}); - - await userAccountManager.cacheGoogleAccount('test1@google.com'); - - expect(consoleLogSpy).toHaveBeenCalled(); - expect(JSON.parse(fs.readFileSync(accountsFile(), 'utf-8'))).toEqual({ - active: 'test1@google.com', - old: [], - }); - }); - - it('should handle valid JSON with incorrect schema by starting fresh', async () => { - fs.mkdirSync(path.dirname(accountsFile()), { recursive: true }); - fs.writeFileSync( - accountsFile(), - JSON.stringify({ active: 'test1@google.com', old: 'not-an-array' }), - ); - const consoleLogSpy = vi - .spyOn(console, 'log') - .mockImplementation(() => {}); - - await userAccountManager.cacheGoogleAccount('test2@google.com'); - - expect(consoleLogSpy).toHaveBeenCalled(); - expect(JSON.parse(fs.readFileSync(accountsFile(), 'utf-8'))).toEqual({ - active: 'test2@google.com', - old: [], - }); - }); - }); - - describe('getCachedGoogleAccount', () => { - it('should return the active account if file exists and is valid', () => { - fs.mkdirSync(path.dirname(accountsFile()), { recursive: true }); - fs.writeFileSync( - accountsFile(), - JSON.stringify({ active: 'active@google.com', old: [] }, null, 2), - ); - const account = userAccountManager.getCachedGoogleAccount(); - expect(account).toBe('active@google.com'); - }); - - it('should return null if file does not exist', () => { - const account = userAccountManager.getCachedGoogleAccount(); - expect(account).toBeNull(); - }); - - it('should return null if file is empty', () => { - fs.mkdirSync(path.dirname(accountsFile()), { recursive: true }); - fs.writeFileSync(accountsFile(), ''); - const account = userAccountManager.getCachedGoogleAccount(); - expect(account).toBeNull(); - }); - - it('should return null and log if file is corrupted', () => { - fs.mkdirSync(path.dirname(accountsFile()), { recursive: true }); - fs.writeFileSync(accountsFile(), '{ "active": "test@google.com"'); // Invalid JSON - const consoleLogSpy = vi - .spyOn(console, 'log') - .mockImplementation(() => {}); - - const account = userAccountManager.getCachedGoogleAccount(); - - expect(account).toBeNull(); - expect(consoleLogSpy).toHaveBeenCalled(); - }); - - it('should return null if active key is missing', () => { - fs.mkdirSync(path.dirname(accountsFile()), { recursive: true }); - fs.writeFileSync(accountsFile(), JSON.stringify({ old: [] })); - const account = userAccountManager.getCachedGoogleAccount(); - expect(account).toBeNull(); - }); - }); - - describe('clearCachedGoogleAccount', () => { - it('should set active to null and move it to old', async () => { - fs.mkdirSync(path.dirname(accountsFile()), { recursive: true }); - fs.writeFileSync( - accountsFile(), - JSON.stringify( - { active: 'active@google.com', old: ['old1@google.com'] }, - null, - 2, - ), - ); - - await userAccountManager.clearCachedGoogleAccount(); - - const stored = JSON.parse(fs.readFileSync(accountsFile(), 'utf-8')); - expect(stored.active).toBeNull(); - expect(stored.old).toEqual(['old1@google.com', 'active@google.com']); - }); - - it('should handle empty file gracefully', async () => { - fs.mkdirSync(path.dirname(accountsFile()), { recursive: true }); - fs.writeFileSync(accountsFile(), ''); - await userAccountManager.clearCachedGoogleAccount(); - const stored = JSON.parse(fs.readFileSync(accountsFile(), 'utf-8')); - expect(stored.active).toBeNull(); - expect(stored.old).toEqual([]); - }); - - it('should handle corrupted JSON by creating a fresh file', async () => { - fs.mkdirSync(path.dirname(accountsFile()), { recursive: true }); - fs.writeFileSync(accountsFile(), 'not valid json'); - const consoleLogSpy = vi - .spyOn(console, 'log') - .mockImplementation(() => {}); - - await userAccountManager.clearCachedGoogleAccount(); - - expect(consoleLogSpy).toHaveBeenCalled(); - const stored = JSON.parse(fs.readFileSync(accountsFile(), 'utf-8')); - expect(stored.active).toBeNull(); - expect(stored.old).toEqual([]); - }); - - it('should be idempotent if active account is already null', async () => { - fs.mkdirSync(path.dirname(accountsFile()), { recursive: true }); - fs.writeFileSync( - accountsFile(), - JSON.stringify({ active: null, old: ['old1@google.com'] }, null, 2), - ); - - await userAccountManager.clearCachedGoogleAccount(); - - const stored = JSON.parse(fs.readFileSync(accountsFile(), 'utf-8')); - expect(stored.active).toBeNull(); - expect(stored.old).toEqual(['old1@google.com']); - }); - - it('should not add a duplicate to the old list', async () => { - fs.mkdirSync(path.dirname(accountsFile()), { recursive: true }); - fs.writeFileSync( - accountsFile(), - JSON.stringify( - { - active: 'active@google.com', - old: ['active@google.com'], - }, - null, - 2, - ), - ); - - await userAccountManager.clearCachedGoogleAccount(); - - const stored = JSON.parse(fs.readFileSync(accountsFile(), 'utf-8')); - expect(stored.active).toBeNull(); - expect(stored.old).toEqual(['active@google.com']); - }); - }); - - describe('getLifetimeGoogleAccounts', () => { - it('should return 0 if the file does not exist', () => { - expect(userAccountManager.getLifetimeGoogleAccounts()).toBe(0); - }); - - it('should return 0 if the file is empty', () => { - fs.mkdirSync(path.dirname(accountsFile()), { recursive: true }); - fs.writeFileSync(accountsFile(), ''); - expect(userAccountManager.getLifetimeGoogleAccounts()).toBe(0); - }); - - it('should return 0 if the file is corrupted', () => { - fs.mkdirSync(path.dirname(accountsFile()), { recursive: true }); - fs.writeFileSync(accountsFile(), 'invalid json'); - const consoleDebugSpy = vi - .spyOn(console, 'log') - .mockImplementation(() => {}); - - expect(userAccountManager.getLifetimeGoogleAccounts()).toBe(0); - expect(consoleDebugSpy).toHaveBeenCalled(); - }); - - it('should return 1 if there is only an active account', () => { - fs.mkdirSync(path.dirname(accountsFile()), { recursive: true }); - fs.writeFileSync( - accountsFile(), - JSON.stringify({ active: 'test1@google.com', old: [] }), - ); - expect(userAccountManager.getLifetimeGoogleAccounts()).toBe(1); - }); - - it('should correctly count old accounts when active is null', () => { - fs.mkdirSync(path.dirname(accountsFile()), { recursive: true }); - fs.writeFileSync( - accountsFile(), - JSON.stringify({ - active: null, - old: ['test1@google.com', 'test2@google.com'], - }), - ); - expect(userAccountManager.getLifetimeGoogleAccounts()).toBe(2); - }); - - it('should correctly count both active and old accounts', () => { - fs.mkdirSync(path.dirname(accountsFile()), { recursive: true }); - fs.writeFileSync( - accountsFile(), - JSON.stringify({ - active: 'test3@google.com', - old: ['test1@google.com', 'test2@google.com'], - }), - ); - expect(userAccountManager.getLifetimeGoogleAccounts()).toBe(3); - }); - - it('should handle valid JSON with incorrect schema by returning 0', () => { - fs.mkdirSync(path.dirname(accountsFile()), { recursive: true }); - fs.writeFileSync( - accountsFile(), - JSON.stringify({ active: null, old: 1 }), - ); - const consoleLogSpy = vi - .spyOn(console, 'log') - .mockImplementation(() => {}); - - expect(userAccountManager.getLifetimeGoogleAccounts()).toBe(0); - expect(consoleLogSpy).toHaveBeenCalled(); - }); - - it('should not double count if active account is also in old list', () => { - fs.mkdirSync(path.dirname(accountsFile()), { recursive: true }); - fs.writeFileSync( - accountsFile(), - JSON.stringify({ - active: 'test1@google.com', - old: ['test1@google.com', 'test2@google.com'], - }), - ); - expect(userAccountManager.getLifetimeGoogleAccounts()).toBe(2); - }); - }); -}); diff --git a/packages/core/src/utils/userAccountManager.ts b/packages/core/src/utils/userAccountManager.ts deleted file mode 100644 index 28d3cef96..000000000 --- a/packages/core/src/utils/userAccountManager.ts +++ /dev/null @@ -1,140 +0,0 @@ -/** - * @license - * Copyright 2025 Google LLC - * SPDX-License-Identifier: Apache-2.0 - */ - -import path from 'node:path'; -import { promises as fsp, readFileSync } from 'node:fs'; -import { Storage } from '../config/storage.js'; - -interface UserAccounts { - active: string | null; - old: string[]; -} - -export class UserAccountManager { - private getGoogleAccountsCachePath(): string { - return Storage.getGoogleAccountsPath(); - } - - /** - * Parses and validates the string content of an accounts file. - * @param content The raw string content from the file. - * @returns A valid UserAccounts object. - */ - private parseAndValidateAccounts(content: string): UserAccounts { - const defaultState = { active: null, old: [] }; - if (!content.trim()) { - return defaultState; - } - - const parsed = JSON.parse(content); - - // Inlined validation logic - if (typeof parsed !== 'object' || parsed === null) { - console.log('Invalid accounts file schema, starting fresh.'); - return defaultState; - } - const { active, old } = parsed as Partial; - const isValid = - (active === undefined || active === null || typeof active === 'string') && - (old === undefined || - (Array.isArray(old) && old.every((i) => typeof i === 'string'))); - - if (!isValid) { - console.log('Invalid accounts file schema, starting fresh.'); - return defaultState; - } - - return { - active: parsed.active ?? null, - old: parsed.old ?? [], - }; - } - - private readAccountsSync(filePath: string): UserAccounts { - const defaultState = { active: null, old: [] }; - try { - const content = readFileSync(filePath, 'utf-8'); - return this.parseAndValidateAccounts(content); - } catch (error) { - if ( - error instanceof Error && - 'code' in error && - error.code === 'ENOENT' - ) { - return defaultState; - } - console.log('Error during sync read of accounts, starting fresh.', error); - return defaultState; - } - } - - private async readAccounts(filePath: string): Promise { - const defaultState = { active: null, old: [] }; - try { - const content = await fsp.readFile(filePath, 'utf-8'); - return this.parseAndValidateAccounts(content); - } catch (error) { - if ( - error instanceof Error && - 'code' in error && - error.code === 'ENOENT' - ) { - return defaultState; - } - console.log('Could not parse accounts file, starting fresh.', error); - return defaultState; - } - } - - async cacheGoogleAccount(email: string): Promise { - const filePath = this.getGoogleAccountsCachePath(); - await fsp.mkdir(path.dirname(filePath), { recursive: true }); - - const accounts = await this.readAccounts(filePath); - - if (accounts.active && accounts.active !== email) { - if (!accounts.old.includes(accounts.active)) { - accounts.old.push(accounts.active); - } - } - - // If the new email was in the old list, remove it - accounts.old = accounts.old.filter((oldEmail) => oldEmail !== email); - - accounts.active = email; - await fsp.writeFile(filePath, JSON.stringify(accounts, null, 2), 'utf-8'); - } - - getCachedGoogleAccount(): string | null { - const filePath = this.getGoogleAccountsCachePath(); - const accounts = this.readAccountsSync(filePath); - return accounts.active; - } - - getLifetimeGoogleAccounts(): number { - const filePath = this.getGoogleAccountsCachePath(); - const accounts = this.readAccountsSync(filePath); - const allAccounts = new Set(accounts.old); - if (accounts.active) { - allAccounts.add(accounts.active); - } - return allAccounts.size; - } - - async clearCachedGoogleAccount(): Promise { - const filePath = this.getGoogleAccountsCachePath(); - const accounts = await this.readAccounts(filePath); - - if (accounts.active) { - if (!accounts.old.includes(accounts.active)) { - accounts.old.push(accounts.active); - } - accounts.active = null; - } - - await fsp.writeFile(filePath, JSON.stringify(accounts, null, 2), 'utf-8'); - } -} diff --git a/packages/sdk-typescript/README.md b/packages/sdk-typescript/README.md index bc3ef6aa4..a9699b02e 100644 --- a/packages/sdk-typescript/README.md +++ b/packages/sdk-typescript/README.md @@ -13,9 +13,8 @@ npm install @qwen-code/sdk ## Requirements - Node.js >= 20.0.0 -- [Qwen Code](https://github.com/QwenLM/qwen-code) >= 0.4.0 (stable) installed and accessible in PATH -> **Note for nvm users**: If you use nvm to manage Node.js versions, the SDK may not be able to auto-detect the Qwen Code executable. You should explicitly set the `pathToQwenExecutable` option to the full path of the `qwen` binary. +> From v0.1.1, the CLI is bundled with the SDK. So no standalone CLI installation is needed. ## Quick Start @@ -372,6 +371,23 @@ try { } ``` +## FAQ / Troubleshooting + +### Version 0.1.0 Requirements + +If you're using SDK version **0.1.0**, please note the following requirements: + +#### Qwen Code Installation Required + +Version 0.1.0 requires [Qwen Code](https://github.com/QwenLM/qwen-code) **>= 0.4.0** to be installed separately and accessible in your PATH. + +```bash +# Install Qwen Code globally +npm install -g qwen-code@^0.4.0 +``` + +**Note**: From version **0.1.1** onwards, the CLI is bundled with the SDK, so no separate Qwen Code installation is needed. + ## License Apache-2.0 - see [LICENSE](./LICENSE) for details. diff --git a/packages/sdk-typescript/package.json b/packages/sdk-typescript/package.json index b0f357095..0c82f138d 100644 --- a/packages/sdk-typescript/package.json +++ b/packages/sdk-typescript/package.json @@ -45,7 +45,9 @@ "node": ">=18.0.0" }, "dependencies": { - "@modelcontextprotocol/sdk": "^1.0.4" + "@modelcontextprotocol/sdk": "^1.25.1", + "zod": "^3.25.0", + "tiktoken": "^1.0.21" }, "devDependencies": { "@types/node": "^20.14.0", @@ -56,8 +58,7 @@ "esbuild": "^0.25.12", "eslint": "^8.57.0", "typescript": "^5.4.5", - "vitest": "^1.6.0", - "zod": "^3.23.8" + "vitest": "^1.6.0" }, "peerDependencies": { "typescript": ">=5.0.0" diff --git a/packages/sdk-typescript/scripts/build.js b/packages/sdk-typescript/scripts/build.js index beda8b0e7..ae3a21e87 100755 --- a/packages/sdk-typescript/scripts/build.js +++ b/packages/sdk-typescript/scripts/build.js @@ -91,3 +91,35 @@ if (existsSync(licenseSource)) { console.warn('Could not copy LICENSE:', error.message); } } + +console.log('Bundling CLI into SDK package...'); +const repoRoot = join(rootDir, '..', '..'); +const rootDistDir = join(repoRoot, 'dist'); + +if (!existsSync(rootDistDir) || !existsSync(join(rootDistDir, 'cli.js'))) { + console.log('Building CLI bundle...'); + try { + execSync('npm run bundle', { stdio: 'inherit', cwd: repoRoot }); + } catch (error) { + console.error('Failed to build CLI bundle:', error.message); + throw error; + } +} + +const cliDistDir = join(rootDir, 'dist', 'cli'); +mkdirSync(cliDistDir, { recursive: true }); + +console.log('Copying CLI bundle...'); +cpSync(join(rootDistDir, 'cli.js'), join(cliDistDir, 'cli.js')); + +const vendorSource = join(rootDistDir, 'vendor'); +if (existsSync(vendorSource)) { + cpSync(vendorSource, join(cliDistDir, 'vendor'), { recursive: true }); +} + +const localesSource = join(rootDistDir, 'locales'); +if (existsSync(localesSource)) { + cpSync(localesSource, join(cliDistDir, 'locales'), { recursive: true }); +} + +console.log('CLI bundle copied successfully to SDK package'); diff --git a/packages/sdk-typescript/src/mcp/tool.ts b/packages/sdk-typescript/src/mcp/tool.ts index 53e003996..ab5fa7e45 100644 --- a/packages/sdk-typescript/src/mcp/tool.ts +++ b/packages/sdk-typescript/src/mcp/tool.ts @@ -8,11 +8,9 @@ * Tool definition helper for SDK-embedded MCP servers */ -import type { CallToolResultSchema } from '@modelcontextprotocol/sdk/types.js'; +import type { CallToolResult } from '@modelcontextprotocol/sdk/types.js'; import type { z, ZodRawShape, ZodObject, ZodTypeAny } from 'zod'; -type CallToolResult = z.infer; - /** * SDK MCP Tool Definition with Zod schema type inference */ diff --git a/packages/sdk-typescript/src/transport/ProcessTransport.ts b/packages/sdk-typescript/src/transport/ProcessTransport.ts index c54d91045..43ff09daf 100644 --- a/packages/sdk-typescript/src/transport/ProcessTransport.ts +++ b/packages/sdk-typescript/src/transport/ProcessTransport.ts @@ -139,6 +139,7 @@ export class ProcessTransport implements Transport { 'stream-json', '--output-format', 'stream-json', + '--channel=SDK', ]; if (this.options.model) { diff --git a/packages/sdk-typescript/src/utils/cliPath.ts b/packages/sdk-typescript/src/utils/cliPath.ts index 2d9194131..49c145251 100644 --- a/packages/sdk-typescript/src/utils/cliPath.ts +++ b/packages/sdk-typescript/src/utils/cliPath.ts @@ -2,24 +2,16 @@ * CLI path auto-detection and subprocess spawning utilities * * Supports multiple execution modes: - * 1. Native binary: 'qwen' (production) - * 2. Node.js bundle: 'node /path/to/cli.js' (production validation) + * 1. Bundled CLI: Node.js bundle included in the SDK package (default) + * 2. Node.js bundle: 'node /path/to/cli.js' (custom path) * 3. Bun bundle: 'bun /path/to/cli.js' (alternative runtime) * 4. TypeScript source: 'tsx /path/to/index.ts' (development) - * - * Auto-detection locations for native binary: - * 1. QWEN_CODE_CLI_PATH environment variable - * 2. ~/.volta/bin/qwen - * 3. ~/.npm-global/bin/qwen - * 4. /usr/local/bin/qwen - * 5. ~/.local/bin/qwen - * 6. ~/node_modules/.bin/qwen - * 7. ~/.yarn/bin/qwen */ import * as fs from 'node:fs'; import * as path from 'node:path'; import { execSync } from 'node:child_process'; +import { fileURLToPath } from 'node:url'; /** * Executable types supported by the SDK @@ -40,49 +32,38 @@ export type SpawnInfo = { originalInput: string; }; -export function findNativeCliPath(): string { - const homeDir = process.env['HOME'] || process.env['USERPROFILE'] || ''; +function getBundledCliPath(): string | null { + try { + const currentFile = + typeof __filename !== 'undefined' + ? __filename + : fileURLToPath(import.meta.url); - const candidates: Array = [ - // 1. Environment variable (highest priority) - process.env['QWEN_CODE_CLI_PATH'], + const currentDir = path.dirname(currentFile); - // 2. Volta bin - path.join(homeDir, '.volta', 'bin', 'qwen'), + const bundledCliPath = path.join(currentDir, 'cli', 'cli.js'); - // 3. Global npm installations - path.join(homeDir, '.npm-global', 'bin', 'qwen'), - - // 4. Common Unix binary locations - '/usr/local/bin/qwen', - - // 5. User local bin - path.join(homeDir, '.local', 'bin', 'qwen'), - - // 6. Node modules bin in home directory - path.join(homeDir, 'node_modules', '.bin', 'qwen'), - - // 7. Yarn global bin - path.join(homeDir, '.yarn', 'bin', 'qwen'), - ]; - - // Find first existing candidate - for (const candidate of candidates) { - if (candidate && fs.existsSync(candidate)) { - return path.resolve(candidate); + if (fs.existsSync(bundledCliPath)) { + return bundledCliPath; } + + return null; + } catch { + return null; + } +} + +export function findNativeCliPath(): string { + const bundledCli = getBundledCliPath(); + if (bundledCli) { + return bundledCli; } - // Not found - throw helpful error throw new Error( - 'qwen CLI not found. Please:\n' + - ' 1. Install qwen globally: npm install -g qwen\n' + - ' 2. Or provide explicit executable: query({ pathToQwenExecutable: "/path/to/qwen" })\n' + - ' 3. Or set environment variable: QWEN_CODE_CLI_PATH="/path/to/qwen"\n' + - '\n' + - 'For development/testing, you can also use:\n' + + 'Bundled qwen CLI not found. The CLI should be included in the SDK package.\n' + + 'If you need to use a custom CLI, provide explicit executable:\n' + + ' • query({ pathToQwenExecutable: "/path/to/cli.js" })\n' + ' • TypeScript source: query({ pathToQwenExecutable: "/path/to/index.ts" })\n' + - ' • Node.js bundle: query({ pathToQwenExecutable: "/path/to/cli.js" })\n' + ' • Force specific runtime: query({ pathToQwenExecutable: "bun:/path/to/cli.js" })', ); } @@ -169,48 +150,49 @@ export function parseExecutableSpec(executableSpec?: string): { } // Check for runtime prefix (e.g., 'bun:/path/to/cli.js') + // Use whitelist mechanism: only treat as runtime spec if prefix matches supported runtimes + const supportedRuntimes = ['node', 'bun', 'tsx', 'deno']; const runtimeMatch = executableSpec.match(/^([^:]+):(.+)$/); + if (runtimeMatch) { const [, runtime, filePath] = runtimeMatch; - if (!runtime || !filePath) { - throw new Error(`Invalid runtime specification: '${executableSpec}'`); + + // Only process as runtime specification if it matches a supported runtime + if (runtime && supportedRuntimes.includes(runtime)) { + if (!filePath) { + throw new Error(`Invalid runtime specification: '${executableSpec}'`); + } + + if (!validateRuntimeAvailability(runtime)) { + throw new Error( + `Runtime '${runtime}' is not available on this system. Please install it first.`, + ); + } + + const resolvedPath = path.resolve(filePath); + + if (!fs.existsSync(resolvedPath)) { + throw new Error( + `Executable file not found at '${resolvedPath}' for runtime '${runtime}'. ` + + 'Please check the file path and ensure the file exists.', + ); + } + + if (!validateFileExtensionForRuntime(resolvedPath, runtime)) { + const ext = path.extname(resolvedPath); + throw new Error( + `File extension '${ext}' is not compatible with runtime '${runtime}'. ` + + `Expected extensions for ${runtime}: ${getExpectedExtensions(runtime).join(', ')}`, + ); + } + + return { + runtime, + executablePath: resolvedPath, + isExplicitRuntime: true, + }; } - - const supportedRuntimes = ['node', 'bun', 'tsx', 'deno']; - if (!supportedRuntimes.includes(runtime)) { - throw new Error( - `Unsupported runtime '${runtime}'. Supported runtimes: ${supportedRuntimes.join(', ')}`, - ); - } - - if (!validateRuntimeAvailability(runtime)) { - throw new Error( - `Runtime '${runtime}' is not available on this system. Please install it first.`, - ); - } - - const resolvedPath = path.resolve(filePath); - - if (!fs.existsSync(resolvedPath)) { - throw new Error( - `Executable file not found at '${resolvedPath}' for runtime '${runtime}'. ` + - 'Please check the file path and ensure the file exists.', - ); - } - - if (!validateFileExtensionForRuntime(resolvedPath, runtime)) { - const ext = path.extname(resolvedPath); - throw new Error( - `File extension '${ext}' is not compatible with runtime '${runtime}'. ` + - `Expected extensions for ${runtime}: ${getExpectedExtensions(runtime).join(', ')}`, - ); - } - - return { - runtime, - executablePath: resolvedPath, - isExplicitRuntime: true, - }; + // If not a supported runtime, fall through to treat as file path (e.g., Windows paths like 'D:\path\to\cli.js') } // Check if it's a command name (no path separators) or a file path diff --git a/packages/sdk-typescript/test/unit/cliPath.test.ts b/packages/sdk-typescript/test/unit/cliPath.test.ts index 43f50dec2..70d8cc378 100644 --- a/packages/sdk-typescript/test/unit/cliPath.test.ts +++ b/packages/sdk-typescript/test/unit/cliPath.test.ts @@ -38,6 +38,8 @@ describe('CLI Path Utilities', () => { mockFs.statSync.mockReturnValue({ isFile: () => true, } as ReturnType); + // Default: return true for existsSync (can be overridden in specific tests) + mockFs.existsSync.mockReturnValue(true); }); afterEach(() => { @@ -50,28 +52,26 @@ describe('CLI Path Utilities', () => { describe('parseExecutableSpec', () => { describe('auto-detection (no spec provided)', () => { - it('should auto-detect native CLI when no spec provided', () => { - // Mock environment variable - const originalEnv = process.env['QWEN_CODE_CLI_PATH']; - process.env['QWEN_CODE_CLI_PATH'] = '/usr/local/bin/qwen'; - mockFs.existsSync.mockReturnValue(true); + it('should auto-detect bundled CLI when no spec provided', () => { + // Mock existsSync to return true for bundled CLI + mockFs.existsSync.mockImplementation((p) => { + const pathStr = p.toString(); + return ( + pathStr.includes('cli/cli.js') || pathStr.includes('cli\\cli.js') + ); + }); const result = parseExecutableSpec(); - expect(result).toEqual({ - executablePath: path.resolve('/usr/local/bin/qwen'), - isExplicitRuntime: false, - }); - - // Restore env - process.env['QWEN_CODE_CLI_PATH'] = originalEnv; + expect(result.executablePath).toContain('cli.js'); + expect(result.isExplicitRuntime).toBe(false); }); - it('should throw when auto-detection fails', () => { + it('should throw when bundled CLI not found', () => { mockFs.existsSync.mockReturnValue(false); expect(() => parseExecutableSpec()).toThrow( - 'qwen CLI not found. Please:', + 'Bundled qwen CLI not found', ); }); }); @@ -125,12 +125,43 @@ describe('CLI Path Utilities', () => { }); }); - it('should throw for invalid runtime prefix format', () => { + it('should treat non-whitelisted runtime prefixes as command names', () => { + // With whitelist approach, 'invalid:format' is not recognized as a runtime spec + // so it's treated as a command name, which fails validation due to the colon expect(() => parseExecutableSpec('invalid:format')).toThrow( - 'Unsupported runtime', + 'Invalid command name', ); }); + it('should treat Windows drive letters as file paths, not runtime specs', () => { + mockFs.existsSync.mockReturnValue(true); + + // Test various Windows drive letters + const windowsPaths = [ + 'C:\\path\\to\\cli.js', + 'D:\\path\\to\\cli.js', + 'E:\\Users\\dev\\qwen\\cli.js', + ]; + + for (const winPath of windowsPaths) { + const result = parseExecutableSpec(winPath); + + expect(result.isExplicitRuntime).toBe(false); + expect(result.runtime).toBeUndefined(); + expect(result.executablePath).toBe(path.resolve(winPath)); + } + }); + + it('should handle Windows paths with forward slashes', () => { + mockFs.existsSync.mockReturnValue(true); + + const result = parseExecutableSpec('C:/path/to/cli.js'); + + expect(result.isExplicitRuntime).toBe(false); + expect(result.runtime).toBeUndefined(); + expect(result.executablePath).toBe(path.resolve('C:/path/to/cli.js')); + }); + it('should throw when runtime-prefixed file does not exist', () => { mockFs.existsSync.mockReturnValue(false); @@ -361,65 +392,44 @@ describe('CLI Path Utilities', () => { }); describe('auto-detection fallback', () => { - it('should auto-detect when no spec provided', () => { - // Mock environment variable - const originalEnv = process.env['QWEN_CODE_CLI_PATH']; - process.env['QWEN_CODE_CLI_PATH'] = '/usr/local/bin/qwen'; + it('should auto-detect bundled CLI when no spec provided', () => { + // Mock existsSync to return true for bundled CLI + mockFs.existsSync.mockImplementation((p) => { + const pathStr = p.toString(); + return ( + pathStr.includes('cli/cli.js') || pathStr.includes('cli\\cli.js') + ); + }); const result = prepareSpawnInfo(); - expect(result).toEqual({ - command: path.resolve('/usr/local/bin/qwen'), - args: [], - type: 'native', - originalInput: '', - }); - - // Restore env - process.env['QWEN_CODE_CLI_PATH'] = originalEnv; + expect(result.command).toBe(process.execPath); + expect(result.args[0]).toContain('cli.js'); + expect(result.type).toBe('node'); + expect(result.originalInput).toBe(''); }); }); }); describe('findNativeCliPath', () => { - it('should find CLI from environment variable', () => { - const originalEnv = process.env['QWEN_CODE_CLI_PATH']; - process.env['QWEN_CODE_CLI_PATH'] = '/custom/path/to/qwen'; - mockFs.existsSync.mockReturnValue(true); - - const result = findNativeCliPath(); - - expect(result).toBe(path.resolve('/custom/path/to/qwen')); - - process.env['QWEN_CODE_CLI_PATH'] = originalEnv; - }); - - it('should search common installation locations', () => { - const originalEnv = process.env['QWEN_CODE_CLI_PATH']; - delete process.env['QWEN_CODE_CLI_PATH']; - - // Mock fs.existsSync to return true for volta bin - // Use path.join to match platform-specific path separators - const voltaBinPath = path.join('.volta', 'bin', 'qwen'); + it('should find bundled CLI', () => { + // Mock existsSync to return true for bundled CLI mockFs.existsSync.mockImplementation((p) => { - return p.toString().includes(voltaBinPath); + const pathStr = p.toString(); + return ( + pathStr.includes('cli/cli.js') || pathStr.includes('cli\\cli.js') + ); }); const result = findNativeCliPath(); - expect(result).toContain(voltaBinPath); - - process.env['QWEN_CODE_CLI_PATH'] = originalEnv; + expect(result).toContain('cli.js'); }); - it('should throw descriptive error when CLI not found', () => { - const originalEnv = process.env['QWEN_CODE_CLI_PATH']; - delete process.env['QWEN_CODE_CLI_PATH']; + it('should throw descriptive error when bundled CLI not found', () => { mockFs.existsSync.mockReturnValue(false); - expect(() => findNativeCliPath()).toThrow('qwen CLI not found. Please:'); - - process.env['QWEN_CODE_CLI_PATH'] = originalEnv; + expect(() => findNativeCliPath()).toThrow('Bundled qwen CLI not found'); }); }); @@ -474,6 +484,41 @@ describe('CLI Path Utilities', () => { originalInput: `bun:${bundlePath}`, }); }); + + it('should handle Windows paths with drive letters', () => { + const windowsPath = 'D:\\path\\to\\cli.js'; + const result = prepareSpawnInfo(windowsPath); + + expect(result).toEqual({ + command: process.execPath, + args: [path.resolve(windowsPath)], + type: 'node', + originalInput: windowsPath, + }); + }); + + it('should handle Windows paths with TypeScript files', () => { + const windowsPath = 'C:\\Users\\dev\\qwen\\index.ts'; + const result = prepareSpawnInfo(windowsPath); + + expect(result).toEqual({ + command: 'tsx', + args: [path.resolve(windowsPath)], + type: 'tsx', + originalInput: windowsPath, + }); + }); + + it('should not confuse Windows drive letters with runtime prefixes', () => { + // Ensure 'D:' is not treated as a runtime specification + const windowsPath = 'D:\\workspace\\project\\cli.js'; + const result = prepareSpawnInfo(windowsPath); + + // Should use node runtime based on .js extension, not treat 'D' as runtime + expect(result.type).toBe('node'); + expect(result.command).toBe(process.execPath); + expect(result.args).toEqual([path.resolve(windowsPath)]); + }); }); describe('error cases', () => { @@ -493,21 +538,39 @@ describe('CLI Path Utilities', () => { ); }); - it('should provide helpful error for invalid runtime specification', () => { + it('should treat non-whitelisted runtime prefixes as command names', () => { + // With whitelist approach, 'invalid:spec' is not recognized as a runtime spec + // so it's treated as a command name, which fails validation due to the colon expect(() => prepareSpawnInfo('invalid:spec')).toThrow( - 'Unsupported runtime', + 'Invalid command name', + ); + }); + + it('should handle Windows paths correctly even when file is missing', () => { + mockFs.existsSync.mockReturnValue(false); + + expect(() => prepareSpawnInfo('D:\\missing\\cli.js')).toThrow( + 'Executable file not found at', + ); + // Should not throw 'Invalid command name' error (which would happen if 'D:' was treated as invalid command) + expect(() => prepareSpawnInfo('D:\\missing\\cli.js')).not.toThrow( + 'Invalid command name', ); }); }); describe('comprehensive validation', () => { describe('runtime validation', () => { - it('should reject unsupported runtimes', () => { - expect(() => - parseExecutableSpec('unsupported:/path/to/file.js'), - ).toThrow( - "Unsupported runtime 'unsupported'. Supported runtimes: node, bun, tsx, deno", - ); + it('should treat unsupported runtime prefixes as file paths', () => { + mockFs.existsSync.mockReturnValue(true); + + // With whitelist approach, 'unsupported:' is not recognized as a runtime spec + // so 'unsupported:/path/to/file.js' is treated as a file path + const result = parseExecutableSpec('unsupported:/path/to/file.js'); + + // Should be treated as a file path, not a runtime specification + expect(result.isExplicitRuntime).toBe(false); + expect(result.runtime).toBeUndefined(); }); it('should validate runtime availability for explicit runtime specs', () => { @@ -634,13 +697,10 @@ describe('CLI Path Utilities', () => { mockFs.existsSync.mockReturnValue(false); expect(() => parseExecutableSpec('/missing/file')).toThrow( - 'Set QWEN_CODE_CLI_PATH environment variable', + 'Executable file not found at', ); expect(() => parseExecutableSpec('/missing/file')).toThrow( - 'Install qwen globally: npm install -g qwen', - ); - expect(() => parseExecutableSpec('/missing/file')).toThrow( - 'Force specific runtime: bun:/path/to/cli.js or tsx:/path/to/index.ts', + 'Please check the file path and ensure the file exists', ); }); }); diff --git a/packages/sdk-typescript/tsconfig.json b/packages/sdk-typescript/tsconfig.json index 11fba047e..868202433 100644 --- a/packages/sdk-typescript/tsconfig.json +++ b/packages/sdk-typescript/tsconfig.json @@ -2,7 +2,7 @@ "compilerOptions": { /* Language and Environment */ "target": "ES2022", - "lib": ["ES2022"], + "lib": ["ES2022", "DOM"], "module": "ESNext", "moduleResolution": "bundler", diff --git a/packages/test-utils/package.json b/packages/test-utils/package.json index 7365c0593..a1310056f 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.4.1", + "version": "0.6.0", "private": true, "main": "src/index.ts", "license": "Apache-2.0", diff --git a/packages/vscode-ide-companion/.vscodeignore b/packages/vscode-ide-companion/.vscodeignore index e74d05364..18e07a04b 100644 --- a/packages/vscode-ide-companion/.vscodeignore +++ b/packages/vscode-ide-companion/.vscodeignore @@ -1,5 +1,6 @@ ** !dist/ +!dist/** ../ ../../ !LICENSE diff --git a/packages/vscode-ide-companion/NOTICES.txt b/packages/vscode-ide-companion/NOTICES.txt index 8866f163d..9e312534d 100644 --- a/packages/vscode-ide-companion/NOTICES.txt +++ b/packages/vscode-ide-companion/NOTICES.txt @@ -1,210 +1,24 @@ This file contains third-party software notices and license terms. ============================================================ -@modelcontextprotocol/sdk@1.15.1 -(git+https://github.com/modelcontextprotocol/typescript-sdk.git) +semver@7.7.2 +(git+https://github.com/npm/node-semver.git) -MIT License +The ISC License -Copyright (c) 2024 Anthropic, PBC +Copyright (c) Isaac Z. Schlueter and Contributors -Permission is hereby granted, free of charge, to any person obtaining a copy -of this software and associated documentation files (the "Software"), to deal -in the Software without restriction, including without limitation the rights -to use, copy, modify, merge, publish, distribute, sublicense, and/or sell -copies of the Software, and to permit persons to whom the Software is -furnished to do so, subject to the following conditions: +Permission to use, copy, modify, and/or distribute this software for any +purpose with or without fee is hereby granted, provided that the above +copyright notice and this permission notice appear in all copies. -The above copyright notice and this permission notice shall be included in all -copies or substantial portions of the Software. - -THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR -IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE -AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, -OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE -SOFTWARE. - - -============================================================ -ajv@6.12.6 -(https://github.com/ajv-validator/ajv.git) - -The MIT License (MIT) - -Copyright (c) 2015-2017 Evgeny Poberezkin - -Permission is hereby granted, free of charge, to any person obtaining a copy -of this software and associated documentation files (the "Software"), to deal -in the Software without restriction, including without limitation the rights -to use, copy, modify, merge, publish, distribute, sublicense, and/or sell -copies of the Software, and to permit persons to whom the Software is -furnished to do so, subject to the following conditions: - -The above copyright notice and this permission notice shall be included in all -copies or substantial portions of the Software. - -THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR -IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE -AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, -OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE -SOFTWARE. - - - -============================================================ -fast-deep-equal@3.1.3 -(git+https://github.com/epoberezkin/fast-deep-equal.git) - -MIT License - -Copyright (c) 2017 Evgeny Poberezkin - -Permission is hereby granted, free of charge, to any person obtaining a copy -of this software and associated documentation files (the "Software"), to deal -in the Software without restriction, including without limitation the rights -to use, copy, modify, merge, publish, distribute, sublicense, and/or sell -copies of the Software, and to permit persons to whom the Software is -furnished to do so, subject to the following conditions: - -The above copyright notice and this permission notice shall be included in all -copies or substantial portions of the Software. - -THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR -IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE -AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, -OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE -SOFTWARE. - - -============================================================ -fast-json-stable-stringify@2.1.0 -(git://github.com/epoberezkin/fast-json-stable-stringify.git) - -This software is released under the MIT license: - -Copyright (c) 2017 Evgeny Poberezkin -Copyright (c) 2013 James Halliday - -Permission is hereby granted, free of charge, to any person obtaining a copy of -this software and associated documentation files (the "Software"), to deal in -the Software without restriction, including without limitation the rights to -use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of -the Software, and to permit persons to whom the Software is furnished to do so, -subject to the following conditions: - -The above copyright notice and this permission notice shall be included in all -copies or substantial portions of the Software. - -THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR -IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS -FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR -COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER -IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN -CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. - - -============================================================ -json-schema-traverse@0.4.1 -(git+https://github.com/epoberezkin/json-schema-traverse.git) - -MIT License - -Copyright (c) 2017 Evgeny Poberezkin - -Permission is hereby granted, free of charge, to any person obtaining a copy -of this software and associated documentation files (the "Software"), to deal -in the Software without restriction, including without limitation the rights -to use, copy, modify, merge, publish, distribute, sublicense, and/or sell -copies of the Software, and to permit persons to whom the Software is -furnished to do so, subject to the following conditions: - -The above copyright notice and this permission notice shall be included in all -copies or substantial portions of the Software. - -THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR -IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE -AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, -OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE -SOFTWARE. - - -============================================================ -uri-js@4.4.1 -(http://github.com/garycourt/uri-js) - -Copyright 2011 Gary Court. All rights reserved. - -Redistribution and use in source and binary forms, with or without modification, are permitted provided that the following conditions are met: - -1. Redistributions of source code must retain the above copyright notice, this list of conditions and the following disclaimer. - -2. Redistributions in binary form must reproduce the above copyright notice, this list of conditions and the following disclaimer in the documentation and/or other materials provided with the distribution. - -THIS SOFTWARE IS PROVIDED BY GARY COURT "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL GARY COURT OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. - -The views and conclusions contained in the software and documentation are those of the authors and should not be interpreted as representing official policies, either expressed or implied, of Gary Court. - - -============================================================ -punycode@2.3.1 -(https://github.com/mathiasbynens/punycode.js.git) - -Copyright Mathias Bynens - -Permission is hereby granted, free of charge, to any person obtaining -a copy of this software and associated documentation files (the -"Software"), to deal in the Software without restriction, including -without limitation the rights to use, copy, modify, merge, publish, -distribute, sublicense, and/or sell copies of the Software, and to -permit persons to whom the Software is furnished to do so, subject to -the following conditions: - -The above copyright notice and this permission notice shall be -included in all copies or substantial portions of the Software. - -THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, -EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF -MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND -NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE -LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION -OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION -WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. - - -============================================================ -content-type@1.0.5 -(No repository found) - -(The MIT License) - -Copyright (c) 2015 Douglas Christopher Wilson - -Permission is hereby granted, free of charge, to any person obtaining -a copy of this software and associated documentation files (the -'Software'), to deal in the Software without restriction, including -without limitation the rights to use, copy, modify, merge, publish, -distribute, sublicense, and/or sell copies of the Software, and to -permit persons to whom the Software is furnished to do so, subject to -the following conditions: - -The above copyright notice and this permission notice shall be -included in all copies or substantial portions of the Software. - -THE SOFTWARE IS PROVIDED 'AS IS', WITHOUT WARRANTY OF ANY KIND, -EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF -MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. -IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY -CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, -TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE -SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. +THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES +WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF +MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR +ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES +WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN +ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF OR +IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE. ============================================================ @@ -290,175 +104,6 @@ TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. -============================================================ -cross-spawn@7.0.6 -(git@github.com:moxystudio/node-cross-spawn.git) - -The MIT License (MIT) - -Copyright (c) 2018 Made With MOXY Lda - -Permission is hereby granted, free of charge, to any person obtaining a copy -of this software and associated documentation files (the "Software"), to deal -in the Software without restriction, including without limitation the rights -to use, copy, modify, merge, publish, distribute, sublicense, and/or sell -copies of the Software, and to permit persons to whom the Software is -furnished to do so, subject to the following conditions: - -The above copyright notice and this permission notice shall be included in -all copies or substantial portions of the Software. - -THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR -IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE -AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, -OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN -THE SOFTWARE. - - -============================================================ -path-key@3.1.1 -(No repository found) - -MIT License - -Copyright (c) Sindre Sorhus (sindresorhus.com) - -Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: - -The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. - -THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. - - -============================================================ -shebang-command@2.0.0 -(No repository found) - -MIT License - -Copyright (c) Kevin Mårtensson (github.com/kevva) - -Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: - -The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. - -THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. - - -============================================================ -shebang-regex@3.0.0 -(No repository found) - -MIT License - -Copyright (c) Sindre Sorhus (sindresorhus.com) - -Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: - -The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. - -THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. - - -============================================================ -which@2.0.2 -(git://github.com/isaacs/node-which.git) - -The ISC License - -Copyright (c) Isaac Z. Schlueter and Contributors - -Permission to use, copy, modify, and/or distribute this software for any -purpose with or without fee is hereby granted, provided that the above -copyright notice and this permission notice appear in all copies. - -THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES -WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF -MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR -ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES -WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN -ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF OR -IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE. - - -============================================================ -isexe@2.0.0 -(git+https://github.com/isaacs/isexe.git) - -The ISC License - -Copyright (c) Isaac Z. Schlueter and Contributors - -Permission to use, copy, modify, and/or distribute this software for any -purpose with or without fee is hereby granted, provided that the above -copyright notice and this permission notice appear in all copies. - -THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES -WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF -MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR -ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES -WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN -ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF OR -IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE. - - -============================================================ -eventsource@3.0.7 -(git://git@github.com/EventSource/eventsource.git) - -The MIT License - -Copyright (c) EventSource GitHub organisation - -Permission is hereby granted, free of charge, to any person obtaining -a copy of this software and associated documentation files (the -"Software"), to deal in the Software without restriction, including -without limitation the rights to use, copy, modify, merge, publish, -distribute, sublicense, and/or sell copies of the Software, and to -permit persons to whom the Software is furnished to do so, subject to -the following conditions: - -The above copyright notice and this permission notice shall be -included in all copies or substantial portions of the Software. - -THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, -EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF -MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND -NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE -LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION -OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION -WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. - - -============================================================ -eventsource-parser@3.0.3 -(git+ssh://git@github.com/rexxars/eventsource-parser.git) - -MIT License - -Copyright (c) 2025 Espen Hovlandsdal - -Permission is hereby granted, free of charge, to any person obtaining a copy -of this software and associated documentation files (the "Software"), to deal -in the Software without restriction, including without limitation the rights -to use, copy, modify, merge, publish, distribute, sublicense, and/or sell -copies of the Software, and to permit persons to whom the Software is -furnished to do so, subject to the following conditions: - -The above copyright notice and this permission notice shall be included in all -copies or substantial portions of the Software. - -THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR -IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE -AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, -OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE -SOFTWARE. - - ============================================================ express@4.21.2 (No repository found) @@ -691,6 +336,34 @@ TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. +============================================================ +content-type@1.0.5 +(No repository found) + +(The MIT License) + +Copyright (c) 2015 Douglas Christopher Wilson + +Permission is hereby granted, free of charge, to any person obtaining +a copy of this software and associated documentation files (the +'Software'), to deal in the Software without restriction, including +without limitation the rights to use, copy, modify, merge, publish, +distribute, sublicense, and/or sell copies of the Software, and to +permit persons to whom the Software is furnished to do so, subject to +the following conditions: + +The above copyright notice and this permission notice shall be +included in all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED 'AS IS', WITHOUT WARRANTY OF ANY KIND, +EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF +MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. +IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY +CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, +TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE +SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + + ============================================================ debug@4.4.1 (git://github.com/debug-js/debug.git) @@ -2218,38 +1891,502 @@ CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. ============================================================ -express-rate-limit@7.5.1 -(git+https://github.com/express-rate-limit/express-rate-limit.git) +markdown-it@14.1.0 +(No repository found) -# MIT License +Copyright (c) 2014 Vitaly Puzrin, Alex Kocharin. -Copyright 2023 Nathan Friedly, Vedant K +Permission is hereby granted, free of charge, to any person +obtaining a copy of this software and associated documentation +files (the "Software"), to deal in the Software without +restriction, including without limitation the rights to use, +copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the +Software is furnished to do so, subject to the following +conditions: -Permission is hereby granted, free of charge, to any person obtaining a copy of -this software and associated documentation files (the "Software"), to deal in -the Software without restriction, including without limitation the rights to -use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of -the Software, and to permit persons to whom the Software is furnished to do so, -subject to the following conditions: +The above copyright notice and this permission notice shall be +included in all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES +OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT +HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR +OTHER DEALINGS IN THE SOFTWARE. + + +============================================================ +argparse@2.0.1 +(No repository found) + +A. HISTORY OF THE SOFTWARE +========================== + +Python was created in the early 1990s by Guido van Rossum at Stichting +Mathematisch Centrum (CWI, see http://www.cwi.nl) in the Netherlands +as a successor of a language called ABC. Guido remains Python's +principal author, although it includes many contributions from others. + +In 1995, Guido continued his work on Python at the Corporation for +National Research Initiatives (CNRI, see http://www.cnri.reston.va.us) +in Reston, Virginia where he released several versions of the +software. + +In May 2000, Guido and the Python core development team moved to +BeOpen.com to form the BeOpen PythonLabs team. In October of the same +year, the PythonLabs team moved to Digital Creations, which became +Zope Corporation. In 2001, the Python Software Foundation (PSF, see +https://www.python.org/psf/) was formed, a non-profit organization +created specifically to own Python-related Intellectual Property. +Zope Corporation was a sponsoring member of the PSF. + +All Python releases are Open Source (see http://www.opensource.org for +the Open Source Definition). Historically, most, but not all, Python +releases have also been GPL-compatible; the table below summarizes +the various releases. + + Release Derived Year Owner GPL- + from compatible? (1) + + 0.9.0 thru 1.2 1991-1995 CWI yes + 1.3 thru 1.5.2 1.2 1995-1999 CNRI yes + 1.6 1.5.2 2000 CNRI no + 2.0 1.6 2000 BeOpen.com no + 1.6.1 1.6 2001 CNRI yes (2) + 2.1 2.0+1.6.1 2001 PSF no + 2.0.1 2.0+1.6.1 2001 PSF yes + 2.1.1 2.1+2.0.1 2001 PSF yes + 2.1.2 2.1.1 2002 PSF yes + 2.1.3 2.1.2 2002 PSF yes + 2.2 and above 2.1.1 2001-now PSF yes + +Footnotes: + +(1) GPL-compatible doesn't mean that we're distributing Python under + the GPL. All Python licenses, unlike the GPL, let you distribute + a modified version without making your changes open source. The + GPL-compatible licenses make it possible to combine Python with + other software that is released under the GPL; the others don't. + +(2) According to Richard Stallman, 1.6.1 is not GPL-compatible, + because its license has a choice of law clause. According to + CNRI, however, Stallman's lawyer has told CNRI's lawyer that 1.6.1 + is "not incompatible" with the GPL. + +Thanks to the many outside volunteers who have worked under Guido's +direction to make these releases possible. + + +B. TERMS AND CONDITIONS FOR ACCESSING OR OTHERWISE USING PYTHON +=============================================================== + +PYTHON SOFTWARE FOUNDATION LICENSE VERSION 2 +-------------------------------------------- + +1. This LICENSE AGREEMENT is between the Python Software Foundation +("PSF"), and the Individual or Organization ("Licensee") accessing and +otherwise using this software ("Python") in source or binary form and +its associated documentation. + +2. Subject to the terms and conditions of this License Agreement, PSF hereby +grants Licensee a nonexclusive, royalty-free, world-wide license to reproduce, +analyze, test, perform and/or display publicly, prepare derivative works, +distribute, and otherwise use Python alone or in any derivative version, +provided, however, that PSF's License Agreement and PSF's notice of copyright, +i.e., "Copyright (c) 2001, 2002, 2003, 2004, 2005, 2006, 2007, 2008, 2009, 2010, +2011, 2012, 2013, 2014, 2015, 2016, 2017, 2018, 2019, 2020 Python Software Foundation; +All Rights Reserved" are retained in Python alone or in any derivative version +prepared by Licensee. + +3. In the event Licensee prepares a derivative work that is based on +or incorporates Python or any part thereof, and wants to make +the derivative work available to others as provided herein, then +Licensee hereby agrees to include in any such work a brief summary of +the changes made to Python. + +4. PSF is making Python available to Licensee on an "AS IS" +basis. PSF MAKES NO REPRESENTATIONS OR WARRANTIES, EXPRESS OR +IMPLIED. BY WAY OF EXAMPLE, BUT NOT LIMITATION, PSF MAKES NO AND +DISCLAIMS ANY REPRESENTATION OR WARRANTY OF MERCHANTABILITY OR FITNESS +FOR ANY PARTICULAR PURPOSE OR THAT THE USE OF PYTHON WILL NOT +INFRINGE ANY THIRD PARTY RIGHTS. + +5. PSF SHALL NOT BE LIABLE TO LICENSEE OR ANY OTHER USERS OF PYTHON +FOR ANY INCIDENTAL, SPECIAL, OR CONSEQUENTIAL DAMAGES OR LOSS AS +A RESULT OF MODIFYING, DISTRIBUTING, OR OTHERWISE USING PYTHON, +OR ANY DERIVATIVE THEREOF, EVEN IF ADVISED OF THE POSSIBILITY THEREOF. + +6. This License Agreement will automatically terminate upon a material +breach of its terms and conditions. + +7. Nothing in this License Agreement shall be deemed to create any +relationship of agency, partnership, or joint venture between PSF and +Licensee. This License Agreement does not grant permission to use PSF +trademarks or trade name in a trademark sense to endorse or promote +products or services of Licensee, or any third party. + +8. By copying, installing or otherwise using Python, Licensee +agrees to be bound by the terms and conditions of this License +Agreement. + + +BEOPEN.COM LICENSE AGREEMENT FOR PYTHON 2.0 +------------------------------------------- + +BEOPEN PYTHON OPEN SOURCE LICENSE AGREEMENT VERSION 1 + +1. This LICENSE AGREEMENT is between BeOpen.com ("BeOpen"), having an +office at 160 Saratoga Avenue, Santa Clara, CA 95051, and the +Individual or Organization ("Licensee") accessing and otherwise using +this software in source or binary form and its associated +documentation ("the Software"). + +2. Subject to the terms and conditions of this BeOpen Python License +Agreement, BeOpen hereby grants Licensee a non-exclusive, +royalty-free, world-wide license to reproduce, analyze, test, perform +and/or display publicly, prepare derivative works, distribute, and +otherwise use the Software alone or in any derivative version, +provided, however, that the BeOpen Python License is retained in the +Software, alone or in any derivative version prepared by Licensee. + +3. BeOpen is making the Software available to Licensee on an "AS IS" +basis. BEOPEN MAKES NO REPRESENTATIONS OR WARRANTIES, EXPRESS OR +IMPLIED. BY WAY OF EXAMPLE, BUT NOT LIMITATION, BEOPEN MAKES NO AND +DISCLAIMS ANY REPRESENTATION OR WARRANTY OF MERCHANTABILITY OR FITNESS +FOR ANY PARTICULAR PURPOSE OR THAT THE USE OF THE SOFTWARE WILL NOT +INFRINGE ANY THIRD PARTY RIGHTS. + +4. BEOPEN SHALL NOT BE LIABLE TO LICENSEE OR ANY OTHER USERS OF THE +SOFTWARE FOR ANY INCIDENTAL, SPECIAL, OR CONSEQUENTIAL DAMAGES OR LOSS +AS A RESULT OF USING, MODIFYING OR DISTRIBUTING THE SOFTWARE, OR ANY +DERIVATIVE THEREOF, EVEN IF ADVISED OF THE POSSIBILITY THEREOF. + +5. This License Agreement will automatically terminate upon a material +breach of its terms and conditions. + +6. This License Agreement shall be governed by and interpreted in all +respects by the law of the State of California, excluding conflict of +law provisions. Nothing in this License Agreement shall be deemed to +create any relationship of agency, partnership, or joint venture +between BeOpen and Licensee. This License Agreement does not grant +permission to use BeOpen trademarks or trade names in a trademark +sense to endorse or promote products or services of Licensee, or any +third party. As an exception, the "BeOpen Python" logos available at +http://www.pythonlabs.com/logos.html may be used according to the +permissions granted on that web page. + +7. By copying, installing or otherwise using the software, Licensee +agrees to be bound by the terms and conditions of this License +Agreement. + + +CNRI LICENSE AGREEMENT FOR PYTHON 1.6.1 +--------------------------------------- + +1. This LICENSE AGREEMENT is between the Corporation for National +Research Initiatives, having an office at 1895 Preston White Drive, +Reston, VA 20191 ("CNRI"), and the Individual or Organization +("Licensee") accessing and otherwise using Python 1.6.1 software in +source or binary form and its associated documentation. + +2. Subject to the terms and conditions of this License Agreement, CNRI +hereby grants Licensee a nonexclusive, royalty-free, world-wide +license to reproduce, analyze, test, perform and/or display publicly, +prepare derivative works, distribute, and otherwise use Python 1.6.1 +alone or in any derivative version, provided, however, that CNRI's +License Agreement and CNRI's notice of copyright, i.e., "Copyright (c) +1995-2001 Corporation for National Research Initiatives; All Rights +Reserved" are retained in Python 1.6.1 alone or in any derivative +version prepared by Licensee. Alternately, in lieu of CNRI's License +Agreement, Licensee may substitute the following text (omitting the +quotes): "Python 1.6.1 is made available subject to the terms and +conditions in CNRI's License Agreement. This Agreement together with +Python 1.6.1 may be located on the Internet using the following +unique, persistent identifier (known as a handle): 1895.22/1013. This +Agreement may also be obtained from a proxy server on the Internet +using the following URL: http://hdl.handle.net/1895.22/1013". + +3. In the event Licensee prepares a derivative work that is based on +or incorporates Python 1.6.1 or any part thereof, and wants to make +the derivative work available to others as provided herein, then +Licensee hereby agrees to include in any such work a brief summary of +the changes made to Python 1.6.1. + +4. CNRI is making Python 1.6.1 available to Licensee on an "AS IS" +basis. CNRI MAKES NO REPRESENTATIONS OR WARRANTIES, EXPRESS OR +IMPLIED. BY WAY OF EXAMPLE, BUT NOT LIMITATION, CNRI MAKES NO AND +DISCLAIMS ANY REPRESENTATION OR WARRANTY OF MERCHANTABILITY OR FITNESS +FOR ANY PARTICULAR PURPOSE OR THAT THE USE OF PYTHON 1.6.1 WILL NOT +INFRINGE ANY THIRD PARTY RIGHTS. + +5. CNRI SHALL NOT BE LIABLE TO LICENSEE OR ANY OTHER USERS OF PYTHON +1.6.1 FOR ANY INCIDENTAL, SPECIAL, OR CONSEQUENTIAL DAMAGES OR LOSS AS +A RESULT OF MODIFYING, DISTRIBUTING, OR OTHERWISE USING PYTHON 1.6.1, +OR ANY DERIVATIVE THEREOF, EVEN IF ADVISED OF THE POSSIBILITY THEREOF. + +6. This License Agreement will automatically terminate upon a material +breach of its terms and conditions. + +7. This License Agreement shall be governed by the federal +intellectual property law of the United States, including without +limitation the federal copyright law, and, to the extent such +U.S. federal law does not apply, by the law of the Commonwealth of +Virginia, excluding Virginia's conflict of law provisions. +Notwithstanding the foregoing, with regard to derivative works based +on Python 1.6.1 that incorporate non-separable material that was +previously distributed under the GNU General Public License (GPL), the +law of the Commonwealth of Virginia shall govern this License +Agreement only as to issues arising under or with respect to +Paragraphs 4, 5, and 7 of this License Agreement. Nothing in this +License Agreement shall be deemed to create any relationship of +agency, partnership, or joint venture between CNRI and Licensee. This +License Agreement does not grant permission to use CNRI trademarks or +trade name in a trademark sense to endorse or promote products or +services of Licensee, or any third party. + +8. By clicking on the "ACCEPT" button where indicated, or by copying, +installing or otherwise using Python 1.6.1, Licensee agrees to be +bound by the terms and conditions of this License Agreement. + + ACCEPT + + +CWI LICENSE AGREEMENT FOR PYTHON 0.9.0 THROUGH 1.2 +-------------------------------------------------- + +Copyright (c) 1991 - 1995, Stichting Mathematisch Centrum Amsterdam, +The Netherlands. All rights reserved. + +Permission to use, copy, modify, and distribute this software and its +documentation for any purpose and without fee is hereby granted, +provided that the above copyright notice appear in all copies and that +both that copyright notice and this permission notice appear in +supporting documentation, and that the name of Stichting Mathematisch +Centrum or CWI not be used in advertising or publicity pertaining to +distribution of the software without specific, written prior +permission. + +STICHTING MATHEMATISCH CENTRUM DISCLAIMS ALL WARRANTIES WITH REGARD TO +THIS SOFTWARE, INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY AND +FITNESS, IN NO EVENT SHALL STICHTING MATHEMATISCH CENTRUM BE LIABLE +FOR ANY SPECIAL, INDIRECT OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES +WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN +ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT +OF OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE. + + +============================================================ +entities@4.5.0 +(git://github.com/fb55/entities.git) + +Copyright (c) Felix Böhm +All rights reserved. + +Redistribution and use in source and binary forms, with or without modification, are permitted provided that the following conditions are met: + +Redistributions of source code must retain the above copyright notice, this list of conditions and the following disclaimer. + +Redistributions in binary form must reproduce the above copyright notice, this list of conditions and the following disclaimer in the documentation and/or other materials provided with the distribution. + +THIS IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS, +EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + + +============================================================ +linkify-it@5.0.0 +(No repository found) + +Copyright (c) 2015 Vitaly Puzrin. + +Permission is hereby granted, free of charge, to any person +obtaining a copy of this software and associated documentation +files (the "Software"), to deal in the Software without +restriction, including without limitation the rights to use, +copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the +Software is furnished to do so, subject to the following +conditions: + +The above copyright notice and this permission notice shall be +included in all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES +OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT +HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR +OTHER DEALINGS IN THE SOFTWARE. + + +============================================================ +uc.micro@2.1.0 +(No repository found) + +Copyright Mathias Bynens + +Permission is hereby granted, free of charge, to any person obtaining +a copy of this software and associated documentation files (the +"Software"), to deal in the Software without restriction, including +without limitation the rights to use, copy, modify, merge, publish, +distribute, sublicense, and/or sell copies of the Software, and to +permit persons to whom the Software is furnished to do so, subject to +the following conditions: + +The above copyright notice and this permission notice shall be +included in all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF +MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE +LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION +OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION +WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + + +============================================================ +mdurl@2.0.0 +(No repository found) + +Copyright (c) 2015 Vitaly Puzrin, Alex Kocharin. + +Permission is hereby granted, free of charge, to any person +obtaining a copy of this software and associated documentation +files (the "Software"), to deal in the Software without +restriction, including without limitation the rights to use, +copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the +Software is furnished to do so, subject to the following +conditions: + +The above copyright notice and this permission notice shall be +included in all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES +OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT +HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR +OTHER DEALINGS IN THE SOFTWARE. + +-------------------------------------------------------------------------------- + +.parse() is based on Joyent's node.js `url` code: + +Copyright Joyent, Inc. and other Node contributors. All rights reserved. +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to +deal in the Software without restriction, including without limitation the +rights to use, copy, modify, merge, publish, distribute, sublicense, and/or +sell copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in +all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS +IN THE SOFTWARE. + + +============================================================ +punycode.js@2.3.1 +(https://github.com/mathiasbynens/punycode.js.git) + +Copyright Mathias Bynens + +Permission is hereby granted, free of charge, to any person obtaining +a copy of this software and associated documentation files (the +"Software"), to deal in the Software without restriction, including +without limitation the rights to use, copy, modify, merge, publish, +distribute, sublicense, and/or sell copies of the Software, and to +permit persons to whom the Software is furnished to do so, subject to +the following conditions: + +The above copyright notice and this permission notice shall be +included in all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF +MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE +LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION +OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION +WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + + +============================================================ +react@19.1.0 +(https://github.com/facebook/react.git) + +MIT License + +Copyright (c) Meta Platforms, Inc. and affiliates. + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR -IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS -FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR -COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER -IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN -CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. ============================================================ -pkce-challenge@5.0.0 -(git+https://github.com/crouchcd/pkce-challenge.git) +react-dom@19.1.0 +(https://github.com/facebook/react.git) MIT License -Copyright (c) 2019 +Copyright (c) Meta Platforms, Inc. and affiliates. + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. + + +============================================================ +scheduler@0.26.0 +(https://github.com/facebook/react.git) + +MIT License + +Copyright (c) Meta Platforms, Inc. and affiliates. Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal @@ -2297,23 +2434,3 @@ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. -============================================================ -zod-to-json-schema@3.24.6 -(https://github.com/StefanTerdell/zod-to-json-schema) - -ISC License - -Copyright (c) 2020, Stefan Terdell - -Permission to use, copy, modify, and/or distribute this software for any -purpose with or without fee is hereby granted, provided that the above -copyright notice and this permission notice appear in all copies. - -THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES -WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF -MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR -ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES -WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN -ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF -OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE. - diff --git a/packages/vscode-ide-companion/README.md b/packages/vscode-ide-companion/README.md index c67887692..a5e4980d2 100644 --- a/packages/vscode-ide-companion/README.md +++ b/packages/vscode-ide-companion/README.md @@ -19,6 +19,63 @@ To use this extension, you'll need: - VS Code version 1.101.0 or newer - Qwen Code (installed separately) running within the VS Code integrated terminal +# Development and Debugging + +To debug and develop this extension locally: + +1. **Clone the repository** + + ```bash + git clone https://github.com/QwenLM/qwen-code.git + cd qwen-code + ``` + +2. **Install dependencies** + + ```bash + npm install + # or if using pnpm + pnpm install + ``` + +3. **Start debugging** + + ```bash + code . # Open the project root in VS Code + ``` + - Open the `packages/vscode-ide-companion/src/extension.ts` file + - Open Debug panel (`Ctrl+Shift+D` or `Cmd+Shift+D`) + - Select **"Launch Companion VS Code Extension"** from the debug dropdown + - Press `F5` to launch Extension Development Host + +4. **Make changes and reload** + - Edit the source code in the original VS Code window + - To see your changes, reload the Extension Development Host window by: + - Pressing `Ctrl+R` (Windows/Linux) or `Cmd+R` (macOS) + - Or clicking the "Reload" button in the debug toolbar + +5. **View logs and debug output** + - Open the Debug Console in the original VS Code window to see extension logs + - In the Extension Development Host window, open Developer Tools with `Help > Toggle Developer Tools` to see webview logs + +## Build for Production + +To build the extension for distribution: + +```bash +npm run compile +# or +pnpm run compile +``` + +To package the extension as a VSIX file: + +```bash +npx vsce package +# or +pnpm vsce package +``` + # Terms of Service and Privacy Notice By installing this extension, you agree to the [Terms of Service](https://github.com/QwenLM/qwen-code/blob/main/docs/tos-privacy.md). diff --git a/packages/vscode-ide-companion/esbuild.js b/packages/vscode-ide-companion/esbuild.js index 7de7c7ada..032c3c138 100644 --- a/packages/vscode-ide-companion/esbuild.js +++ b/packages/vscode-ide-companion/esbuild.js @@ -31,8 +31,69 @@ const esbuildProblemMatcherPlugin = { }, }; +/** + * @type {import('esbuild').Plugin} + */ +const cssInjectPlugin = { + name: 'css-inject', + setup(build) { + // Handle CSS files + build.onLoad({ filter: /\.css$/ }, async (args) => { + const fs = await import('fs'); + const postcss = (await import('postcss')).default; + const tailwindcss = (await import('tailwindcss')).default; + const autoprefixer = (await import('autoprefixer')).default; + + let css = await fs.promises.readFile(args.path, 'utf8'); + + // For styles.css, we need to resolve @import statements + if (args.path.endsWith('styles.css')) { + // Read all imported CSS files and inline them + const importRegex = /@import\s+'([^']+)';/g; + let match; + const basePath = args.path.substring(0, args.path.lastIndexOf('/')); + while ((match = importRegex.exec(css)) !== null) { + const importPath = match[1]; + // Resolve relative paths correctly + let fullPath; + if (importPath.startsWith('./')) { + fullPath = basePath + importPath.substring(1); + } else if (importPath.startsWith('../')) { + fullPath = basePath + '/' + importPath; + } else { + fullPath = basePath + '/' + importPath; + } + + try { + const importedCss = await fs.promises.readFile(fullPath, 'utf8'); + css = css.replace(match[0], importedCss); + } catch (err) { + console.warn(`Could not import ${fullPath}: ${err.message}`); + } + } + } + + // Process with PostCSS (Tailwind + Autoprefixer) + const result = await postcss([tailwindcss, autoprefixer]).process(css, { + from: args.path, + to: args.path, + }); + + return { + contents: ` + const style = document.createElement('style'); + style.textContent = ${JSON.stringify(result.css)}; + document.head.appendChild(style); + `, + loader: 'js', + }; + }); + }, +}; + async function main() { - const ctx = await esbuild.context({ + // Build extension + const extensionCtx = await esbuild.context({ entryPoints: ['src/extension.ts'], bundle: true, format: 'cjs', @@ -55,11 +116,30 @@ async function main() { ], loader: { '.node': 'file' }, }); + + // Build webview + const webviewCtx = await esbuild.context({ + entryPoints: ['src/webview/index.tsx'], + bundle: true, + format: 'iife', + minify: production, + sourcemap: !production, + sourcesContent: false, + platform: 'browser', + outfile: 'dist/webview.js', + logLevel: 'silent', + plugins: [cssInjectPlugin, esbuildProblemMatcherPlugin], + jsx: 'automatic', // Use new JSX transform (React 17+) + define: { + 'process.env.NODE_ENV': production ? '"production"' : '"development"', + }, + }); + if (watch) { - await ctx.watch(); + await Promise.all([extensionCtx.watch(), webviewCtx.watch()]); } else { - await ctx.rebuild(); - await ctx.dispose(); + await Promise.all([extensionCtx.rebuild(), webviewCtx.rebuild()]); + await Promise.all([extensionCtx.dispose(), webviewCtx.dispose()]); } } diff --git a/packages/vscode-ide-companion/eslint.config.mjs b/packages/vscode-ide-companion/eslint.config.mjs index 02fc9fba0..4b444a9b2 100644 --- a/packages/vscode-ide-companion/eslint.config.mjs +++ b/packages/vscode-ide-companion/eslint.config.mjs @@ -6,20 +6,44 @@ import typescriptEslint from '@typescript-eslint/eslint-plugin'; import tsParser from '@typescript-eslint/parser'; +import reactHooks from 'eslint-plugin-react-hooks'; +import importPlugin from 'eslint-plugin-import'; export default [ { - files: ['**/*.ts'], + files: ['**/*.ts', '**/*.tsx'], + }, + { + files: ['**/*.js', '**/*.cjs', '**/*.mjs'], + languageOptions: { + ecmaVersion: 2022, + sourceType: 'commonjs', + globals: { + module: 'readonly', + require: 'readonly', + __dirname: 'readonly', + __filename: 'readonly', + process: 'readonly', + console: 'readonly', + }, + }, }, { plugins: { '@typescript-eslint': typescriptEslint, + 'react-hooks': reactHooks, + import: importPlugin, }, languageOptions: { parser: tsParser, ecmaVersion: 2022, sourceType: 'module', + parserOptions: { + ecmaFeatures: { + jsx: true, + }, + }, }, rules: { @@ -30,6 +54,17 @@ export default [ format: ['camelCase', 'PascalCase'], }, ], + 'react-hooks/rules-of-hooks': 'error', + 'react-hooks/exhaustive-deps': 'error', + // Restrict deep imports but allow known-safe exceptions used by the webview + // - react-dom/client: required for React 18's createRoot API + // - ./styles/**: local CSS modules loaded by the webview + 'import/no-internal-modules': [ + 'error', + { + allow: ['react-dom/client', './styles/**'], + }, + ], curly: 'warn', eqeqeq: 'warn', diff --git a/packages/vscode-ide-companion/package.json b/packages/vscode-ide-companion/package.json index 4d1f10236..5fa753162 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.4.1", + "version": "0.6.0", "publisher": "qwenlm", "icon": "assets/icon.png", "repository": { @@ -11,7 +11,7 @@ "directory": "packages/vscode-ide-companion" }, "engines": { - "vscode": "^1.99.0" + "vscode": "^1.85.0" }, "license": "LICENSE", "preview": true, @@ -54,6 +54,15 @@ { "command": "qwen-code.showNotices", "title": "Qwen Code: View Third-Party Notices" + }, + { + "command": "qwen-code.openChat", + "title": "Qwen Code: Open", + "icon": "./assets/icon.png" + }, + { + "command": "qwen-code.login", + "title": "Qwen Code: Login" } ], "menus": { @@ -65,6 +74,10 @@ { "command": "qwen.diff.cancel", "when": "qwen.diff.isVisible" + }, + { + "command": "qwen-code.login", + "when": "false" } ], "editor/title": [ @@ -77,6 +90,10 @@ "command": "qwen.diff.cancel", "when": "qwen.diff.isVisible", "group": "navigation" + }, + { + "command": "qwen-code.openChat", + "group": "navigation" } ] }, @@ -96,7 +113,7 @@ "main": "./dist/extension.cjs", "type": "module", "scripts": { - "prepackage": "npm run generate:notices && npm run check-types && npm run lint && npm run build:prod", + "prepackage": "node ./scripts/prepackage.js", "build": "npm run build:dev", "build:dev": "npm run check-types && npm run lint && node esbuild.js", "build:prod": "node esbuild.js --production", @@ -115,21 +132,33 @@ "devDependencies": { "@types/cors": "^2.8.19", "@types/express": "^5.0.3", + "@types/markdown-it": "^14.1.2", "@types/node": "20.x", - "@types/vscode": "^1.99.0", + "@types/react": "^19.1.8", + "@types/react-dom": "^19.1.6", + "@types/semver": "^7.7.1", + "@types/vscode": "^1.85.0", "@typescript-eslint/eslint-plugin": "^8.31.1", "@typescript-eslint/parser": "^8.31.1", "@vscode/vsce": "^3.6.0", + "autoprefixer": "^10.4.22", "esbuild": "^0.25.3", "eslint": "^9.25.1", + "eslint-plugin-react-hooks": "^5.2.0", "npm-run-all2": "^8.0.2", + "postcss": "^8.5.6", + "tailwindcss": "^3.4.18", "typescript": "^5.8.3", "vitest": "^3.2.4" }, "dependencies": { - "@modelcontextprotocol/sdk": "^1.15.1", + "semver": "^7.7.2", + "@modelcontextprotocol/sdk": "^1.25.1", "cors": "^2.8.5", "express": "^5.1.0", + "markdown-it": "^14.1.0", + "react": "^19.1.0", + "react-dom": "^19.1.0", "zod": "^3.25.76" } } diff --git a/packages/vscode-ide-companion/postcss.config.js b/packages/vscode-ide-companion/postcss.config.js new file mode 100644 index 000000000..49f4aea7a --- /dev/null +++ b/packages/vscode-ide-companion/postcss.config.js @@ -0,0 +1,13 @@ +/** + * @license + * Copyright 2025 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + */ + +/* eslint-disable no-undef */ +module.exports = { + plugins: { + tailwindcss: {}, + autoprefixer: {}, + }, +}; diff --git a/packages/vscode-ide-companion/scripts/copy-bundled-cli.js b/packages/vscode-ide-companion/scripts/copy-bundled-cli.js new file mode 100644 index 000000000..d720e47f1 --- /dev/null +++ b/packages/vscode-ide-companion/scripts/copy-bundled-cli.js @@ -0,0 +1,67 @@ +/** + * @license + * Copyright 2025 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + */ + +/** + * Copy the already-built root dist/ folder into the extension dist/qwen-cli/. + * + * Assumes repoRoot/dist already exists (e.g. produced by `npm run bundle` and + * optionally `npm run prepare:package`). + */ + +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; +import fs from 'node:fs/promises'; +import { existsSync } from 'node:fs'; + +const __filename = fileURLToPath(import.meta.url); +const __dirname = path.dirname(__filename); + +const extensionRoot = path.resolve(__dirname, '..'); +const repoRoot = path.resolve(extensionRoot, '..', '..'); +const rootDistDir = path.join(repoRoot, 'dist'); +const extensionDistDir = path.join(extensionRoot, 'dist'); +const bundledCliDir = path.join(extensionDistDir, 'qwen-cli'); + +async function main() { + const cliJs = path.join(rootDistDir, 'cli.js'); + const vendorDir = path.join(rootDistDir, 'vendor'); + + if (!existsSync(cliJs) || !existsSync(vendorDir)) { + throw new Error( + `[copy-bundled-cli] Missing root dist artifacts. Expected:\n- ${cliJs}\n- ${vendorDir}\n\nRun root "npm run bundle" first.`, + ); + } + + await fs.mkdir(extensionDistDir, { recursive: true }); + const existingNodeModules = path.join(bundledCliDir, 'node_modules'); + const tmpNodeModules = path.join( + extensionDistDir, + 'qwen-cli.node_modules.tmp', + ); + const keepNodeModules = existsSync(existingNodeModules); + + // Preserve destination node_modules if it exists (e.g. after packaging install). + if (keepNodeModules) { + await fs.rm(tmpNodeModules, { recursive: true, force: true }); + await fs.rename(existingNodeModules, tmpNodeModules); + } + + await fs.rm(bundledCliDir, { recursive: true, force: true }); + await fs.mkdir(bundledCliDir, { recursive: true }); + + await fs.cp(rootDistDir, bundledCliDir, { recursive: true }); + + if (keepNodeModules) { + await fs.rename(tmpNodeModules, existingNodeModules); + } + + console.log(`[copy-bundled-cli] Copied ${rootDistDir} -> ${bundledCliDir}`); +} + +main().catch((err) => { + console.error(err); + process.exit(1); +}); diff --git a/packages/vscode-ide-companion/scripts/prepackage.js b/packages/vscode-ide-companion/scripts/prepackage.js new file mode 100644 index 000000000..de76a3359 --- /dev/null +++ b/packages/vscode-ide-companion/scripts/prepackage.js @@ -0,0 +1,98 @@ +/** + * @license + * Copyright 2025 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + */ + +/** + * VS Code extension packaging orchestration. + * + * We bundle the CLI into the extension so users don't need a global install. + * To match the published CLI layout, we need to: + * - build root bundle (dist/cli.js + vendor/ + sandbox profiles) + * - run root prepare:package (dist/package.json + locales + README/LICENSE) + * - install production deps into root dist/ (dist/node_modules) so runtime deps + * like optional node-pty are present inside the VSIX payload. + * + * Then we generate notices and build the extension. + */ + +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { spawnSync } from 'node:child_process'; + +const __filename = fileURLToPath(import.meta.url); +const __dirname = path.dirname(__filename); + +const extensionRoot = path.resolve(__dirname, '..'); +const repoRoot = path.resolve(extensionRoot, '..', '..'); +const bundledCliDir = path.join(extensionRoot, 'dist', 'qwen-cli'); + +function npmBin() { + return process.platform === 'win32' ? 'npm.cmd' : 'npm'; +} + +function run(cmd, args, opts = {}) { + const res = spawnSync(cmd, args, { + stdio: 'inherit', + shell: process.platform === 'win32', + ...opts, + }); + if (res.error) { + throw res.error; + } + if (typeof res.status === 'number' && res.status !== 0) { + throw new Error( + `Command failed (${res.status}): ${cmd} ${args.map((a) => JSON.stringify(a)).join(' ')}`, + ); + } +} + +function main() { + const npm = npmBin(); + + console.log('[prepackage] Bundling root CLI...'); + run(npm, ['--prefix', repoRoot, 'run', 'bundle'], { cwd: repoRoot }); + + console.log('[prepackage] Preparing root dist/ package metadata...'); + run(npm, ['--prefix', repoRoot, 'run', 'prepare:package'], { cwd: repoRoot }); + + console.log('[prepackage] Generating notices...'); + run(npm, ['run', 'generate:notices'], { cwd: extensionRoot }); + + console.log('[prepackage] Typechecking...'); + run(npm, ['run', 'check-types'], { cwd: extensionRoot }); + + console.log('[prepackage] Linting...'); + run(npm, ['run', 'lint'], { cwd: extensionRoot }); + + console.log('[prepackage] Building extension (production)...'); + run(npm, ['run', 'build:prod'], { cwd: extensionRoot }); + + console.log('[prepackage] Copying bundled CLI dist/ into extension...'); + run( + 'node', + [`${path.join(extensionRoot, 'scripts', 'copy-bundled-cli.js')}`], + { + cwd: extensionRoot, + }, + ); + + console.log( + '[prepackage] Installing production deps into extension dist/qwen-cli...', + ); + run( + npm, + [ + '--prefix', + bundledCliDir, + 'install', + '--omit=dev', + '--no-audit', + '--no-fund', + ], + { cwd: bundledCliDir }, + ); +} + +main(); diff --git a/packages/vscode-ide-companion/src/commands/index.ts b/packages/vscode-ide-companion/src/commands/index.ts new file mode 100644 index 000000000..e75e1bd10 --- /dev/null +++ b/packages/vscode-ide-companion/src/commands/index.ts @@ -0,0 +1,80 @@ +import * as vscode from 'vscode'; +import type { DiffManager } from '../diff-manager.js'; +import type { WebViewProvider } from '../webview/WebViewProvider.js'; + +type Logger = (message: string) => void; + +export const runQwenCodeCommand = 'qwen-code.runQwenCode'; +export const showDiffCommand = 'qwenCode.showDiff'; +export const openChatCommand = 'qwen-code.openChat'; +export const openNewChatTabCommand = 'qwenCode.openNewChatTab'; +export const loginCommand = 'qwen-code.login'; + +export function registerNewCommands( + context: vscode.ExtensionContext, + log: Logger, + diffManager: DiffManager, + getWebViewProviders: () => WebViewProvider[], + createWebViewProvider: () => WebViewProvider, +): void { + const disposables: vscode.Disposable[] = []; + + disposables.push( + vscode.commands.registerCommand(openChatCommand, async () => { + const providers = getWebViewProviders(); + if (providers.length > 0) { + await providers[providers.length - 1].show(); + } else { + const provider = createWebViewProvider(); + await provider.show(); + } + }), + ); + + disposables.push( + vscode.commands.registerCommand( + showDiffCommand, + async (args: { path: string; oldText: string; newText: string }) => { + try { + let absolutePath = args.path; + if (!args.path.startsWith('/') && !args.path.match(/^[a-zA-Z]:/)) { + const workspaceFolder = vscode.workspace.workspaceFolders?.[0]; + if (workspaceFolder) { + absolutePath = vscode.Uri.joinPath( + workspaceFolder.uri, + args.path, + ).fsPath; + } + } + log(`[Command] Showing diff for ${absolutePath}`); + await diffManager.showDiff(absolutePath, args.oldText, args.newText); + } catch (error) { + log(`[Command] Error showing diff: ${error}`); + vscode.window.showErrorMessage(`Failed to show diff: ${error}`); + } + }, + ), + ); + + disposables.push( + vscode.commands.registerCommand(openNewChatTabCommand, async () => { + const provider = createWebViewProvider(); + // Session restoration is now disabled by default, so no need to suppress it + await provider.show(); + }), + ); + + disposables.push( + vscode.commands.registerCommand(loginCommand, async () => { + const providers = getWebViewProviders(); + if (providers.length > 0) { + await providers[providers.length - 1].forceReLogin(); + } else { + vscode.window.showInformationMessage( + 'Please open Qwen Code chat first before logging in.', + ); + } + }), + ); + context.subscriptions.push(...disposables); +} diff --git a/packages/vscode-ide-companion/src/constants/acpSchema.ts b/packages/vscode-ide-companion/src/constants/acpSchema.ts new file mode 100644 index 000000000..9f06e4fad --- /dev/null +++ b/packages/vscode-ide-companion/src/constants/acpSchema.ts @@ -0,0 +1,25 @@ +/** + * @license + * Copyright 2025 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + */ + +export const AGENT_METHODS = { + authenticate: 'authenticate', + initialize: 'initialize', + session_cancel: 'session/cancel', + session_list: 'session/list', + session_load: 'session/load', + session_new: 'session/new', + session_prompt: 'session/prompt', + session_save: 'session/save', + session_set_mode: 'session/set_mode', +} as const; + +export const CLIENT_METHODS = { + fs_read_text_file: 'fs/read_text_file', + fs_write_text_file: 'fs/write_text_file', + authenticate_update: 'authenticate/update', + session_request_permission: 'session/request_permission', + session_update: 'session/update', +} as const; diff --git a/packages/vscode-ide-companion/src/constants/loadingMessages.ts b/packages/vscode-ide-companion/src/constants/loadingMessages.ts new file mode 100644 index 000000000..edb01ca2c --- /dev/null +++ b/packages/vscode-ide-companion/src/constants/loadingMessages.ts @@ -0,0 +1,146 @@ +/** + * @license + * Copyright 2025 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + */ + +/** + * Loading messages from Qwen Code CLI + * Source: packages/cli/src/ui/hooks/usePhraseCycler.ts + */ +export const WITTY_LOADING_PHRASES = [ + "I'm Feeling Lucky", + 'Shipping awesomeness... ', + 'Painting the serifs back on...', + 'Navigating the slime mold...', + 'Consulting the digital spirits...', + 'Reticulating splines...', + 'Warming up the AI hamsters...', + 'Asking the magic conch shell...', + 'Generating witty retort...', + 'Polishing the algorithms...', + "Don't rush perfection (or my code)...", + 'Brewing fresh bytes...', + 'Counting electrons...', + 'Engaging cognitive processors...', + 'Checking for syntax errors in the universe...', + 'One moment, optimizing humor...', + 'Shuffling punchlines...', + 'Untangling neural nets...', + 'Compiling brilliance...', + 'Loading wit.exe...', + 'Summoning the cloud of wisdom...', + 'Preparing a witty response...', + "Just a sec, I'm debugging reality...", + 'Confuzzling the options...', + 'Tuning the cosmic frequencies...', + 'Crafting a response worthy of your patience...', + 'Compiling the 1s and 0s...', + 'Resolving dependencies... and existential crises...', + 'Defragmenting memories... both RAM and personal...', + 'Rebooting the humor module...', + 'Caching the essentials (mostly cat memes)...', + 'Optimizing for ludicrous speed', + "Swapping bits... don't tell the bytes...", + 'Garbage collecting... be right back...', + 'Assembling the interwebs...', + 'Converting coffee into code...', + 'Updating the syntax for reality...', + 'Rewiring the synapses...', + 'Looking for a misplaced semicolon...', + "Greasin' the cogs of the machine...", + 'Pre-heating the servers...', + 'Calibrating the flux capacitor...', + 'Engaging the improbability drive...', + 'Channeling the Force...', + 'Aligning the stars for optimal response...', + 'So say we all...', + 'Loading the next great idea...', + "Just a moment, I'm in the zone...", + 'Preparing to dazzle you with brilliance...', + "Just a tick, I'm polishing my wit...", + "Hold tight, I'm crafting a masterpiece...", + "Just a jiffy, I'm debugging the universe...", + "Just a moment, I'm aligning the pixels...", + "Just a sec, I'm optimizing the humor...", + "Just a moment, I'm tuning the algorithms...", + 'Warp speed engaged...', + 'Mining for more Dilithium crystals...', + "Don't panic...", + 'Following the white rabbit...', + 'The truth is in here... somewhere...', + 'Blowing on the cartridge...', + 'Loading... Do a barrel roll!', + 'Waiting for the respawn...', + 'Finishing the Kessel Run in less than 12 parsecs...', + "The cake is not a lie, it's just still loading...", + 'Fiddling with the character creation screen...', + "Just a moment, I'm finding the right meme...", + "Pressing 'A' to continue...", + 'Herding digital cats...', + 'Polishing the pixels...', + 'Finding a suitable loading screen pun...', + 'Distracting you with this witty phrase...', + 'Almost there... probably...', + 'Our hamsters are working as fast as they can...', + 'Giving Cloudy a pat on the head...', + 'Petting the cat...', + 'Rickrolling my boss...', + 'Never gonna give you up, never gonna let you down...', + 'Slapping the bass...', + 'Tasting the snozberries...', + "I'm going the distance, I'm going for speed...", + 'Is this the real life? Is this just fantasy?...', + "I've got a good feeling about this...", + 'Poking the bear...', + 'Doing research on the latest memes...', + 'Figuring out how to make this more witty...', + 'Hmmm... let me think...', + 'What do you call a fish with no eyes? A fsh...', + 'Why did the computer go to therapy? It had too many bytes...', + "Why don't programmers like nature? It has too many bugs...", + 'Why do programmers prefer dark mode? Because light attracts bugs...', + 'Why did the developer go broke? Because they used up all their cache...', + "What can you do with a broken pencil? Nothing, it's pointless...", + 'Applying percussive maintenance...', + 'Searching for the correct USB orientation...', + 'Ensuring the magic smoke stays inside the wires...', + 'Rewriting in Rust for no particular reason...', + 'Trying to exit Vim...', + 'Spinning up the hamster wheel...', + "That's not a bug, it's an undocumented feature...", + 'Engage.', + "I'll be back... with an answer.", + 'My other process is a TARDIS...', + 'Communing with the machine spirit...', + 'Letting the thoughts marinate...', + 'Just remembered where I put my keys...', + 'Pondering the orb...', + "I've seen things you people wouldn't believe... like a user who reads loading messages.", + 'Initiating thoughtful gaze...', + "What's a computer's favorite snack? Microchips.", + "Why do Java developers wear glasses? Because they don't C#.", + 'Charging the laser... pew pew!', + 'Dividing by zero... just kidding!', + 'Looking for an adult superviso... I mean, processing.', + 'Making it go beep boop.', + 'Buffering... because even AIs need a moment.', + 'Entangling quantum particles for a faster response...', + 'Polishing the chrome... on the algorithms.', + 'Are you not entertained? (Working on it!)', + 'Summoning the code gremlins... to help, of course.', + 'Just waiting for the dial-up tone to finish...', + 'Recalibrating the humor-o-meter.', + 'My other loading screen is even funnier.', + "Pretty sure there's a cat walking on the keyboard somewhere...", + 'Enhancing... Enhancing... Still loading.', + "It's not a bug, it's a feature... of this loading screen.", + 'Have you tried turning it off and on again? (The loading screen, not me.)', + 'Constructing additional pylons...', + "New line? That's Ctrl+J.", +]; + +export const getRandomLoadingMessage = (): string => + WITTY_LOADING_PHRASES[ + Math.floor(Math.random() * WITTY_LOADING_PHRASES.length) + ]; diff --git a/packages/vscode-ide-companion/src/diff-manager.ts b/packages/vscode-ide-companion/src/diff-manager.ts index fea9edc44..9a32769c1 100644 --- a/packages/vscode-ide-companion/src/diff-manager.ts +++ b/packages/vscode-ide-companion/src/diff-manager.ts @@ -12,6 +12,10 @@ import { type JSONRPCNotification } from '@modelcontextprotocol/sdk/types.js'; import * as path from 'node:path'; import * as vscode from 'vscode'; import { DIFF_SCHEME } from './extension.js'; +import { + findLeftGroupOfChatWebview, + ensureLeftGroupOfChatWebview, +} from './utils/editorGroupUtils.js'; export class DiffContentProvider implements vscode.TextDocumentContentProvider { private content = new Map(); @@ -42,7 +46,9 @@ export class DiffContentProvider implements vscode.TextDocumentContentProvider { // Information about a diff view that is currently open. interface DiffInfo { originalFilePath: string; + oldContent: string; newContent: string; + leftDocUri: vscode.Uri; rightDocUri: vscode.Uri; } @@ -55,11 +61,26 @@ export class DiffManager { readonly onDidChange = this.onDidChangeEmitter.event; private diffDocuments = new Map(); private readonly subscriptions: vscode.Disposable[] = []; + // Dedupe: remember recent showDiff calls keyed by (file+content) + private recentlyShown = new Map(); + private pendingDelayTimers = new Map(); + private static readonly DEDUPE_WINDOW_MS = 1500; + // Optional hooks from extension to influence diff behavior + // - shouldDelay: when true, we defer opening diffs briefly (e.g., while a permission drawer is open) + // - shouldSuppress: when true, we skip opening diffs entirely (e.g., in auto/yolo mode) + private shouldDelay?: () => boolean; + private shouldSuppress?: () => boolean; + // Timed suppression window (e.g. immediately after permission allow) + private suppressUntil: number | null = null; constructor( private readonly log: (message: string) => void, private readonly diffContentProvider: DiffContentProvider, + shouldDelay?: () => boolean, + shouldSuppress?: () => boolean, ) { + this.shouldDelay = shouldDelay; + this.shouldSuppress = shouldSuppress; this.subscriptions.push( vscode.window.onDidChangeActiveTextEditor((editor) => { this.onActiveEditorChange(editor); @@ -75,43 +96,142 @@ export class DiffManager { } /** - * Creates and shows a new diff view. + * Checks if a diff view already exists for the given file path and content + * @param filePath Path to the file being diffed + * @param oldContent The original content (left side) + * @param newContent The modified content (right side) + * @returns True if a diff view with the same content already exists, false otherwise */ - async showDiff(filePath: string, newContent: string) { - const fileUri = vscode.Uri.file(filePath); + private hasExistingDiff( + filePath: string, + oldContent: string, + newContent: string, + ): boolean { + for (const diffInfo of this.diffDocuments.values()) { + if ( + diffInfo.originalFilePath === filePath && + diffInfo.oldContent === oldContent && + diffInfo.newContent === newContent + ) { + return true; + } + } + return false; + } + /** + * Finds an existing diff view for the given file path and focuses it + * @param filePath Path to the file being diffed + * @returns True if an existing diff view was found and focused, false otherwise + */ + private async focusExistingDiff(filePath: string): Promise { + const normalizedPath = path.normalize(filePath); + for (const [, diffInfo] of this.diffDocuments.entries()) { + if (diffInfo.originalFilePath === normalizedPath) { + const rightDocUri = diffInfo.rightDocUri; + const leftDocUri = diffInfo.leftDocUri; + + const diffTitle = `${path.basename(filePath)} (Before ↔ After)`; + + try { + await vscode.commands.executeCommand( + 'vscode.diff', + leftDocUri, + rightDocUri, + diffTitle, + { + viewColumn: vscode.ViewColumn.Beside, + preview: false, + preserveFocus: true, + }, + ); + return true; + } catch (error) { + this.log(`Failed to focus existing diff: ${error}`); + return false; + } + } + } + return false; + } + + /** + * Creates and shows a new diff view. + * - Overload 1: showDiff(filePath, newContent) + * - Overload 2: showDiff(filePath, oldContent, newContent) + * If only newContent is provided, the old content will be read from the + * filesystem (empty string when file does not exist). + */ + async showDiff(filePath: string, newContent: string): Promise; + async showDiff( + filePath: string, + oldContent: string, + newContent: string, + ): Promise; + async showDiff(filePath: string, a: string, b?: string): Promise { + const haveOld = typeof b === 'string'; + const oldContent = haveOld ? a : await this.readOldContentFromFs(filePath); + const newContent = haveOld ? (b as string) : a; + const normalizedPath = path.normalize(filePath); + const key = this.makeKey(normalizedPath, oldContent, newContent); + + // Check if a diff view with the same content already exists + if (this.hasExistingDiff(normalizedPath, oldContent, newContent)) { + const last = this.recentlyShown.get(key) || 0; + const now = Date.now(); + if (now - last < DiffManager.DEDUPE_WINDOW_MS) { + // Within dedupe window: ignore the duplicate request entirely + this.log( + `Duplicate showDiff suppressed for ${filePath} (within ${DiffManager.DEDUPE_WINDOW_MS}ms)`, + ); + return; + } + // Outside the dedupe window: softly focus the existing diff + await this.focusExistingDiff(normalizedPath); + this.recentlyShown.set(key, now); + return; + } + // Left side: old content using qwen-diff scheme + const leftDocUri = vscode.Uri.from({ + scheme: DIFF_SCHEME, + path: normalizedPath, + query: `old&rand=${Math.random()}`, + }); + this.diffContentProvider.setContent(leftDocUri, oldContent); + + // Right side: new content using qwen-diff scheme const rightDocUri = vscode.Uri.from({ scheme: DIFF_SCHEME, - path: filePath, - // cache busting - query: `rand=${Math.random()}`, + path: normalizedPath, + query: `new&rand=${Math.random()}`, }); this.diffContentProvider.setContent(rightDocUri, newContent); this.addDiffDocument(rightDocUri, { - originalFilePath: filePath, + originalFilePath: normalizedPath, + oldContent, newContent, + leftDocUri, rightDocUri, }); - const diffTitle = `${path.basename(filePath)} ↔ Modified`; + const diffTitle = `${path.basename(normalizedPath)} (Before ↔ After)`; await vscode.commands.executeCommand( 'setContext', 'qwen.diff.isVisible', true, ); - let leftDocUri; - try { - await vscode.workspace.fs.stat(fileUri); - leftDocUri = fileUri; - } catch { - // We need to provide an empty document to diff against. - // Using the 'untitled' scheme is one way to do this. - leftDocUri = vscode.Uri.from({ - scheme: 'untitled', - path: filePath, - }); + // Prefer opening the diff adjacent to the chat webview (so we don't + // replace content inside the locked webview group). We try the group to + // the left of the chat webview first; if none exists we fall back to + // ViewColumn.Beside. With the chat locked in the leftmost group, this + // fallback opens diffs to the right of the chat. + let targetViewColumn = findLeftGroupOfChatWebview(); + if (targetViewColumn === undefined) { + // If there is no left neighbor, create one to satisfy the requirement of + // opening diffs to the left of the chat webview. + targetViewColumn = await ensureLeftGroupOfChatWebview(); } await vscode.commands.executeCommand( @@ -120,6 +240,10 @@ export class DiffManager { rightDocUri, diffTitle, { + // If a left-of-webview group was found, target it explicitly so the + // diff opens there while keeping focus on the webview. Otherwise, use + // the default "open to side" behavior. + viewColumn: targetViewColumn ?? vscode.ViewColumn.Beside, preview: false, preserveFocus: true, }, @@ -127,16 +251,19 @@ export class DiffManager { await vscode.commands.executeCommand( 'workbench.action.files.setActiveEditorWriteableInSession', ); + + this.recentlyShown.set(key, Date.now()); } /** * Closes an open diff view for a specific file. */ async closeDiff(filePath: string, suppressNotification = false) { + const normalizedPath = path.normalize(filePath); let uriToClose: vscode.Uri | undefined; - for (const [uriString, diffInfo] of this.diffDocuments.entries()) { - if (diffInfo.originalFilePath === filePath) { - uriToClose = vscode.Uri.parse(uriString); + for (const [, diffInfo] of this.diffDocuments.entries()) { + if (diffInfo.originalFilePath === normalizedPath) { + uriToClose = diffInfo.rightDocUri; break; } } @@ -267,4 +394,40 @@ export class DiffManager { } } } + + /** Close all open qwen-diff editors */ + async closeAll(): Promise { + // Collect keys first to avoid iterator invalidation while closing + const uris = Array.from(this.diffDocuments.keys()).map((k) => + vscode.Uri.parse(k), + ); + for (const uri of uris) { + try { + await this.closeDiffEditor(uri); + } catch (err) { + this.log(`Failed to close diff editor: ${err}`); + } + } + } + + // Read the current content of file from the workspace; return empty string if not found + private async readOldContentFromFs(filePath: string): Promise { + try { + const fileUri = vscode.Uri.file(filePath); + const document = await vscode.workspace.openTextDocument(fileUri); + return document.getText(); + } catch { + return ''; + } + } + + private makeKey(filePath: string, oldContent: string, newContent: string) { + // Simple stable key; content could be large but kept transiently + return `${filePath}\u241F${oldContent}\u241F${newContent}`; + } + + /** Temporarily suppress opening diffs for a short duration. */ + suppressFor(durationMs: number): void { + this.suppressUntil = Date.now() + Math.max(0, durationMs); + } } diff --git a/packages/vscode-ide-companion/src/extension.test.ts b/packages/vscode-ide-companion/src/extension.test.ts index 2560881dc..31d5aa52f 100644 --- a/packages/vscode-ide-companion/src/extension.test.ts +++ b/packages/vscode-ide-companion/src/extension.test.ts @@ -40,6 +40,9 @@ vi.mock('vscode', () => ({ }, showTextDocument: vi.fn(), showWorkspaceFolderPick: vi.fn(), + registerWebviewPanelSerializer: vi.fn(() => ({ + dispose: vi.fn(), + })), }, workspace: { workspaceFolders: [], diff --git a/packages/vscode-ide-companion/src/extension.ts b/packages/vscode-ide-companion/src/extension.ts index 8e2344a9d..c27a7e9d9 100644 --- a/packages/vscode-ide-companion/src/extension.ts +++ b/packages/vscode-ide-companion/src/extension.ts @@ -14,6 +14,8 @@ import { IDE_DEFINITIONS, type IdeInfo, } from '@qwen-code/qwen-code-core/src/ide/detect-ide.js'; +import { WebViewProvider } from './webview/WebViewProvider.js'; +import { registerNewCommands } from './commands/index.js'; const CLI_IDE_COMPANION_IDENTIFIER = 'qwenlm.qwen-code-vscode-ide-companion'; const INFO_MESSAGE_SHOWN_KEY = 'qwenCodeInfoMessageShown'; @@ -31,6 +33,7 @@ const HIDE_INSTALLATION_GREETING_IDES: ReadonlySet = new Set([ let ideServer: IDEServer; let logger: vscode.OutputChannel; +let webViewProviders: WebViewProvider[] = []; // Track multiple chat tabs let log: (message: string) => void = () => {}; @@ -108,7 +111,75 @@ export async function activate(context: vscode.ExtensionContext) { checkForUpdates(context, log); const diffContentProvider = new DiffContentProvider(); - const diffManager = new DiffManager(log, diffContentProvider); + const diffManager = new DiffManager( + log, + diffContentProvider, + // Delay when any chat tab has a pending permission drawer + () => webViewProviders.some((p) => p.hasPendingPermission()), + // Suppress diffs when active mode is auto or yolo in any chat tab + () => { + const providers = webViewProviders.filter( + (p) => typeof p.shouldSuppressDiff === 'function', + ); + if (providers.length === 0) { + return false; + } + return providers.every((p) => p.shouldSuppressDiff()); + }, + ); + + // Helper function to create a new WebView provider instance + const createWebViewProvider = (): WebViewProvider => { + const provider = new WebViewProvider(context, context.extensionUri); + webViewProviders.push(provider); + return provider; + }; + + // Register WebView panel serializer for persistence across reloads + context.subscriptions.push( + vscode.window.registerWebviewPanelSerializer('qwenCode.chat', { + async deserializeWebviewPanel( + webviewPanel: vscode.WebviewPanel, + state: unknown, + ) { + console.log( + '[Extension] Deserializing WebView panel with state:', + state, + ); + + // Create a new provider for the restored panel + const provider = createWebViewProvider(); + console.log('[Extension] Provider created for deserialization'); + + // Restore state if available BEFORE restoring the panel + if (state && typeof state === 'object') { + console.log('[Extension] Restoring state:', state); + provider.restoreState( + state as { + conversationId: string | null; + agentInitialized: boolean; + }, + ); + } else { + console.log('[Extension] No state to restore or invalid state'); + } + + await provider.restorePanel(webviewPanel); + console.log('[Extension] Panel restore completed'); + + log('WebView panel restored from serialization'); + }, + }), + ); + + // Register newly added commands via commands module + registerNewCommands( + context, + log, + diffManager, + () => webViewProviders, + createWebViewProvider, + ); context.subscriptions.push( vscode.workspace.onDidCloseTextDocument((doc) => { @@ -120,17 +191,53 @@ export async function activate(context: vscode.ExtensionContext) { DIFF_SCHEME, diffContentProvider, ), - vscode.commands.registerCommand('qwen.diff.accept', (uri?: vscode.Uri) => { + (vscode.commands.registerCommand('qwen.diff.accept', (uri?: vscode.Uri) => { const docUri = uri ?? vscode.window.activeTextEditor?.document.uri; if (docUri && docUri.scheme === DIFF_SCHEME) { diffManager.acceptDiff(docUri); } + // If WebView is requesting permission, actively select an allow option (prefer once) + try { + for (const provider of webViewProviders) { + if (provider?.hasPendingPermission()) { + provider.respondToPendingPermission('allow'); + } + } + } catch (err) { + console.warn('[Extension] Auto-allow on diff.accept failed:', err); + } + console.log('[Extension] Diff accepted'); }), vscode.commands.registerCommand('qwen.diff.cancel', (uri?: vscode.Uri) => { const docUri = uri ?? vscode.window.activeTextEditor?.document.uri; if (docUri && docUri.scheme === DIFF_SCHEME) { diffManager.cancelDiff(docUri); } + // If WebView is requesting permission, actively select reject/cancel + try { + for (const provider of webViewProviders) { + if (provider?.hasPendingPermission()) { + provider.respondToPendingPermission('cancel'); + } + } + } catch (err) { + console.warn('[Extension] Auto-reject on diff.cancel failed:', err); + } + console.log('[Extension] Diff cancelled'); + })), + vscode.commands.registerCommand('qwen.diff.closeAll', async () => { + try { + await diffManager.closeAll(); + } catch (err) { + console.warn('[Extension] qwen.diff.closeAll failed:', err); + } + }), + vscode.commands.registerCommand('qwen.diff.suppressBriefly', async () => { + try { + diffManager.suppressFor(1200); + } catch (err) { + console.warn('[Extension] qwen.diff.suppressBriefly failed:', err); + } }), ); @@ -160,34 +267,49 @@ export async function activate(context: vscode.ExtensionContext) { vscode.workspace.onDidGrantWorkspaceTrust(() => { ideServer.syncEnvVars(); }), - vscode.commands.registerCommand('qwen-code.runQwenCode', async () => { - const workspaceFolders = vscode.workspace.workspaceFolders; - if (!workspaceFolders || workspaceFolders.length === 0) { - vscode.window.showInformationMessage( - 'No folder open. Please open a folder to run Qwen Code.', - ); - return; - } + vscode.commands.registerCommand( + 'qwen-code.runQwenCode', + async ( + location?: + | vscode.TerminalLocation + | vscode.TerminalEditorLocationOptions, + ) => { + const workspaceFolders = vscode.workspace.workspaceFolders; + if (!workspaceFolders || workspaceFolders.length === 0) { + vscode.window.showInformationMessage( + 'No folder open. Please open a folder to run Qwen Code.', + ); + return; + } - let selectedFolder: vscode.WorkspaceFolder | undefined; - if (workspaceFolders.length === 1) { - selectedFolder = workspaceFolders[0]; - } else { - selectedFolder = await vscode.window.showWorkspaceFolderPick({ - placeHolder: 'Select a folder to run Qwen Code in', - }); - } + let selectedFolder: vscode.WorkspaceFolder | undefined; + if (workspaceFolders.length === 1) { + selectedFolder = workspaceFolders[0]; + } else { + selectedFolder = await vscode.window.showWorkspaceFolderPick({ + placeHolder: 'Select a folder to run Qwen Code in', + }); + } - if (selectedFolder) { - const qwenCmd = 'qwen'; - const terminal = vscode.window.createTerminal({ - name: `Qwen Code (${selectedFolder.name})`, - cwd: selectedFolder.uri.fsPath, - }); - terminal.show(); - terminal.sendText(qwenCmd); - } - }), + if (selectedFolder) { + const cliEntry = vscode.Uri.joinPath( + context.extensionUri, + 'dist', + 'qwen-cli', + 'cli.js', + ).fsPath; + const quote = (s: string) => `"${s.replaceAll('"', '\\"')}"`; + const qwenCmd = `${quote(process.execPath)} ${quote(cliEntry)}`; + const terminal = vscode.window.createTerminal({ + name: `Qwen Code (${selectedFolder.name})`, + cwd: selectedFolder.uri.fsPath, + location, + }); + terminal.show(); + terminal.sendText(qwenCmd); + } + }, + ), vscode.commands.registerCommand('qwen-code.showNotices', async () => { const noticePath = vscode.Uri.joinPath( context.extensionUri, @@ -204,6 +326,11 @@ export async function deactivate(): Promise { if (ideServer) { await ideServer.stop(); } + // Dispose all WebView providers + webViewProviders.forEach((provider) => { + provider.dispose(); + }); + webViewProviders = []; } catch (err) { const message = err instanceof Error ? err.message : String(err); log(`Failed to stop IDE server during deactivation: ${message}`); diff --git a/packages/vscode-ide-companion/src/ide-server.test.ts b/packages/vscode-ide-companion/src/ide-server.test.ts index 1293f487e..8268efe69 100644 --- a/packages/vscode-ide-companion/src/ide-server.test.ts +++ b/packages/vscode-ide-companion/src/ide-server.test.ts @@ -27,13 +27,14 @@ vi.mock('node:fs/promises', () => ({ writeFile: vi.fn(() => Promise.resolve(undefined)), unlink: vi.fn(() => Promise.resolve(undefined)), chmod: vi.fn(() => Promise.resolve(undefined)), + mkdir: vi.fn(() => Promise.resolve(undefined)), })); vi.mock('node:os', async (importOriginal) => { const actual = await importOriginal(); return { ...actual, - tmpdir: vi.fn(() => '/tmp'), + homedir: vi.fn(() => '/home/test'), }; }); @@ -128,30 +129,24 @@ describe('IDEServer', () => { ); const port = getPortFromMock(replaceMock); - const expectedPortFile = path.join( - '/tmp', - `qwen-code-ide-server-${port}.json`, - ); - const expectedPpidPortFile = path.join( - '/tmp', - `qwen-code-ide-server-${process.ppid}.json`, + const expectedLockFile = path.join( + '/home/test', + '.qwen', + 'ide', + `${port}.lock`, ); const expectedContent = JSON.stringify({ port: parseInt(port, 10), workspacePath: expectedWorkspacePaths, ppid: process.ppid, authToken: 'test-auth-token', + ideName: 'VS Code', }); expect(fs.writeFile).toHaveBeenCalledWith( - expectedPortFile, + expectedLockFile, expectedContent, ); - expect(fs.writeFile).toHaveBeenCalledWith( - expectedPpidPortFile, - expectedContent, - ); - expect(fs.chmod).toHaveBeenCalledWith(expectedPortFile, 0o600); - expect(fs.chmod).toHaveBeenCalledWith(expectedPpidPortFile, 0o600); + expect(fs.chmod).toHaveBeenCalledWith(expectedLockFile, 0o600); }); it('should set a single folder path', async () => { @@ -166,30 +161,24 @@ describe('IDEServer', () => { ); const port = getPortFromMock(replaceMock); - const expectedPortFile = path.join( - '/tmp', - `qwen-code-ide-server-${port}.json`, - ); - const expectedPpidPortFile = path.join( - '/tmp', - `qwen-code-ide-server-${process.ppid}.json`, + const expectedLockFile = path.join( + '/home/test', + '.qwen', + 'ide', + `${port}.lock`, ); const expectedContent = JSON.stringify({ port: parseInt(port, 10), workspacePath: '/foo/bar', ppid: process.ppid, authToken: 'test-auth-token', + ideName: 'VS Code', }); expect(fs.writeFile).toHaveBeenCalledWith( - expectedPortFile, + expectedLockFile, expectedContent, ); - expect(fs.writeFile).toHaveBeenCalledWith( - expectedPpidPortFile, - expectedContent, - ); - expect(fs.chmod).toHaveBeenCalledWith(expectedPortFile, 0o600); - expect(fs.chmod).toHaveBeenCalledWith(expectedPpidPortFile, 0o600); + expect(fs.chmod).toHaveBeenCalledWith(expectedLockFile, 0o600); }); it('should set an empty string if no folders are open', async () => { @@ -204,30 +193,24 @@ describe('IDEServer', () => { ); const port = getPortFromMock(replaceMock); - const expectedPortFile = path.join( - '/tmp', - `qwen-code-ide-server-${port}.json`, - ); - const expectedPpidPortFile = path.join( - '/tmp', - `qwen-code-ide-server-${process.ppid}.json`, + const expectedLockFile = path.join( + '/home/test', + '.qwen', + 'ide', + `${port}.lock`, ); const expectedContent = JSON.stringify({ port: parseInt(port, 10), workspacePath: '', ppid: process.ppid, authToken: 'test-auth-token', + ideName: 'VS Code', }); expect(fs.writeFile).toHaveBeenCalledWith( - expectedPortFile, + expectedLockFile, expectedContent, ); - expect(fs.writeFile).toHaveBeenCalledWith( - expectedPpidPortFile, - expectedContent, - ); - expect(fs.chmod).toHaveBeenCalledWith(expectedPortFile, 0o600); - expect(fs.chmod).toHaveBeenCalledWith(expectedPpidPortFile, 0o600); + expect(fs.chmod).toHaveBeenCalledWith(expectedLockFile, 0o600); }); it('should update the path when workspace folders change', async () => { @@ -256,30 +239,24 @@ describe('IDEServer', () => { ); const port = getPortFromMock(replaceMock); - const expectedPortFile = path.join( - '/tmp', - `qwen-code-ide-server-${port}.json`, - ); - const expectedPpidPortFile = path.join( - '/tmp', - `qwen-code-ide-server-${process.ppid}.json`, + const expectedLockFile = path.join( + '/home/test', + '.qwen', + 'ide', + `${port}.lock`, ); const expectedContent = JSON.stringify({ port: parseInt(port, 10), workspacePath: expectedWorkspacePaths, ppid: process.ppid, authToken: 'test-auth-token', + ideName: 'VS Code', }); expect(fs.writeFile).toHaveBeenCalledWith( - expectedPortFile, + expectedLockFile, expectedContent, ); - expect(fs.writeFile).toHaveBeenCalledWith( - expectedPpidPortFile, - expectedContent, - ); - expect(fs.chmod).toHaveBeenCalledWith(expectedPortFile, 0o600); - expect(fs.chmod).toHaveBeenCalledWith(expectedPpidPortFile, 0o600); + expect(fs.chmod).toHaveBeenCalledWith(expectedLockFile, 0o600); // Simulate removing a folder vscodeMock.workspace.workspaceFolders = [{ uri: { fsPath: '/baz/qux' } }]; @@ -294,36 +271,26 @@ describe('IDEServer', () => { workspacePath: '/baz/qux', ppid: process.ppid, authToken: 'test-auth-token', + ideName: 'VS Code', }); expect(fs.writeFile).toHaveBeenCalledWith( - expectedPortFile, + expectedLockFile, expectedContent2, ); - expect(fs.writeFile).toHaveBeenCalledWith( - expectedPpidPortFile, - expectedContent2, - ); - expect(fs.chmod).toHaveBeenCalledWith(expectedPortFile, 0o600); - expect(fs.chmod).toHaveBeenCalledWith(expectedPpidPortFile, 0o600); + expect(fs.chmod).toHaveBeenCalledWith(expectedLockFile, 0o600); }); - it('should clear env vars and delete port file on stop', async () => { + it('should clear env vars and delete lock file on stop', async () => { await ideServer.start(mockContext); const replaceMock = mockContext.environmentVariableCollection.replace; const port = getPortFromMock(replaceMock); - const portFile = path.join('/tmp', `qwen-code-ide-server-${port}.json`); - const ppidPortFile = path.join( - '/tmp', - `qwen-code-ide-server-${process.ppid}.json`, - ); - expect(fs.writeFile).toHaveBeenCalledWith(portFile, expect.any(String)); - expect(fs.writeFile).toHaveBeenCalledWith(ppidPortFile, expect.any(String)); + const lockFile = path.join('/home/test', '.qwen', 'ide', `${port}.lock`); + expect(fs.writeFile).toHaveBeenCalledWith(lockFile, expect.any(String)); await ideServer.stop(); expect(mockContext.environmentVariableCollection.clear).toHaveBeenCalled(); - expect(fs.unlink).toHaveBeenCalledWith(portFile); - expect(fs.unlink).toHaveBeenCalledWith(ppidPortFile); + expect(fs.unlink).toHaveBeenCalledWith(lockFile); }); it.skipIf(process.platform !== 'win32')( @@ -344,30 +311,24 @@ describe('IDEServer', () => { ); const port = getPortFromMock(replaceMock); - const expectedPortFile = path.join( - '/tmp', - `qwen-code-ide-server-${port}.json`, - ); - const expectedPpidPortFile = path.join( - '/tmp', - `qwen-code-ide-server-${process.ppid}.json`, + const expectedLockFile = path.join( + '/home/test', + '.qwen', + 'ide', + `${port}.lock`, ); const expectedContent = JSON.stringify({ port: parseInt(port, 10), workspacePath: expectedWorkspacePaths, ppid: process.ppid, authToken: 'test-auth-token', + ideName: 'VS Code', }); expect(fs.writeFile).toHaveBeenCalledWith( - expectedPortFile, + expectedLockFile, expectedContent, ); - expect(fs.writeFile).toHaveBeenCalledWith( - expectedPpidPortFile, - expectedContent, - ); - expect(fs.chmod).toHaveBeenCalledWith(expectedPortFile, 0o600); - expect(fs.chmod).toHaveBeenCalledWith(expectedPpidPortFile, 0o600); + expect(fs.chmod).toHaveBeenCalledWith(expectedLockFile, 0o600); }, ); @@ -379,7 +340,7 @@ describe('IDEServer', () => { port = (ideServer as unknown as { port: number }).port; }); - it('should allow request without auth token for backwards compatibility', async () => { + it('should reject request without auth token', async () => { const response = await fetch(`http://localhost:${port}/mcp`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, @@ -390,7 +351,9 @@ describe('IDEServer', () => { id: 1, }), }); - expect(response.status).not.toBe(401); + expect(response.status).toBe(401); + const body = await response.text(); + expect(body).toBe('Unauthorized'); }); it('should allow request with valid auth token', async () => { @@ -550,6 +513,7 @@ describe('IDEServer HTTP endpoints', () => { headers: { Host: `localhost:${port}`, 'Content-Type': 'application/json', + Authorization: 'Bearer test-auth-token', }, }, JSON.stringify({ jsonrpc: '2.0', method: 'initialize' }), diff --git a/packages/vscode-ide-companion/src/ide-server.ts b/packages/vscode-ide-companion/src/ide-server.ts index e67dfa815..f7712399d 100644 --- a/packages/vscode-ide-companion/src/ide-server.ts +++ b/packages/vscode-ide-companion/src/ide-server.ts @@ -10,6 +10,7 @@ import { IdeContextNotificationSchema, OpenDiffRequestSchema, } from '@qwen-code/qwen-code-core/src/ide/types.js'; +import { detectIdeFromEnv } from '@qwen-code/qwen-code-core/src/ide/detect-ide.js'; import { isInitializeRequest } from '@modelcontextprotocol/sdk/types.js'; import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'; import { StreamableHTTPServerTransport } from '@modelcontextprotocol/sdk/server/streamableHttp.js'; @@ -38,12 +39,24 @@ class CORSError extends Error { const MCP_SESSION_ID_HEADER = 'mcp-session-id'; const IDE_SERVER_PORT_ENV_VAR = 'QWEN_CODE_IDE_SERVER_PORT'; const IDE_WORKSPACE_PATH_ENV_VAR = 'QWEN_CODE_IDE_WORKSPACE_PATH'; +const QWEN_DIR = '.qwen'; +const IDE_DIR = 'ide'; + +async function getGlobalIdeDir(): Promise { + const homeDir = os.homedir(); + // Prefer home dir, but fall back to tmpdir if unavailable (matches core Storage behavior). + const baseDir = homeDir + ? path.join(homeDir, QWEN_DIR) + : path.join(os.tmpdir(), QWEN_DIR); + const ideDir = path.join(baseDir, IDE_DIR); + await fs.mkdir(ideDir, { recursive: true }); + return ideDir; +} interface WritePortAndWorkspaceArgs { context: vscode.ExtensionContext; port: number; - portFile: string; - ppidPortFile: string; + lockFile: string; authToken: string; log: (message: string) => void; } @@ -51,8 +64,7 @@ interface WritePortAndWorkspaceArgs { async function writePortAndWorkspace({ context, port, - portFile, - ppidPortFile, + lockFile, authToken, log, }: WritePortAndWorkspaceArgs): Promise { @@ -71,26 +83,24 @@ async function writePortAndWorkspace({ workspacePath, ); + const ideInfo = detectIdeFromEnv(); const content = JSON.stringify({ port, workspacePath, ppid: process.ppid, authToken, + ideName: ideInfo.displayName, }); - log(`Writing port file to: ${portFile}`); - log(`Writing ppid port file to: ${ppidPortFile}`); + log(`Writing IDE lock file to: ${lockFile}`); try { - await Promise.all([ - fs.writeFile(portFile, content).then(() => fs.chmod(portFile, 0o600)), - fs - .writeFile(ppidPortFile, content) - .then(() => fs.chmod(ppidPortFile, 0o600)), - ]); + await fs.mkdir(path.dirname(lockFile), { recursive: true }); + await fs.writeFile(lockFile, content); + await fs.chmod(lockFile, 0o600); } catch (err) { const message = err instanceof Error ? err.message : String(err); - log(`Failed to write port to file: ${message}`); + log(`Failed to write IDE lock file: ${message}`); } } @@ -121,8 +131,7 @@ export class IDEServer { private server: HTTPServer | undefined; private context: vscode.ExtensionContext | undefined; private log: (message: string) => void; - private portFile: string | undefined; - private ppidPortFile: string | undefined; + private lockFile: string | undefined; private port: number | undefined; private authToken: string | undefined; private transports: { [sessionId: string]: StreamableHTTPServerTransport } = @@ -164,6 +173,7 @@ export class IDEServer { const allowedHosts = [ `localhost:${this.port}`, `127.0.0.1:${this.port}`, + `host.docker.internal:${this.port}`, // Add Docker support ]; if (!allowedHosts.includes(host)) { return res.status(403).json({ error: 'Invalid Host header' }); @@ -173,19 +183,24 @@ export class IDEServer { app.use((req, res, next) => { const authHeader = req.headers.authorization; - if (authHeader) { - const parts = authHeader.split(' '); - if (parts.length !== 2 || parts[0] !== 'Bearer') { - this.log('Malformed Authorization header. Rejecting request.'); - res.status(401).send('Unauthorized'); - return; - } - const token = parts[1]; - if (token !== this.authToken) { - this.log('Invalid auth token provided. Rejecting request.'); - res.status(401).send('Unauthorized'); - return; - } + if (!authHeader) { + this.log('Missing Authorization header. Rejecting request.'); + res.status(401).send('Unauthorized'); + return; + } + + const parts = authHeader.split(' '); + if (parts.length !== 2 || parts[0] !== 'Bearer') { + this.log('Malformed Authorization header. Rejecting request.'); + res.status(401).send('Unauthorized'); + return; + } + + const token = parts[1]; + if (token !== this.authToken) { + this.log('Invalid auth token provided. Rejecting request.'); + res.status(401).send('Unauthorized'); + return; } next(); }); @@ -326,22 +341,21 @@ export class IDEServer { const address = (this.server as HTTPServer).address(); if (address && typeof address !== 'string') { this.port = address.port; - this.portFile = path.join( - os.tmpdir(), - `qwen-code-ide-server-${this.port}.json`, - ); - this.ppidPortFile = path.join( - os.tmpdir(), - `qwen-code-ide-server-${process.ppid}.json`, - ); + try { + const ideDir = await getGlobalIdeDir(); + // Name the lock file by port to support multiple server instances. + this.lockFile = path.join(ideDir, `${this.port}.lock`); + } catch (err) { + const message = err instanceof Error ? err.message : String(err); + this.log(`Failed to determine IDE lock directory: ${message}`); + } this.log(`IDE server listening on http://127.0.0.1:${this.port}`); - if (this.authToken) { + if (this.authToken && this.lockFile) { await writePortAndWorkspace({ context, port: this.port, - portFile: this.portFile, - ppidPortFile: this.ppidPortFile, + lockFile: this.lockFile, authToken: this.authToken, log: this.log, }); @@ -370,15 +384,13 @@ export class IDEServer { this.context && this.server && this.port && - this.portFile && - this.ppidPortFile && + this.lockFile && this.authToken ) { await writePortAndWorkspace({ context: this.context, port: this.port, - portFile: this.portFile, - ppidPortFile: this.ppidPortFile, + lockFile: this.lockFile, authToken: this.authToken, log: this.log, }); @@ -404,16 +416,9 @@ export class IDEServer { if (this.context) { this.context.environmentVariableCollection.clear(); } - if (this.portFile) { + if (this.lockFile) { try { - await fs.unlink(this.portFile); - } catch (_err) { - // Ignore errors if the file doesn't exist. - } - } - if (this.ppidPortFile) { - try { - await fs.unlink(this.ppidPortFile); + await fs.unlink(this.lockFile); } catch (_err) { // Ignore errors if the file doesn't exist. } @@ -437,6 +442,7 @@ const createMcpServer = (diffManager: DiffManager) => { inputSchema: OpenDiffRequestSchema.shape, }, async ({ filePath, newContent }: z.infer) => { + // Minimal call site: only pass newContent; DiffManager reads old content itself await diffManager.showDiff(filePath, newContent); return { content: [] }; }, diff --git a/packages/vscode-ide-companion/src/open-files-manager.test.ts b/packages/vscode-ide-companion/src/open-files-manager.test.ts index 0b1ada822..74d18ffa0 100644 --- a/packages/vscode-ide-companion/src/open-files-manager.test.ts +++ b/packages/vscode-ide-companion/src/open-files-manager.test.ts @@ -414,7 +414,7 @@ describe('OpenFilesManager', () => { await vi.advanceTimersByTimeAsync(100); file1 = manager.state.workspaceState!.openFiles!.find( - (f) => f.path === '/test/file1.txt', + (f: { path: string }) => f.path === '/test/file1.txt', )!; const file2 = manager.state.workspaceState!.openFiles![0]; diff --git a/packages/vscode-ide-companion/src/services/acpConnection.ts b/packages/vscode-ide-companion/src/services/acpConnection.ts new file mode 100644 index 000000000..4b2c4028b --- /dev/null +++ b/packages/vscode-ide-companion/src/services/acpConnection.ts @@ -0,0 +1,412 @@ +/** + * @license + * Copyright 2025 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + */ + +import { JSONRPC_VERSION } from '../types/acpTypes.js'; +import type { + AcpMessage, + AcpPermissionRequest, + AcpResponse, + AcpSessionUpdate, + AuthenticateUpdateNotification, +} from '../types/acpTypes.js'; +import type { ApprovalModeValue } from '../types/approvalModeValueTypes.js'; +import type { ChildProcess, SpawnOptions } from 'child_process'; +import { spawn } from 'child_process'; +import type { + PendingRequest, + AcpConnectionCallbacks, +} from '../types/connectionTypes.js'; +import { AcpMessageHandler } from './acpMessageHandler.js'; +import { AcpSessionManager } from './acpSessionManager.js'; +import * as fs from 'node:fs'; + +/** + * ACP Connection Handler for VSCode Extension + * + * This class implements the client side of the ACP (Agent Communication Protocol). + */ +export class AcpConnection { + private child: ChildProcess | null = null; + private pendingRequests = new Map>(); + private nextRequestId = { value: 0 }; + // Remember the working dir provided at connect() so later ACP calls + // that require cwd (e.g. session/list) can include it. + private workingDir: string = process.cwd(); + + private messageHandler: AcpMessageHandler; + private sessionManager: AcpSessionManager; + + onSessionUpdate: (data: AcpSessionUpdate) => void = () => {}; + onPermissionRequest: (data: AcpPermissionRequest) => Promise<{ + optionId: string; + }> = () => Promise.resolve({ optionId: 'allow' }); + onAuthenticateUpdate: (data: AuthenticateUpdateNotification) => void = + () => {}; + onEndTurn: () => void = () => {}; + // Called after successful initialize() with the initialize result + onInitialized: (init: unknown) => void = () => {}; + + constructor() { + this.messageHandler = new AcpMessageHandler(); + this.sessionManager = new AcpSessionManager(); + } + + /** + * Connect to Qwen ACP + * + * @param cliEntryPath - Path to the bundled CLI entrypoint (cli.js) + * @param workingDir - Working directory + * @param extraArgs - Extra command line arguments + */ + async connect( + cliEntryPath: string, + workingDir: string = process.cwd(), + extraArgs: string[] = [], + ): Promise { + if (this.child) { + this.disconnect(); + } + + this.workingDir = workingDir; + + const env = { ...process.env }; + + // If proxy is configured in extraArgs, also set it as environment variable + // This ensures token refresh requests also use the proxy + const proxyArg = extraArgs.find( + (arg, i) => arg === '--proxy' && i + 1 < extraArgs.length, + ); + if (proxyArg) { + const proxyIndex = extraArgs.indexOf('--proxy'); + const proxyUrl = extraArgs[proxyIndex + 1]; + console.log('[ACP] Setting proxy environment variables:', proxyUrl); + + env['HTTP_PROXY'] = proxyUrl; + env['HTTPS_PROXY'] = proxyUrl; + env['http_proxy'] = proxyUrl; + env['https_proxy'] = proxyUrl; + } + + // Always run the bundled CLI using the VS Code extension host's Node runtime. + // This avoids PATH/NVM/global install problems and ensures deterministic behavior. + const spawnCommand: string = process.execPath; + const spawnArgs: string[] = [ + cliEntryPath, + '--experimental-acp', + '--channel=VSCode', + ...extraArgs, + ]; + + if (!fs.existsSync(cliEntryPath)) { + throw new Error( + `Bundled Qwen CLI entry not found at ${cliEntryPath}. The extension may not have been packaged correctly.`, + ); + } + + console.log('[ACP] Spawning command:', spawnCommand, spawnArgs.join(' ')); + + const options: SpawnOptions = { + cwd: workingDir, + stdio: ['pipe', 'pipe', 'pipe'], + env, + // We spawn node directly; no shell needed (and shell quoting can break paths). + shell: false, + }; + + this.child = spawn(spawnCommand, spawnArgs, options); + await this.setupChildProcessHandlers(); + } + + /** + * Set up child process handlers + */ + private async setupChildProcessHandlers(): Promise { + let spawnError: Error | null = null; + + this.child!.stderr?.on('data', (data) => { + const message = data.toString(); + if ( + message.toLowerCase().includes('error') && + !message.includes('Loaded cached') + ) { + console.error(`[ACP qwen]:`, message); + } else { + console.log(`[ACP qwen]:`, message); + } + }); + + this.child!.on('error', (error) => { + spawnError = error; + }); + + this.child!.on('exit', (code, signal) => { + console.error( + `[ACP qwen] Process exited with code: ${code}, signal: ${signal}`, + ); + }); + + // Wait for process to start + await new Promise((resolve) => setTimeout(resolve, 1000)); + + if (spawnError) { + throw spawnError; + } + + if (!this.child || this.child.killed) { + throw new Error(`Qwen ACP process failed to start`); + } + + // Handle messages from ACP server + let buffer = ''; + this.child.stdout?.on('data', (data) => { + buffer += data.toString(); + const lines = buffer.split('\n'); + buffer = lines.pop() || ''; + + for (const line of lines) { + if (line.trim()) { + try { + const message = JSON.parse(line) as AcpMessage; + console.log( + '[ACP] <<< Received message:', + JSON.stringify(message).substring(0, 500 * 3), + ); + this.handleMessage(message); + } catch (_error) { + // Ignore non-JSON lines + console.log( + '[ACP] <<< Non-JSON line (ignored):', + line.substring(0, 200), + ); + } + } + } + }); + + // Initialize protocol + const res = await this.sessionManager.initialize( + this.child, + this.pendingRequests, + this.nextRequestId, + ); + + console.log('[ACP] Initialization response:', res); + try { + this.onInitialized(res); + } catch (err) { + console.warn('[ACP] onInitialized callback error:', err); + } + } + + /** + * Handle received messages + * + * @param message - ACP message + */ + private handleMessage(message: AcpMessage): void { + const callbacks: AcpConnectionCallbacks = { + onSessionUpdate: this.onSessionUpdate, + onPermissionRequest: this.onPermissionRequest, + onAuthenticateUpdate: this.onAuthenticateUpdate, + onEndTurn: this.onEndTurn, + }; + + // Handle message + if ('method' in message) { + // Request or notification + this.messageHandler + .handleIncomingRequest(message, callbacks) + .then((result) => { + if ('id' in message && typeof message.id === 'number') { + this.messageHandler.sendResponseMessage(this.child, { + jsonrpc: JSONRPC_VERSION, + id: message.id, + result, + }); + } + }) + .catch((error) => { + if ('id' in message && typeof message.id === 'number') { + this.messageHandler.sendResponseMessage(this.child, { + jsonrpc: JSONRPC_VERSION, + id: message.id, + error: { + code: -32603, + message: error instanceof Error ? error.message : String(error), + }, + }); + } + }); + } else { + // Response + this.messageHandler.handleMessage( + message, + this.pendingRequests, + callbacks, + ); + } + } + + /** + * Authenticate + * + * @param methodId - Authentication method ID + * @returns Authentication response + */ + async authenticate(methodId?: string): Promise { + return this.sessionManager.authenticate( + methodId, + this.child, + this.pendingRequests, + this.nextRequestId, + ); + } + + /** + * Create new session + * + * @param cwd - Working directory + * @returns New session response + */ + async newSession(cwd: string = process.cwd()): Promise { + return this.sessionManager.newSession( + cwd, + this.child, + this.pendingRequests, + this.nextRequestId, + ); + } + + /** + * Send prompt message + * + * @param prompt - Prompt content + * @returns Response + */ + async sendPrompt(prompt: string): Promise { + return this.sessionManager.sendPrompt( + prompt, + this.child, + this.pendingRequests, + this.nextRequestId, + ); + } + + /** + * Load existing session + * + * @param sessionId - Session ID + * @returns Load response + */ + async loadSession( + sessionId: string, + cwdOverride?: string, + ): Promise { + return this.sessionManager.loadSession( + sessionId, + this.child, + this.pendingRequests, + this.nextRequestId, + cwdOverride || this.workingDir, + ); + } + + /** + * Get session list + * + * @returns Session list response + */ + async listSessions(options?: { + cursor?: number; + size?: number; + }): Promise { + return this.sessionManager.listSessions( + this.child, + this.pendingRequests, + this.nextRequestId, + this.workingDir, + options, + ); + } + + /** + * Switch to specified session + * + * @param sessionId - Session ID + * @returns Switch response + */ + async switchSession(sessionId: string): Promise { + return this.sessionManager.switchSession(sessionId, this.nextRequestId); + } + + /** + * Cancel current session prompt generation + */ + async cancelSession(): Promise { + await this.sessionManager.cancelSession(this.child); + } + + /** + * Save current session + * + * @param tag - Save tag + * @returns Save response + */ + async saveSession(tag: string): Promise { + return this.sessionManager.saveSession( + tag, + this.child, + this.pendingRequests, + this.nextRequestId, + ); + } + + /** + * Set approval mode + */ + async setMode(modeId: ApprovalModeValue): Promise { + return this.sessionManager.setMode( + modeId, + this.child, + this.pendingRequests, + this.nextRequestId, + ); + } + + /** + * Disconnect + */ + disconnect(): void { + if (this.child) { + this.child.kill(); + this.child = null; + } + + this.pendingRequests.clear(); + this.sessionManager.reset(); + } + + /** + * Check if connected + */ + get isConnected(): boolean { + return this.child !== null && !this.child.killed; + } + + /** + * Check if there is an active session + */ + get hasActiveSession(): boolean { + return this.sessionManager.getCurrentSessionId() !== null; + } + + /** + * Get current session ID + */ + get currentSessionId(): string | null { + return this.sessionManager.getCurrentSessionId(); + } +} diff --git a/packages/vscode-ide-companion/src/services/acpFileHandler.ts b/packages/vscode-ide-companion/src/services/acpFileHandler.ts new file mode 100644 index 000000000..8dce3c7b7 --- /dev/null +++ b/packages/vscode-ide-companion/src/services/acpFileHandler.ts @@ -0,0 +1,111 @@ +/** + * @license + * Copyright 2025 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + */ + +/** + * ACP File Operation Handler + * + * Responsible for handling file read and write operations in the ACP protocol + */ + +import { promises as fs } from 'fs'; +import * as path from 'path'; + +/** + * ACP File Operation Handler Class + * Provides file read and write functionality according to ACP protocol specifications + */ +export class AcpFileHandler { + /** + * Handle read text file request + * + * @param params - File read parameters + * @param params.path - File path + * @param params.sessionId - Session ID + * @param params.line - Starting line number (optional) + * @param params.limit - Read line limit (optional) + * @returns File content + * @throws Error when file reading fails + */ + async handleReadTextFile(params: { + path: string; + sessionId: string; + line: number | null; + limit: number | null; + }): Promise<{ content: string }> { + console.log(`[ACP] fs/read_text_file request received for: ${params.path}`); + console.log(`[ACP] Parameters:`, { + line: params.line, + limit: params.limit, + sessionId: params.sessionId, + }); + + try { + const content = await fs.readFile(params.path, 'utf-8'); + console.log( + `[ACP] Successfully read file: ${params.path} (${content.length} bytes)`, + ); + + // Handle line offset and limit + if (params.line !== null || params.limit !== null) { + const lines = content.split('\n'); + const startLine = params.line || 0; + const endLine = params.limit ? startLine + params.limit : lines.length; + const selectedLines = lines.slice(startLine, endLine); + const result = { content: selectedLines.join('\n') }; + console.log(`[ACP] Returning ${selectedLines.length} lines`); + return result; + } + + const result = { content }; + console.log(`[ACP] Returning full file content`); + return result; + } catch (error) { + const errorMsg = error instanceof Error ? error.message : String(error); + console.error(`[ACP] Failed to read file ${params.path}:`, errorMsg); + + throw new Error(`Failed to read file '${params.path}': ${errorMsg}`); + } + } + + /** + * Handle write text file request + * + * @param params - File write parameters + * @param params.path - File path + * @param params.content - File content + * @param params.sessionId - Session ID + * @returns null indicates success + * @throws Error when file writing fails + */ + async handleWriteTextFile(params: { + path: string; + content: string; + sessionId: string; + }): Promise { + console.log( + `[ACP] fs/write_text_file request received for: ${params.path}`, + ); + console.log(`[ACP] Content size: ${params.content.length} bytes`); + + try { + // Ensure directory exists + const dirName = path.dirname(params.path); + console.log(`[ACP] Ensuring directory exists: ${dirName}`); + await fs.mkdir(dirName, { recursive: true }); + + // Write file + await fs.writeFile(params.path, params.content, 'utf-8'); + + console.log(`[ACP] Successfully wrote file: ${params.path}`); + return null; + } catch (error) { + const errorMsg = error instanceof Error ? error.message : String(error); + console.error(`[ACP] Failed to write file ${params.path}:`, errorMsg); + + throw new Error(`Failed to write file '${params.path}': ${errorMsg}`); + } + } +} diff --git a/packages/vscode-ide-companion/src/services/acpMessageHandler.ts b/packages/vscode-ide-companion/src/services/acpMessageHandler.ts new file mode 100644 index 000000000..8766fdf31 --- /dev/null +++ b/packages/vscode-ide-companion/src/services/acpMessageHandler.ts @@ -0,0 +1,252 @@ +/** + * @license + * Copyright 2025 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + */ + +/** + * ACP Message Handler + * + * Responsible for receiving, parsing, and distributing messages in the ACP protocol + */ + +import type { + AcpMessage, + AcpRequest, + AcpNotification, + AcpResponse, + AcpSessionUpdate, + AcpPermissionRequest, + AuthenticateUpdateNotification, +} from '../types/acpTypes.js'; +import { CLIENT_METHODS } from '../constants/acpSchema.js'; +import type { + PendingRequest, + AcpConnectionCallbacks, +} from '../types/connectionTypes.js'; +import { AcpFileHandler } from '../services/acpFileHandler.js'; +import type { ChildProcess } from 'child_process'; + +/** + * ACP Message Handler Class + * Responsible for receiving, parsing, and processing messages + */ +export class AcpMessageHandler { + private fileHandler: AcpFileHandler; + + constructor() { + this.fileHandler = new AcpFileHandler(); + } + + /** + * Send response message to child process + * + * @param child - Child process instance + * @param response - Response message + */ + sendResponseMessage(child: ChildProcess | null, response: AcpResponse): void { + if (child?.stdin) { + const jsonString = JSON.stringify(response); + const lineEnding = process.platform === 'win32' ? '\r\n' : '\n'; + child.stdin.write(jsonString + lineEnding); + } + } + + /** + * Handle received messages + * + * @param message - ACP message + * @param pendingRequests - Pending requests map + * @param callbacks - Callback functions collection + */ + handleMessage( + message: AcpMessage, + pendingRequests: Map>, + callbacks: AcpConnectionCallbacks, + ): void { + try { + if ('method' in message) { + // Request or notification + this.handleIncomingRequest(message, callbacks).catch(() => {}); + } else if ( + 'id' in message && + typeof message.id === 'number' && + pendingRequests.has(message.id) + ) { + // Response + this.handleResponse(message, pendingRequests, callbacks); + } + } catch (error) { + console.error('[ACP] Error handling message:', error); + } + } + + /** + * Handle response message + * + * @param message - Response message + * @param pendingRequests - Pending requests map + * @param callbacks - Callback functions collection + */ + private handleResponse( + message: AcpMessage, + pendingRequests: Map>, + callbacks: AcpConnectionCallbacks, + ): void { + if (!('id' in message) || typeof message.id !== 'number') { + return; + } + + const pendingRequest = pendingRequests.get(message.id); + if (!pendingRequest) { + return; + } + + const { resolve, reject, method } = pendingRequest; + pendingRequests.delete(message.id); + + if ('result' in message) { + console.log( + `[ACP] Response for ${method}:`, + // JSON.stringify(message.result).substring(0, 200), + message.result, + ); + + if (message.result && typeof message.result === 'object') { + const stopReasonValue = + (message.result as { stopReason?: unknown }).stopReason ?? + (message.result as { stop_reason?: unknown }).stop_reason; + if (typeof stopReasonValue === 'string') { + callbacks.onEndTurn(stopReasonValue); + } else if ( + 'stopReason' in message.result || + 'stop_reason' in message.result + ) { + // stop_reason present but not a string (e.g., null) -> still emit + callbacks.onEndTurn(); + } + } + resolve(message.result); + } else if ('error' in message) { + const errorCode = message.error?.code || 'unknown'; + const errorMsg = message.error?.message || 'Unknown ACP error'; + const errorData = message.error?.data + ? JSON.stringify(message.error.data) + : ''; + console.error(`[ACP] Error response for ${method}:`, { + code: errorCode, + message: errorMsg, + data: errorData, + }); + reject( + new Error( + `${errorMsg} (code: ${errorCode})${errorData ? '\nData: ' + errorData : ''}`, + ), + ); + } + } + + /** + * Handle incoming requests + * + * @param message - Request or notification message + * @param callbacks - Callback functions collection + * @returns Request processing result + */ + async handleIncomingRequest( + message: AcpRequest | AcpNotification, + callbacks: AcpConnectionCallbacks, + ): Promise { + const { method, params } = message; + + let result = null; + + switch (method) { + case CLIENT_METHODS.session_update: + console.log( + '[ACP] >>> Processing session_update:', + JSON.stringify(params).substring(0, 300), + ); + callbacks.onSessionUpdate(params as AcpSessionUpdate); + break; + case CLIENT_METHODS.authenticate_update: + console.log( + '[ACP] >>> Processing authenticate_update:', + JSON.stringify(params).substring(0, 300), + ); + callbacks.onAuthenticateUpdate( + params as AuthenticateUpdateNotification, + ); + break; + case CLIENT_METHODS.session_request_permission: + result = await this.handlePermissionRequest( + params as AcpPermissionRequest, + callbacks, + ); + break; + case CLIENT_METHODS.fs_read_text_file: + result = await this.fileHandler.handleReadTextFile( + params as { + path: string; + sessionId: string; + line: number | null; + limit: number | null; + }, + ); + break; + case CLIENT_METHODS.fs_write_text_file: + result = await this.fileHandler.handleWriteTextFile( + params as { path: string; content: string; sessionId: string }, + ); + break; + default: + console.warn(`[ACP] Unhandled method: ${method}`); + break; + } + + return result; + } + + /** + * Handle permission requests + * + * @param params - Permission request parameters + * @param callbacks - Callback functions collection + * @returns Permission request result + */ + private async handlePermissionRequest( + params: AcpPermissionRequest, + callbacks: AcpConnectionCallbacks, + ): Promise<{ + outcome: { outcome: string; optionId: string }; + }> { + try { + const response = await callbacks.onPermissionRequest(params); + const optionId = response?.optionId; + console.log('[ACP] Permission request:', optionId); + // Handle cancel, deny, or allow + let outcome: string; + if (optionId && (optionId.includes('reject') || optionId === 'cancel')) { + outcome = 'cancelled'; + } else { + outcome = 'selected'; + } + console.log('[ACP] Permission outcome:', outcome); + + return { + outcome: { + outcome, + // optionId: optionId === 'cancel' ? 'cancel' : optionId, + optionId, + }, + }; + } catch (_error) { + return { + outcome: { + outcome: 'rejected', + optionId: 'reject_once', + }, + }; + } + } +} diff --git a/packages/vscode-ide-companion/src/services/acpSessionManager.ts b/packages/vscode-ide-companion/src/services/acpSessionManager.ts new file mode 100644 index 000000000..e2055a3a2 --- /dev/null +++ b/packages/vscode-ide-companion/src/services/acpSessionManager.ts @@ -0,0 +1,484 @@ +/** + * @license + * Copyright 2025 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + */ + +/** + * ACP Session Manager + * + * Responsible for managing ACP protocol session operations, including initialization, authentication, session creation, and switching + */ +import { JSONRPC_VERSION } from '../types/acpTypes.js'; +import type { + AcpRequest, + AcpNotification, + AcpResponse, +} from '../types/acpTypes.js'; +import type { ApprovalModeValue } from '../types/approvalModeValueTypes.js'; +import { AGENT_METHODS } from '../constants/acpSchema.js'; +import type { PendingRequest } from '../types/connectionTypes.js'; +import type { ChildProcess } from 'child_process'; + +/** + * ACP Session Manager Class + * Provides session initialization, authentication, creation, loading, and switching functionality + */ +export class AcpSessionManager { + private sessionId: string | null = null; + private isInitialized = false; + + /** + * Send request to ACP server + * + * @param method - Request method name + * @param params - Request parameters + * @param child - Child process instance + * @param pendingRequests - Pending requests map + * @param nextRequestId - Request ID counter + * @returns Request response + */ + private sendRequest( + method: string, + params: Record | undefined, + child: ChildProcess | null, + pendingRequests: Map>, + nextRequestId: { value: number }, + ): Promise { + const id = nextRequestId.value++; + const message: AcpRequest = { + jsonrpc: JSONRPC_VERSION, + id, + method, + ...(params && { params }), + }; + + return new Promise((resolve, reject) => { + // No timeout for session_prompt as LLM tasks can take 5-10 minutes or longer + // The request should always terminate with a stop_reason + let timeoutId: NodeJS.Timeout | undefined; + let timeoutDuration: number | undefined; + + if (method !== AGENT_METHODS.session_prompt) { + // Set timeout for other methods + timeoutDuration = method === AGENT_METHODS.initialize ? 120000 : 60000; + timeoutId = setTimeout(() => { + pendingRequests.delete(id); + reject(new Error(`Request ${method} timed out`)); + }, timeoutDuration); + } + + const pendingRequest: PendingRequest = { + resolve: (value: T) => { + if (timeoutId) { + clearTimeout(timeoutId); + } + resolve(value); + }, + reject: (error: Error) => { + if (timeoutId) { + clearTimeout(timeoutId); + } + reject(error); + }, + timeoutId, + method, + }; + + pendingRequests.set(id, pendingRequest as PendingRequest); + this.sendMessage(message, child); + }); + } + + /** + * Send message to child process + * + * @param message - Request or notification message + * @param child - Child process instance + */ + private sendMessage( + message: AcpRequest | AcpNotification, + child: ChildProcess | null, + ): void { + if (child?.stdin) { + const jsonString = JSON.stringify(message); + const lineEnding = process.platform === 'win32' ? '\r\n' : '\n'; + child.stdin.write(jsonString + lineEnding); + } + } + + /** + * Initialize ACP protocol connection + * + * @param child - Child process instance + * @param pendingRequests - Pending requests map + * @param nextRequestId - Request ID counter + * @returns Initialization response + */ + async initialize( + child: ChildProcess | null, + pendingRequests: Map>, + nextRequestId: { value: number }, + ): Promise { + const initializeParams = { + protocolVersion: 1, + clientCapabilities: { + fs: { + readTextFile: true, + writeTextFile: true, + }, + }, + }; + + console.log('[ACP] Sending initialize request...'); + const response = await this.sendRequest( + AGENT_METHODS.initialize, + initializeParams, + child, + pendingRequests, + nextRequestId, + ); + this.isInitialized = true; + + console.log('[ACP] Initialize successful'); + return response; + } + + /** + * Perform authentication + * + * @param methodId - Authentication method ID + * @param child - Child process instance + * @param pendingRequests - Pending requests map + * @param nextRequestId - Request ID counter + * @returns Authentication response + */ + async authenticate( + methodId: string | undefined, + child: ChildProcess | null, + pendingRequests: Map>, + nextRequestId: { value: number }, + ): Promise { + const authMethodId = methodId || 'default'; + console.log( + '[ACP] Sending authenticate request with methodId:', + authMethodId, + ); + const response = await this.sendRequest( + AGENT_METHODS.authenticate, + { + methodId: authMethodId, + }, + child, + pendingRequests, + nextRequestId, + ); + console.log('[ACP] Authenticate successful', response); + return response; + } + + /** + * Create new session + * + * @param cwd - Working directory + * @param child - Child process instance + * @param pendingRequests - Pending requests map + * @param nextRequestId - Request ID counter + * @returns New session response + */ + async newSession( + cwd: string, + child: ChildProcess | null, + pendingRequests: Map>, + nextRequestId: { value: number }, + ): Promise { + console.log('[ACP] Sending session/new request with cwd:', cwd); + const response = await this.sendRequest< + AcpResponse & { sessionId?: string } + >( + AGENT_METHODS.session_new, + { + cwd, + mcpServers: [], + }, + child, + pendingRequests, + nextRequestId, + ); + + this.sessionId = (response && response.sessionId) || null; + console.log('[ACP] Session created with ID:', this.sessionId); + return response; + } + + /** + * Send prompt message + * + * @param prompt - Prompt content + * @param child - Child process instance + * @param pendingRequests - Pending requests map + * @param nextRequestId - Request ID counter + * @returns Response + * @throws Error when there is no active session + */ + async sendPrompt( + prompt: string, + child: ChildProcess | null, + pendingRequests: Map>, + nextRequestId: { value: number }, + ): Promise { + if (!this.sessionId) { + throw new Error('No active ACP session'); + } + + return await this.sendRequest( + AGENT_METHODS.session_prompt, + { + sessionId: this.sessionId, + prompt: [{ type: 'text', text: prompt }], + }, + child, + pendingRequests, + nextRequestId, + ); + } + + /** + * Load existing session + * + * @param sessionId - Session ID + * @param child - Child process instance + * @param pendingRequests - Pending requests map + * @param nextRequestId - Request ID counter + * @returns Load response + */ + async loadSession( + sessionId: string, + child: ChildProcess | null, + pendingRequests: Map>, + nextRequestId: { value: number }, + cwd: string = process.cwd(), + ): Promise { + console.log('[ACP] Sending session/load request for session:', sessionId); + console.log('[ACP] Request parameters:', { + sessionId, + cwd, + mcpServers: [], + }); + + try { + const response = await this.sendRequest( + AGENT_METHODS.session_load, + { + sessionId, + cwd, + mcpServers: [], + }, + child, + pendingRequests, + nextRequestId, + ); + + console.log( + '[ACP] Session load response:', + JSON.stringify(response).substring(0, 500), + ); + + // Check if response contains an error + if (response && response.error) { + console.error('[ACP] Session load returned error:', response.error); + } else { + console.log('[ACP] Session load succeeded'); + // session/load returns null on success per schema; update local sessionId + // so subsequent prompts use the loaded session. + this.sessionId = sessionId; + } + + return response; + } catch (error) { + console.error( + '[ACP] Session load request failed with exception:', + error instanceof Error ? error.message : String(error), + ); + throw error; + } + } + + /** + * Get session list + * + * @param child - Child process instance + * @param pendingRequests - Pending requests map + * @param nextRequestId - Request ID counter + * @returns Session list response + */ + async listSessions( + child: ChildProcess | null, + pendingRequests: Map>, + nextRequestId: { value: number }, + cwd: string = process.cwd(), + options?: { cursor?: number; size?: number }, + ): Promise { + console.log('[ACP] Requesting session list...'); + try { + // session/list requires cwd in params per ACP schema + const params: Record = { cwd }; + if (options?.cursor !== undefined) { + params.cursor = options.cursor; + } + if (options?.size !== undefined) { + params.size = options.size; + } + + const response = await this.sendRequest( + AGENT_METHODS.session_list, + params, + child, + pendingRequests, + nextRequestId, + ); + console.log( + '[ACP] Session list response:', + JSON.stringify(response).substring(0, 200), + ); + return response; + } catch (error) { + console.error('[ACP] Failed to get session list:', error); + throw error; + } + } + + /** + * Set approval mode for current session (ACP session/set_mode) + * + * @param modeId - Approval mode value + */ + async setMode( + modeId: ApprovalModeValue, + child: ChildProcess | null, + pendingRequests: Map>, + nextRequestId: { value: number }, + ): Promise { + if (!this.sessionId) { + throw new Error('No active ACP session'); + } + console.log('[ACP] Sending session/set_mode:', modeId); + const res = await this.sendRequest( + AGENT_METHODS.session_set_mode, + { sessionId: this.sessionId, modeId }, + child, + pendingRequests, + nextRequestId, + ); + console.log('[ACP] set_mode response:', res); + return res; + } + + /** + * Switch to specified session + * + * @param sessionId - Session ID + * @param nextRequestId - Request ID counter + * @returns Switch response + */ + async switchSession( + sessionId: string, + nextRequestId: { value: number }, + ): Promise { + console.log('[ACP] Switching to session:', sessionId); + this.sessionId = sessionId; + + const mockResponse: AcpResponse = { + jsonrpc: JSONRPC_VERSION, + id: nextRequestId.value++, + result: { sessionId }, + }; + console.log( + '[ACP] Session ID updated locally (switch not supported by CLI)', + ); + return mockResponse; + } + + /** + * Cancel prompt generation for current session + * + * @param child - Child process instance + */ + async cancelSession(child: ChildProcess | null): Promise { + if (!this.sessionId) { + console.warn('[ACP] No active session to cancel'); + return; + } + + console.log('[ACP] Cancelling session:', this.sessionId); + + const cancelParams = { + sessionId: this.sessionId, + }; + + const message: AcpNotification = { + jsonrpc: JSONRPC_VERSION, + method: AGENT_METHODS.session_cancel, + params: cancelParams, + }; + + this.sendMessage(message, child); + console.log('[ACP] Cancel notification sent'); + } + + /** + * Save current session + * + * @param tag - Save tag + * @param child - Child process instance + * @param pendingRequests - Pending requests map + * @param nextRequestId - Request ID counter + * @returns Save response + */ + async saveSession( + tag: string, + child: ChildProcess | null, + pendingRequests: Map>, + nextRequestId: { value: number }, + ): Promise { + if (!this.sessionId) { + throw new Error('No active ACP session'); + } + + console.log('[ACP] Saving session with tag:', tag); + const response = await this.sendRequest( + AGENT_METHODS.session_save, + { + sessionId: this.sessionId, + tag, + }, + child, + pendingRequests, + nextRequestId, + ); + console.log('[ACP] Session save response:', response); + return response; + } + + /** + * Reset session manager state + */ + reset(): void { + this.sessionId = null; + this.isInitialized = false; + } + + /** + * Get current session ID + */ + getCurrentSessionId(): string | null { + return this.sessionId; + } + + /** + * Check if initialized + */ + getIsInitialized(): boolean { + return this.isInitialized; + } +} diff --git a/packages/vscode-ide-companion/src/services/conversationStore.ts b/packages/vscode-ide-companion/src/services/conversationStore.ts new file mode 100644 index 000000000..8a31af9ca --- /dev/null +++ b/packages/vscode-ide-companion/src/services/conversationStore.ts @@ -0,0 +1,83 @@ +/** + * @license + * Copyright 2025 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + */ + +import type * as vscode from 'vscode'; +import type { ChatMessage } from './qwenAgentManager.js'; + +export interface Conversation { + id: string; + title: string; + messages: ChatMessage[]; + createdAt: number; + updatedAt: number; +} + +export class ConversationStore { + private context: vscode.ExtensionContext; + private currentConversationId: string | null = null; + + constructor(context: vscode.ExtensionContext) { + this.context = context; + } + + async createConversation(title: string = 'New Chat'): Promise { + const conversation: Conversation = { + id: `conv_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`, + title, + messages: [], + createdAt: Date.now(), + updatedAt: Date.now(), + }; + + const conversations = await this.getAllConversations(); + conversations.push(conversation); + await this.context.globalState.update('conversations', conversations); + + this.currentConversationId = conversation.id; + return conversation; + } + + async getAllConversations(): Promise { + return this.context.globalState.get('conversations', []); + } + + async getConversation(id: string): Promise { + const conversations = await this.getAllConversations(); + return conversations.find((c) => c.id === id) || null; + } + + async addMessage( + conversationId: string, + message: ChatMessage, + ): Promise { + const conversations = await this.getAllConversations(); + const conversation = conversations.find((c) => c.id === conversationId); + + if (conversation) { + conversation.messages.push(message); + conversation.updatedAt = Date.now(); + await this.context.globalState.update('conversations', conversations); + } + } + + async deleteConversation(id: string): Promise { + const conversations = await this.getAllConversations(); + const filtered = conversations.filter((c) => c.id !== id); + await this.context.globalState.update('conversations', filtered); + + if (this.currentConversationId === id) { + this.currentConversationId = null; + } + } + + getCurrentConversationId(): string | null { + return this.currentConversationId; + } + + setCurrentConversationId(id: string): void { + this.currentConversationId = id; + } +} diff --git a/packages/vscode-ide-companion/src/services/qwenAgentManager.ts b/packages/vscode-ide-companion/src/services/qwenAgentManager.ts new file mode 100644 index 000000000..e60ee3a21 --- /dev/null +++ b/packages/vscode-ide-companion/src/services/qwenAgentManager.ts @@ -0,0 +1,1280 @@ +/** + * @license + * Copyright 2025 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + */ +import { AcpConnection } from './acpConnection.js'; +import type { + AcpSessionUpdate, + AcpPermissionRequest, + AuthenticateUpdateNotification, +} from '../types/acpTypes.js'; +import type { ApprovalModeValue } from '../types/approvalModeValueTypes.js'; +import { QwenSessionReader, type QwenSession } from './qwenSessionReader.js'; +import { QwenSessionManager } from './qwenSessionManager.js'; +import type { + ChatMessage, + PlanEntry, + ToolCallUpdateData, + QwenAgentCallbacks, +} from '../types/chatTypes.js'; +import { + QwenConnectionHandler, + type QwenConnectionResult, +} from '../services/qwenConnectionHandler.js'; +import { QwenSessionUpdateHandler } from './qwenSessionUpdateHandler.js'; +import { authMethod } from '../types/acpTypes.js'; +import { isAuthenticationRequiredError } from '../utils/authErrors.js'; +import { handleAuthenticateUpdate } from '../utils/authNotificationHandler.js'; + +export type { ChatMessage, PlanEntry, ToolCallUpdateData }; + +/** + * Qwen Agent Manager + * + * Coordinates various modules and provides unified interface + */ +interface AgentConnectOptions { + autoAuthenticate?: boolean; +} +interface AgentSessionOptions { + autoAuthenticate?: boolean; +} + +export class QwenAgentManager { + private connection: AcpConnection; + private sessionReader: QwenSessionReader; + private sessionManager: QwenSessionManager; + private connectionHandler: QwenConnectionHandler; + private sessionUpdateHandler: QwenSessionUpdateHandler; + private currentWorkingDir: string = process.cwd(); + // When loading a past session via ACP, the CLI replays history through + // session/update notifications. We set this flag to route message chunks + // (user/assistant) as discrete chat messages instead of live streaming. + private rehydratingSessionId: string | null = null; + // CLI is now the single source of truth for authentication state + // Deduplicate concurrent session/new attempts + private sessionCreateInFlight: Promise | null = null; + + // Callback storage + private callbacks: QwenAgentCallbacks = {}; + + constructor() { + this.connection = new AcpConnection(); + this.sessionReader = new QwenSessionReader(); + this.sessionManager = new QwenSessionManager(); + this.connectionHandler = new QwenConnectionHandler(); + this.sessionUpdateHandler = new QwenSessionUpdateHandler({}); + + // Set ACP connection callbacks + this.connection.onSessionUpdate = (data: AcpSessionUpdate) => { + // If we are rehydrating a loaded session, map message chunks into + // full messages for the UI, instead of streaming behavior. + try { + const targetId = this.rehydratingSessionId; + if ( + targetId && + typeof data === 'object' && + data && + 'update' in data && + (data as { sessionId?: string }).sessionId === targetId + ) { + const update = ( + data as unknown as { + update: { sessionUpdate: string; content?: { text?: string } }; + } + ).update; + const text = update?.content?.text || ''; + if (update?.sessionUpdate === 'user_message_chunk' && text) { + console.log( + '[QwenAgentManager] Rehydration: routing user message chunk', + ); + this.callbacks.onMessage?.({ + role: 'user', + content: text, + timestamp: Date.now(), + }); + return; + } + if (update?.sessionUpdate === 'agent_message_chunk' && text) { + console.log( + '[QwenAgentManager] Rehydration: routing agent message chunk', + ); + this.callbacks.onMessage?.({ + role: 'assistant', + content: text, + timestamp: Date.now(), + }); + return; + } + // For other types during rehydration, fall through to normal handler + console.log( + '[QwenAgentManager] Rehydration: non-text update, forwarding to handler', + ); + } + } catch (err) { + console.warn('[QwenAgentManager] Rehydration routing failed:', err); + } + + // Default handling path + this.sessionUpdateHandler.handleSessionUpdate(data); + }; + + this.connection.onPermissionRequest = async ( + data: AcpPermissionRequest, + ) => { + if (this.callbacks.onPermissionRequest) { + const optionId = await this.callbacks.onPermissionRequest(data); + return { optionId }; + } + return { optionId: 'allow_once' }; + }; + + this.connection.onEndTurn = (reason?: string) => { + try { + if (this.callbacks.onEndTurn) { + this.callbacks.onEndTurn(reason); + } else if (this.callbacks.onStreamChunk) { + // Fallback: send a zero-length chunk then rely on streamEnd elsewhere + this.callbacks.onStreamChunk(''); + } + } catch (err) { + console.warn('[QwenAgentManager] onEndTurn callback error:', err); + } + }; + + this.connection.onAuthenticateUpdate = ( + data: AuthenticateUpdateNotification, + ) => { + try { + // Handle authentication update notifications by showing VS Code notification + handleAuthenticateUpdate(data); + } catch (err) { + console.warn( + '[QwenAgentManager] onAuthenticateUpdate callback error:', + err, + ); + } + }; + + // Initialize callback to surface available modes and current mode to UI + this.connection.onInitialized = (init: unknown) => { + try { + const obj = (init || {}) as Record; + const modes = obj['modes'] as + | { + currentModeId?: 'plan' | 'default' | 'auto-edit' | 'yolo'; + availableModes?: Array<{ + id: 'plan' | 'default' | 'auto-edit' | 'yolo'; + name: string; + description: string; + }>; + } + | undefined; + if (modes && this.callbacks.onModeInfo) { + this.callbacks.onModeInfo({ + currentModeId: modes.currentModeId, + availableModes: modes.availableModes, + }); + } + } catch (err) { + console.warn('[QwenAgentManager] onInitialized parse error:', err); + } + }; + } + + /** + * Connect to Qwen service + * + * @param workingDir - Working directory + * @param cliEntryPath - Path to bundled CLI entrypoint (cli.js) + */ + async connect( + workingDir: string, + cliEntryPath: string, + options?: AgentConnectOptions, + ): Promise { + this.currentWorkingDir = workingDir; + return this.connectionHandler.connect( + this.connection, + workingDir, + cliEntryPath, + options, + ); + } + + /** + * Send message + * + * @param message - Message content + */ + async sendMessage(message: string): Promise { + await this.connection.sendPrompt(message); + } + + /** + * Set approval mode from UI + */ + async setApprovalModeFromUi( + mode: ApprovalModeValue, + ): Promise { + const modeId = mode; + try { + const res = await this.connection.setMode(modeId); + // Optimistically notify UI using response + const result = (res?.result || {}) as { modeId?: string }; + const confirmed = + (result.modeId as + | 'plan' + | 'default' + | 'auto-edit' + | 'yolo' + | undefined) || modeId; + this.callbacks.onModeChanged?.(confirmed); + return confirmed; + } catch (err) { + console.error('[QwenAgentManager] Failed to set mode:', err); + throw err; + } + } + + /** + * Validate if current session is still active + * This is a lightweight check to verify session validity + * + * @returns True if session is valid, false otherwise + */ + async validateCurrentSession(): Promise { + try { + // If we don't have a current session, it's definitely not valid + if (!this.connection.currentSessionId) { + return false; + } + + // Try to get session list to verify our session still exists + const sessions = await this.getSessionList(); + const currentSessionId = this.connection.currentSessionId; + + // Check if our current session exists in the session list + const sessionExists = sessions.some( + (session: Record) => + session.id === currentSessionId || + session.sessionId === currentSessionId, + ); + + return sessionExists; + } catch (error) { + console.warn('[QwenAgentManager] Session validation failed:', error); + // If we can't validate, assume session is invalid + return false; + } + } + + /** + * Get session list with version-aware strategy + * First tries ACP method if CLI version supports it, falls back to file system method + * + * @returns Session list + */ + async getSessionList(): Promise>> { + console.log( + '[QwenAgentManager] Getting session list with version-aware strategy', + ); + + try { + console.log( + '[QwenAgentManager] Attempting to get session list via ACP method', + ); + const response = await this.connection.listSessions(); + console.log('[QwenAgentManager] ACP session list response:', response); + + // sendRequest resolves with the JSON-RPC "result" directly + // Newer CLI returns an object: { items: [...], nextCursor?, hasMore } + // Older prototypes might return an array. Support both. + const res: unknown = response; + let items: Array> = []; + + // Note: AcpSessionManager resolves `sendRequest` with the JSON-RPC + // "result" directly (not the full AcpResponse). Treat it as unknown + // and carefully narrow before accessing `items` to satisfy strict TS. + if (res && typeof res === 'object' && 'items' in res) { + const itemsValue = (res as { items?: unknown }).items; + items = Array.isArray(itemsValue) + ? (itemsValue as Array>) + : []; + } + + console.log( + '[QwenAgentManager] Sessions retrieved via ACP:', + res, + items.length, + ); + if (items.length > 0) { + const sessions = items.map((item) => ({ + id: item.sessionId || item.id, + sessionId: item.sessionId || item.id, + title: item.title || item.name || item.prompt || 'Untitled Session', + name: item.title || item.name || item.prompt || 'Untitled Session', + startTime: item.startTime, + lastUpdated: item.mtime || item.lastUpdated, + messageCount: item.messageCount || 0, + projectHash: item.projectHash, + filePath: item.filePath, + cwd: item.cwd, + })); + + console.log( + '[QwenAgentManager] Sessions retrieved via ACP:', + sessions.length, + ); + return sessions; + } + } catch (error) { + console.warn( + '[QwenAgentManager] ACP session list failed, falling back to file system method:', + error, + ); + } + + // Always fall back to file system method + try { + console.log('[QwenAgentManager] Getting session list from file system'); + const sessions = await this.sessionReader.getAllSessions(undefined, true); + console.log( + '[QwenAgentManager] Session list from file system (all projects):', + sessions.length, + ); + + const result = sessions.map( + (session: QwenSession): Record => ({ + id: session.sessionId, + sessionId: session.sessionId, + title: this.sessionReader.getSessionTitle(session), + name: this.sessionReader.getSessionTitle(session), + startTime: session.startTime, + lastUpdated: session.lastUpdated, + messageCount: session.messageCount ?? session.messages.length, + projectHash: session.projectHash, + filePath: session.filePath, + cwd: session.cwd, + }), + ); + + console.log( + '[QwenAgentManager] Sessions retrieved from file system:', + result.length, + ); + return result; + } catch (error) { + console.error( + '[QwenAgentManager] Failed to get session list from file system:', + error, + ); + return []; + } + } + + /** + * Get session list (paged) + * Uses ACP session/list with cursor-based pagination when available. + * Falls back to file system scan with equivalent pagination semantics. + */ + async getSessionListPaged(params?: { + cursor?: number; + size?: number; + }): Promise<{ + sessions: Array>; + nextCursor?: number; + hasMore: boolean; + }> { + const size = params?.size ?? 20; + const cursor = params?.cursor; + + try { + const response = await this.connection.listSessions({ + size, + ...(cursor !== undefined ? { cursor } : {}), + }); + // sendRequest resolves with the JSON-RPC "result" directly + const res: unknown = response; + let items: Array> = []; + + if (Array.isArray(res)) { + items = res; + } else if (typeof res === 'object' && res !== null && 'items' in res) { + const responseObject = res as { + items?: Array>; + }; + items = Array.isArray(responseObject.items) ? responseObject.items : []; + } + + const mapped = items.map((item) => ({ + id: item.sessionId || item.id, + sessionId: item.sessionId || item.id, + title: item.title || item.name || item.prompt || 'Untitled Session', + name: item.title || item.name || item.prompt || 'Untitled Session', + startTime: item.startTime, + lastUpdated: item.mtime || item.lastUpdated, + messageCount: item.messageCount || 0, + projectHash: item.projectHash, + filePath: item.filePath, + cwd: item.cwd, + })); + + const nextCursor: number | undefined = + typeof res === 'object' && res !== null && 'nextCursor' in res + ? typeof res.nextCursor === 'number' + ? res.nextCursor + : undefined + : undefined; + const hasMore: boolean = + typeof res === 'object' && res !== null && 'hasMore' in res + ? Boolean(res.hasMore) + : false; + + return { sessions: mapped, nextCursor, hasMore }; + } catch (error) { + console.warn('[QwenAgentManager] Paged ACP session list failed:', error); + // fall through to file system + } + + // Fallback: file system for current project only (to match ACP semantics) + try { + const all = await this.sessionReader.getAllSessions( + this.currentWorkingDir, + false, + ); + // Sorted by lastUpdated desc already per reader + const allWithMtime = all.map((s) => ({ + raw: s, + mtime: new Date(s.lastUpdated).getTime(), + })); + const filtered = + cursor !== undefined + ? allWithMtime.filter((x) => x.mtime < cursor) + : allWithMtime; + const page = filtered.slice(0, size); + const sessions = page.map((x) => ({ + id: x.raw.sessionId, + sessionId: x.raw.sessionId, + title: this.sessionReader.getSessionTitle(x.raw), + name: this.sessionReader.getSessionTitle(x.raw), + startTime: x.raw.startTime, + lastUpdated: x.raw.lastUpdated, + messageCount: x.raw.messageCount ?? x.raw.messages.length, + projectHash: x.raw.projectHash, + filePath: x.raw.filePath, + cwd: x.raw.cwd, + })); + const nextCursorVal = + page.length > 0 ? page[page.length - 1].mtime : undefined; + const hasMore = filtered.length > size; + return { sessions, nextCursor: nextCursorVal, hasMore }; + } catch (error) { + console.error('[QwenAgentManager] File system paged list failed:', error); + return { sessions: [], hasMore: false }; + } + } + + /** + * Get session messages (read from disk) + * + * @param sessionId - Session ID + * @returns Message list + */ + async getSessionMessages(sessionId: string): Promise { + try { + try { + const list = await this.getSessionList(); + const item = list.find( + (s) => s.sessionId === sessionId || s.id === sessionId, + ); + console.log( + '[QwenAgentManager] Session list item for filePath lookup:', + item, + ); + if ( + typeof item === 'object' && + item !== null && + 'filePath' in item && + typeof item.filePath === 'string' + ) { + const messages = await this.readJsonlMessages(item.filePath); + // Even if messages array is empty, we should return it rather than falling back + // This ensures we don't accidentally show messages from a different session format + return messages; + } + } catch (e) { + console.warn('[QwenAgentManager] JSONL read path lookup failed:', e); + } + + // Fallback: legacy JSON session files + const session = await this.sessionReader.getSession( + sessionId, + this.currentWorkingDir, + ); + if (!session) { + return []; + } + return session.messages.map( + (msg: { type: string; content: string; timestamp: string }) => ({ + role: msg.type === 'user' ? 'user' : 'assistant', + content: msg.content, + timestamp: new Date(msg.timestamp).getTime(), + }), + ); + } catch (error) { + console.error( + '[QwenAgentManager] Failed to get session messages:', + error, + ); + return []; + } + } + + // Read CLI JSONL session file and convert to ChatMessage[] for UI + private async readJsonlMessages(filePath: string): Promise { + const fs = await import('fs'); + const readline = await import('readline'); + try { + if (!fs.existsSync(filePath)) { + return []; + } + const fileStream = fs.createReadStream(filePath, { encoding: 'utf-8' }); + const rl = readline.createInterface({ + input: fileStream, + crlfDelay: Infinity, + }); + const records: unknown[] = []; + for await (const line of rl) { + const trimmed = line.trim(); + if (!trimmed) { + continue; + } + try { + const obj = JSON.parse(trimmed); + records.push(obj); + } catch { + /* ignore */ + } + } + // Simple linear reconstruction: filter user/assistant and sort by timestamp + console.log( + '[QwenAgentManager] JSONL records read:', + records.length, + filePath, + ); + + // Include all types of records, not just user/assistant + // Narrow unknown JSONL rows into a minimal shape we can work with. + type JsonlRecord = { + type: string; + timestamp: string; + message?: unknown; + toolCallResult?: { callId?: string; status?: string } | unknown; + subtype?: string; + systemPayload?: { uiEvent?: Record } | unknown; + plan?: { entries?: Array> } | unknown; + }; + + const isJsonlRecord = (x: unknown): x is JsonlRecord => + typeof x === 'object' && + x !== null && + typeof (x as Record).type === 'string' && + typeof (x as Record).timestamp === 'string'; + + const allRecords = records + .filter(isJsonlRecord) + .sort( + (a, b) => + new Date(a.timestamp).getTime() - new Date(b.timestamp).getTime(), + ); + + const msgs: ChatMessage[] = []; + for (const r of allRecords) { + // Handle user and assistant messages + if ((r.type === 'user' || r.type === 'assistant') && r.message) { + msgs.push({ + role: + r.type === 'user' ? ('user' as const) : ('assistant' as const), + content: this.contentToText(r.message), + timestamp: new Date(r.timestamp).getTime(), + }); + } + // Handle tool call records that might have content we want to show + else if (r.type === 'tool_call' || r.type === 'tool_call_update') { + // Convert tool calls to messages if they have relevant content + const toolContent = this.extractToolCallContent(r as unknown); + if (toolContent) { + msgs.push({ + role: 'assistant', + content: toolContent, + timestamp: new Date(r.timestamp).getTime(), + }); + } + } + // Handle tool result records + else if ( + r.type === 'tool_result' && + r.toolCallResult && + typeof r.toolCallResult === 'object' + ) { + const toolResult = r.toolCallResult as { + callId?: string; + status?: string; + }; + const callId = toolResult.callId ?? 'unknown'; + const status = toolResult.status ?? 'unknown'; + const resultText = `Tool Result (${callId}): ${status}`; + msgs.push({ + role: 'assistant', + content: resultText, + timestamp: new Date(r.timestamp).getTime(), + }); + } + // Handle system telemetry records + else if ( + r.type === 'system' && + r.subtype === 'ui_telemetry' && + r.systemPayload && + typeof r.systemPayload === 'object' && + 'uiEvent' in r.systemPayload && + (r.systemPayload as { uiEvent?: Record }).uiEvent + ) { + const uiEvent = ( + r.systemPayload as { + uiEvent?: Record; + } + ).uiEvent as Record; + let telemetryText = ''; + + if ( + typeof uiEvent['event.name'] === 'string' && + (uiEvent['event.name'] as string).includes('tool_call') + ) { + const functionName = + (uiEvent['function_name'] as string | undefined) || + 'Unknown tool'; + const status = + (uiEvent['status'] as string | undefined) || 'unknown'; + const duration = + typeof uiEvent['duration_ms'] === 'number' + ? ` (${uiEvent['duration_ms']}ms)` + : ''; + telemetryText = `Tool Call: ${functionName} - ${status}${duration}`; + } else if ( + typeof uiEvent['event.name'] === 'string' && + (uiEvent['event.name'] as string).includes('api_response') + ) { + const statusCode = + (uiEvent['status_code'] as string | number | undefined) || + 'unknown'; + const duration = + typeof uiEvent['duration_ms'] === 'number' + ? ` (${uiEvent['duration_ms']}ms)` + : ''; + telemetryText = `API Response: Status ${statusCode}${duration}`; + } else { + // Generic system telemetry + const eventName = + (uiEvent['event.name'] as string | undefined) || 'Unknown event'; + telemetryText = `System Event: ${eventName}`; + } + + if (telemetryText) { + msgs.push({ + role: 'assistant', + content: telemetryText, + timestamp: new Date(r.timestamp).getTime(), + }); + } + } + // Handle plan entries + else if ( + r.type === 'plan' && + r.plan && + typeof r.plan === 'object' && + 'entries' in r.plan + ) { + const planEntries = + ((r.plan as { entries?: Array> }) + .entries as Array> | undefined) || []; + if (planEntries.length > 0) { + const planText = planEntries + .map( + (entry: Record, index: number) => + `${index + 1}. ${ + entry.description || entry.title || 'Unnamed step' + }`, + ) + .join('\n'); + msgs.push({ + role: 'assistant', + content: `Plan:\n${planText}`, + timestamp: new Date(r.timestamp).getTime(), + }); + } + } + // Handle other types if needed + } + + console.log( + '[QwenAgentManager] JSONL messages reconstructed:', + msgs.length, + ); + return msgs; + } catch (err) { + console.warn('[QwenAgentManager] Failed to read JSONL messages:', err); + return []; + } + } + + // Extract meaningful content from tool call records + private extractToolCallContent(record: unknown): string | null { + try { + // Type guard for record + if (typeof record !== 'object' || record === null) { + return null; + } + + // Cast to a more specific type for easier handling + const typedRecord = record as Record; + + // If the tool call has a result or output, include it + if ('toolCallResult' in typedRecord && typedRecord.toolCallResult) { + return `Tool result: ${this.formatValue(typedRecord.toolCallResult)}`; + } + + // If the tool call has content, include it + if ('content' in typedRecord && typedRecord.content) { + return this.formatValue(typedRecord.content); + } + + // If the tool call has a title or name, include it + if ( + ('title' in typedRecord && typedRecord.title) || + ('name' in typedRecord && typedRecord.name) + ) { + return `Tool: ${typedRecord.title || typedRecord.name}`; + } + + // Handle tool_call records with more details + if ( + typedRecord.type === 'tool_call' && + 'toolCall' in typedRecord && + typedRecord.toolCall + ) { + const toolCall = typedRecord.toolCall as Record; + if ( + ('title' in toolCall && toolCall.title) || + ('name' in toolCall && toolCall.name) + ) { + return `Tool call: ${toolCall.title || toolCall.name}`; + } + if ('rawInput' in toolCall && toolCall.rawInput) { + return `Tool input: ${this.formatValue(toolCall.rawInput)}`; + } + } + + // Handle tool_call_update records with status + if (typedRecord.type === 'tool_call_update') { + const status = + ('status' in typedRecord && typedRecord.status) || 'unknown'; + const title = + ('title' in typedRecord && typedRecord.title) || + ('name' in typedRecord && typedRecord.name) || + 'Unknown tool'; + return `Tool ${status}: ${title}`; + } + + return null; + } catch { + return null; + } + } + + // Format any value to a string for display + private formatValue(value: unknown): string { + if (value === null || value === undefined) { + return ''; + } + if (typeof value === 'string') { + return value; + } + if (typeof value === 'object') { + try { + return JSON.stringify(value, null, 2); + } catch (_e) { + return String(value); + } + } + return String(value); + } + + // Extract plain text from Content (genai Content) + private contentToText(message: unknown): string { + try { + // Type guard for message + if (typeof message !== 'object' || message === null) { + return ''; + } + + // Cast to a more specific type for easier handling + const typedMessage = message as Record; + + const parts = Array.isArray(typedMessage.parts) ? typedMessage.parts : []; + const texts: string[] = []; + for (const p of parts) { + // Type guard for part + if (typeof p !== 'object' || p === null) { + continue; + } + + const typedPart = p as Record; + if (typeof typedPart.text === 'string') { + texts.push(typedPart.text); + } else if (typeof typedPart.data === 'string') { + texts.push(typedPart.data); + } + } + return texts.join('\n'); + } catch { + return ''; + } + } + + /** + * Save session via /chat save command + * Since CLI doesn't support session/save ACP method, we send /chat save command directly + * + * @param sessionId - Session ID + * @param tag - Save tag + * @returns Save response + */ + async saveSessionViaCommand( + sessionId: string, + tag: string, + ): Promise<{ success: boolean; message?: string }> { + try { + console.log( + '[QwenAgentManager] Saving session via /chat save command:', + sessionId, + 'with tag:', + tag, + ); + + // Send /chat save command as a prompt + // The CLI will handle this as a special command + await this.connection.sendPrompt(`/chat save "${tag}"`); + + console.log('[QwenAgentManager] /chat save command sent successfully'); + return { + success: true, + message: `Session saved with tag: ${tag}`, + }; + } catch (error) { + console.error('[QwenAgentManager] /chat save command failed:', error); + return { + success: false, + message: error instanceof Error ? error.message : String(error), + }; + } + } + + /** + * Save session via ACP session/save method (deprecated, CLI doesn't support) + * + * @deprecated Use saveSessionViaCommand instead + * @param sessionId - Session ID + * @param tag - Save tag + * @returns Save response + */ + async saveSessionViaAcp( + sessionId: string, + tag: string, + ): Promise<{ success: boolean; message?: string }> { + // Fallback to command-based save since CLI doesn't support session/save ACP method + console.warn( + '[QwenAgentManager] saveSessionViaAcp is deprecated, using command-based save instead', + ); + return this.saveSessionViaCommand(sessionId, tag); + } + + /** + * Try to load session via ACP session/load method + * This method will only be used if CLI version supports it + * + * @param sessionId - Session ID + * @returns Load response or error + */ + async loadSessionViaAcp( + sessionId: string, + cwdOverride?: string, + ): Promise { + try { + // Route upcoming session/update messages as discrete messages for replay + this.rehydratingSessionId = sessionId; + console.log( + '[QwenAgentManager] Rehydration start for session:', + sessionId, + ); + console.log( + '[QwenAgentManager] Attempting session/load via ACP for session:', + sessionId, + ); + const response = await this.connection.loadSession( + sessionId, + cwdOverride, + ); + console.log( + '[QwenAgentManager] Session load succeeded. Response:', + JSON.stringify(response).substring(0, 200), + ); + return response; + } catch (error) { + const errorMessage = + error instanceof Error ? error.message : String(error); + console.error( + '[QwenAgentManager] Session load via ACP failed for session:', + sessionId, + ); + console.error('[QwenAgentManager] Error type:', error?.constructor?.name); + console.error('[QwenAgentManager] Error message:', errorMessage); + + // Check if error is from ACP response + if (error && typeof error === 'object') { + // Safely check if 'error' property exists + if ('error' in error) { + const acpError = error as { + error?: { code?: number; message?: string }; + }; + if (acpError.error) { + console.error( + '[QwenAgentManager] ACP error code:', + acpError.error.code, + ); + console.error( + '[QwenAgentManager] ACP error message:', + acpError.error.message, + ); + } + } else { + console.error('[QwenAgentManager] Non-ACPIf error details:', error); + } + } + + throw error; + } finally { + // End rehydration routing regardless of outcome + console.log('[QwenAgentManager] Rehydration end for session:', sessionId); + this.rehydratingSessionId = null; + } + } + + /** + * Load session with version-aware strategy + * First tries ACP method if CLI version supports it, falls back to file system method + * + * @param sessionId - Session ID to load + * @returns Loaded session messages or null + */ + async loadSession(sessionId: string): Promise { + console.log( + '[QwenAgentManager] Loading session with version-aware strategy:', + sessionId, + ); + + try { + console.log( + '[QwenAgentManager] Attempting to load session via ACP method', + ); + await this.loadSessionViaAcp(sessionId); + console.log('[QwenAgentManager] Session loaded successfully via ACP'); + + // After loading via ACP, we still need to get messages from file system + // In future, we might get them directly from the ACP response + } catch (error) { + console.warn( + '[QwenAgentManager] ACP session load failed, falling back to file system method:', + error, + ); + } + + // Always fall back to file system method + try { + console.log( + '[QwenAgentManager] Loading session messages from file system', + ); + const messages = await this.loadSessionMessagesFromFile(sessionId); + console.log( + '[QwenAgentManager] Session messages loaded successfully from file system', + ); + return messages; + } catch (error) { + console.error( + '[QwenAgentManager] Failed to load session messages from file system:', + error, + ); + return null; + } + } + + /** + * Load session messages from file system + * + * @param sessionId - Session ID to load + * @returns Loaded session messages + */ + private async loadSessionMessagesFromFile( + sessionId: string, + ): Promise { + try { + console.log( + '[QwenAgentManager] Loading session from file system:', + sessionId, + ); + + // Load session from file system + const session = await this.sessionManager.loadSession( + sessionId, + this.currentWorkingDir, + ); + + if (!session) { + console.log( + '[QwenAgentManager] Session not found in file system:', + sessionId, + ); + return null; + } + + // Convert message format + const messages: ChatMessage[] = session.messages.map((msg) => ({ + role: msg.type === 'user' ? 'user' : 'assistant', + content: msg.content, + timestamp: new Date(msg.timestamp).getTime(), + })); + + return messages; + } catch (error) { + console.error( + '[QwenAgentManager] Session load from file system failed:', + error, + ); + throw error; + } + } + + /** + * Create new session + * + * Note: Authentication should be done in connect() method, only create session here + * + * @param workingDir - Working directory + * @returns Newly created session ID + */ + async createNewSession( + workingDir: string, + options?: AgentSessionOptions, + ): Promise { + const autoAuthenticate = options?.autoAuthenticate ?? true; + // Reuse existing session if present + if (this.connection.currentSessionId) { + return this.connection.currentSessionId; + } + // Deduplicate concurrent session/new attempts + if (this.sessionCreateInFlight) { + return this.sessionCreateInFlight; + } + + console.log('[QwenAgentManager] Creating new session...'); + + this.sessionCreateInFlight = (async () => { + try { + // Try to create a new ACP session. If Qwen asks for auth, let it handle authentication. + try { + await this.connection.newSession(workingDir); + } catch (err) { + const requiresAuth = isAuthenticationRequiredError(err); + + if (requiresAuth) { + if (!autoAuthenticate) { + console.warn( + '[QwenAgentManager] session/new requires authentication but auto-auth is disabled. Deferring until user logs in.', + ); + throw err; + } + console.warn( + '[QwenAgentManager] session/new requires authentication. Retrying with authenticate...', + ); + try { + // Let CLI handle authentication - it's the single source of truth + await this.connection.authenticate(authMethod); + console.log( + '[QwenAgentManager] createNewSession Authentication successful. Retrying session/new...', + ); + // Add a slight delay to ensure auth state is settled + await new Promise((resolve) => setTimeout(resolve, 300)); + await this.connection.newSession(workingDir); + } catch (reauthErr) { + console.error( + '[QwenAgentManager] Re-authentication failed:', + reauthErr, + ); + throw reauthErr; + } + } else { + throw err; + } + } + const newSessionId = this.connection.currentSessionId; + console.log( + '[QwenAgentManager] New session created with ID:', + newSessionId, + ); + return newSessionId; + } finally { + this.sessionCreateInFlight = null; + } + })(); + + return this.sessionCreateInFlight; + } + + /** + * Switch to specified session + * + * @param sessionId - Session ID + */ + async switchToSession(sessionId: string): Promise { + await this.connection.switchSession(sessionId); + } + + /** + * Cancel current prompt + */ + async cancelCurrentPrompt(): Promise { + console.log('[QwenAgentManager] Cancelling current prompt'); + await this.connection.cancelSession(); + } + + /** + * Register message callback + * + * @param callback - Message callback function + */ + onMessage(callback: (message: ChatMessage) => void): void { + this.callbacks.onMessage = callback; + this.sessionUpdateHandler.updateCallbacks(this.callbacks); + } + + /** + * Register stream chunk callback + * + * @param callback - Stream chunk callback function + */ + onStreamChunk(callback: (chunk: string) => void): void { + this.callbacks.onStreamChunk = callback; + this.sessionUpdateHandler.updateCallbacks(this.callbacks); + } + + /** + * Register thought chunk callback + * + * @param callback - Thought chunk callback function + */ + onThoughtChunk(callback: (chunk: string) => void): void { + this.callbacks.onThoughtChunk = callback; + this.sessionUpdateHandler.updateCallbacks(this.callbacks); + } + + /** + * Register tool call callback + * + * @param callback - Tool call callback function + */ + onToolCall(callback: (update: ToolCallUpdateData) => void): void { + this.callbacks.onToolCall = callback; + this.sessionUpdateHandler.updateCallbacks(this.callbacks); + } + + /** + * Register plan callback + * + * @param callback - Plan callback function + */ + onPlan(callback: (entries: PlanEntry[]) => void): void { + this.callbacks.onPlan = callback; + this.sessionUpdateHandler.updateCallbacks(this.callbacks); + } + + /** + * Register permission request callback + * + * @param callback - Permission request callback function + */ + onPermissionRequest( + callback: (request: AcpPermissionRequest) => Promise, + ): void { + this.callbacks.onPermissionRequest = callback; + this.sessionUpdateHandler.updateCallbacks(this.callbacks); + } + + /** + * Register end-of-turn callback + * + * @param callback - Called when ACP stopReason is reported + */ + onEndTurn(callback: (reason?: string) => void): void { + this.callbacks.onEndTurn = callback; + this.sessionUpdateHandler.updateCallbacks(this.callbacks); + } + + /** + * Register initialize mode info callback + */ + onModeInfo( + callback: (info: { + currentModeId?: 'plan' | 'default' | 'auto-edit' | 'yolo'; + availableModes?: Array<{ + id: 'plan' | 'default' | 'auto-edit' | 'yolo'; + name: string; + description: string; + }>; + }) => void, + ): void { + this.callbacks.onModeInfo = callback; + this.sessionUpdateHandler.updateCallbacks(this.callbacks); + } + + /** + * Register mode changed callback + */ + onModeChanged( + callback: (modeId: 'plan' | 'default' | 'auto-edit' | 'yolo') => void, + ): void { + this.callbacks.onModeChanged = callback; + this.sessionUpdateHandler.updateCallbacks(this.callbacks); + } + + /** + * Disconnect + */ + disconnect(): void { + this.connection.disconnect(); + } + + /** + * Check if connected + */ + get isConnected(): boolean { + return this.connection.isConnected; + } + + /** + * Get current session ID + */ + get currentSessionId(): string | null { + return this.connection.currentSessionId; + } +} diff --git a/packages/vscode-ide-companion/src/services/qwenConnectionHandler.ts b/packages/vscode-ide-companion/src/services/qwenConnectionHandler.ts new file mode 100644 index 000000000..c66ee23c6 --- /dev/null +++ b/packages/vscode-ide-companion/src/services/qwenConnectionHandler.ts @@ -0,0 +1,184 @@ +/** + * @license + * Copyright 2025 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + */ + +/** + * Qwen Connection Handler + * + * Handles Qwen Agent connection establishment, authentication, and session creation + */ + +import type { AcpConnection } from './acpConnection.js'; +import { isAuthenticationRequiredError } from '../utils/authErrors.js'; +import { authMethod } from '../types/acpTypes.js'; + +export interface QwenConnectionResult { + sessionCreated: boolean; + requiresAuth: boolean; +} + +/** + * Qwen Connection Handler class + * Handles connection, authentication, and session initialization + */ +export class QwenConnectionHandler { + /** + * Connect to Qwen service and establish session + * + * @param connection - ACP connection instance + * @param workingDir - Working directory + * @param cliPath - CLI path (optional, if provided will override the path in configuration) + */ + async connect( + connection: AcpConnection, + workingDir: string, + cliEntryPath: string, + options?: { + autoAuthenticate?: boolean; + }, + ): Promise { + const connectId = Date.now(); + console.log(`[QwenAgentManager] 🚀 CONNECT() CALLED - ID: ${connectId}`); + const autoAuthenticate = options?.autoAuthenticate ?? true; + let sessionCreated = false; + let requiresAuth = false; + + // Build extra CLI arguments (only essential parameters) + const extraArgs: string[] = []; + + await connection.connect(cliEntryPath!, workingDir, extraArgs); + + // Try to restore existing session or create new session + // Note: Auto-restore on connect is disabled to avoid surprising loads + // when user opens a "New Chat" tab. Restoration is now an explicit action + // (session selector → session/load) or handled by higher-level flows. + const sessionRestored = false; + + // Create new session if unable to restore + if (!sessionRestored) { + console.log( + '[QwenAgentManager] no sessionRestored, Creating new session...', + ); + + try { + console.log( + '[QwenAgentManager] Creating new session (letting CLI handle authentication)...', + ); + await this.newSessionWithRetry( + connection, + workingDir, + 3, + authMethod, + autoAuthenticate, + ); + console.log('[QwenAgentManager] New session created successfully'); + sessionCreated = true; + } catch (sessionError) { + const needsAuth = + autoAuthenticate === false && + isAuthenticationRequiredError(sessionError); + if (needsAuth) { + requiresAuth = true; + console.log( + '[QwenAgentManager] Session creation requires authentication; waiting for user-triggered login.', + ); + } else { + console.log( + `\n⚠️ [SESSION FAILED] newSessionWithRetry threw error\n`, + ); + console.log(`[QwenAgentManager] Error details:`, sessionError); + throw sessionError; + } + } + } else { + sessionCreated = true; + } + + console.log(`\n========================================`); + console.log(`[QwenAgentManager] ✅ CONNECT() COMPLETED SUCCESSFULLY`); + console.log(`========================================\n`); + return { sessionCreated, requiresAuth }; + } + + /** + * Create new session (with retry) + * + * @param connection - ACP connection instance + * @param workingDir - Working directory + * @param maxRetries - Maximum number of retries + */ + private async newSessionWithRetry( + connection: AcpConnection, + workingDir: string, + maxRetries: number, + authMethod: string, + autoAuthenticate: boolean, + ): Promise { + for (let attempt = 1; attempt <= maxRetries; attempt++) { + try { + console.log( + `[QwenAgentManager] Creating session (attempt ${attempt}/${maxRetries})...`, + ); + await connection.newSession(workingDir); + console.log('[QwenAgentManager] Session created successfully'); + return; + } catch (error) { + const errorMessage = + error instanceof Error ? error.message : String(error); + console.error( + `[QwenAgentManager] Session creation attempt ${attempt} failed:`, + errorMessage, + ); + + // If Qwen reports that authentication is required, try to + // authenticate on-the-fly once and retry without waiting. + const requiresAuth = isAuthenticationRequiredError(error); + if (requiresAuth) { + if (!autoAuthenticate) { + console.log( + '[QwenAgentManager] Authentication required but auto-authentication is disabled. Propagating error.', + ); + throw error; + } + console.log( + '[QwenAgentManager] Qwen requires authentication. Authenticating and retrying session/new...', + ); + try { + await connection.authenticate(authMethod); + // FIXME: @yiliang114 If there is no delay for a while, immediately executing + // newSession may cause the cli authorization jump to be triggered again + // Add a slight delay to ensure auth state is settled + await new Promise((resolve) => setTimeout(resolve, 300)); + console.log( + '[QwenAgentManager] newSessionWithRetry Authentication successful', + ); + // Retry immediately after successful auth + await connection.newSession(workingDir); + console.log( + '[QwenAgentManager] Session created successfully after auth', + ); + return; + } catch (authErr) { + console.error( + '[QwenAgentManager] Re-authentication failed:', + authErr, + ); + // Fall through to retry logic below + } + } + + if (attempt === maxRetries) { + throw new Error( + `Session creation failed after ${maxRetries} attempts: ${errorMessage}`, + ); + } + + const delay = Math.min(1000 * Math.pow(2, attempt - 1), 5000); + console.log(`[QwenAgentManager] Retrying in ${delay}ms...`); + await new Promise((resolve) => setTimeout(resolve, delay)); + } + } + } +} diff --git a/packages/vscode-ide-companion/src/services/qwenSessionManager.ts b/packages/vscode-ide-companion/src/services/qwenSessionManager.ts new file mode 100644 index 000000000..9336a060b --- /dev/null +++ b/packages/vscode-ide-companion/src/services/qwenSessionManager.ts @@ -0,0 +1,212 @@ +/** + * @license + * Copyright 2025 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + */ + +import * as fs from 'fs'; +import * as path from 'path'; +import * as os from 'os'; +import * as crypto from 'crypto'; +import type { QwenSession, QwenMessage } from './qwenSessionReader.js'; + +/** + * Qwen Session Manager + * + * This service provides direct filesystem access to save and load sessions + * without relying on the CLI's ACP session/save method. + * + * Note: This is primarily used as a fallback mechanism when ACP methods are + * unavailable or fail. In normal operation, ACP session/list and session/load + * should be preferred for consistency with the CLI. + */ +export class QwenSessionManager { + private qwenDir: string; + + constructor() { + this.qwenDir = path.join(os.homedir(), '.qwen'); + } + + /** + * 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 + */ + private getSessionDir(workingDir: string): string { + const projectHash = this.getProjectHash(workingDir); + return path.join(this.qwenDir, 'tmp', projectHash, 'chats'); + } + + /** + * Generate a new session ID + */ + private generateSessionId(): string { + return crypto.randomUUID(); + } + + /** + * Save current conversation as a named session + * + * @param messages - Current conversation messages + * @param sessionName - Name/tag for the saved session + * @param workingDir - Current working directory + * @returns Session ID of the saved session + */ + async saveSession( + messages: QwenMessage[], + sessionName: string, + workingDir: string, + ): Promise { + try { + // Create session directory if it doesn't exist + const sessionDir = this.getSessionDir(workingDir); + if (!fs.existsSync(sessionDir)) { + fs.mkdirSync(sessionDir, { recursive: true }); + } + + // Generate session ID and filename using CLI's naming convention + const sessionId = this.generateSessionId(); + const shortId = sessionId.split('-')[0]; // First part of UUID (8 chars) + const now = new Date(); + const isoDate = now.toISOString().split('T')[0]; // YYYY-MM-DD + const isoTime = now + .toISOString() + .split('T')[1] + .split(':') + .slice(0, 2) + .join('-'); // HH-MM + const filename = `session-${isoDate}T${isoTime}-${shortId}.json`; + const filePath = path.join(sessionDir, filename); + + // Create session object + const session: QwenSession = { + sessionId, + projectHash: this.getProjectHash(workingDir), + startTime: messages[0]?.timestamp || new Date().toISOString(), + lastUpdated: new Date().toISOString(), + messages, + }; + + // Save session to file + fs.writeFileSync(filePath, JSON.stringify(session, null, 2), 'utf-8'); + + console.log(`[QwenSessionManager] Session saved: ${filePath}`); + return sessionId; + } catch (error) { + console.error('[QwenSessionManager] Failed to save session:', error); + throw error; + } + } + + /** + * Load a saved session by name + * + * @param sessionName - Name/tag of the session to load + * @param workingDir - Current working directory + * @returns Loaded session or null if not found + */ + async loadSession( + sessionId: string, + workingDir: string, + ): Promise { + try { + const sessionDir = this.getSessionDir(workingDir); + const filename = `session-${sessionId}.json`; + const filePath = path.join(sessionDir, filename); + + if (!fs.existsSync(filePath)) { + console.log(`[QwenSessionManager] Session file not found: ${filePath}`); + return null; + } + + const content = fs.readFileSync(filePath, 'utf-8'); + const session = JSON.parse(content) as QwenSession; + + console.log(`[QwenSessionManager] Session loaded: ${filePath}`); + return session; + } catch (error) { + console.error('[QwenSessionManager] Failed to load session:', error); + return null; + } + } + + /** + * List all saved sessions + * + * @param workingDir - Current working directory + * @returns Array of session objects + */ + async listSessions(workingDir: string): Promise { + try { + const sessionDir = this.getSessionDir(workingDir); + + if (!fs.existsSync(sessionDir)) { + return []; + } + + const files = fs + .readdirSync(sessionDir) + .filter( + (file) => file.startsWith('session-') && file.endsWith('.json'), + ); + + const sessions: QwenSession[] = []; + for (const file of files) { + try { + const filePath = path.join(sessionDir, file); + const content = fs.readFileSync(filePath, 'utf-8'); + const session = JSON.parse(content) as QwenSession; + sessions.push(session); + } catch (error) { + console.error( + `[QwenSessionManager] Failed to read session file ${file}:`, + error, + ); + } + } + + // Sort by last updated time (newest first) + sessions.sort( + (a, b) => + new Date(b.lastUpdated).getTime() - new Date(a.lastUpdated).getTime(), + ); + + return sessions; + } catch (error) { + console.error('[QwenSessionManager] Failed to list sessions:', error); + return []; + } + } + + /** + * Delete a saved session + * + * @param sessionId - ID of the session to delete + * @param workingDir - Current working directory + * @returns True if deleted successfully, false otherwise + */ + async deleteSession(sessionId: string, workingDir: string): Promise { + try { + const sessionDir = this.getSessionDir(workingDir); + const filename = `session-${sessionId}.json`; + const filePath = path.join(sessionDir, filename); + + if (fs.existsSync(filePath)) { + fs.unlinkSync(filePath); + console.log(`[QwenSessionManager] Session deleted: ${filePath}`); + return true; + } + + return false; + } catch (error) { + console.error('[QwenSessionManager] Failed to delete session:', error); + return false; + } + } +} diff --git a/packages/vscode-ide-companion/src/services/qwenSessionReader.ts b/packages/vscode-ide-companion/src/services/qwenSessionReader.ts new file mode 100644 index 000000000..3fc4e484f --- /dev/null +++ b/packages/vscode-ide-companion/src/services/qwenSessionReader.ts @@ -0,0 +1,361 @@ +/** + * @license + * Copyright 2025 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + */ + +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'; + +export interface QwenMessage { + id: string; + timestamp: string; + type: 'user' | 'qwen'; + content: string; + thoughts?: unknown[]; + tokens?: { + input: number; + output: number; + cached: number; + thoughts: number; + tool: number; + total: number; + }; + model?: string; +} + +export interface QwenSession { + sessionId: string; + projectHash: string; + startTime: string; + lastUpdated: string; + messages: QwenMessage[]; + filePath?: string; + messageCount?: number; + firstUserText?: string; + cwd?: string; +} + +export class QwenSessionReader { + private qwenDir: string; + + constructor() { + this.qwenDir = path.join(os.homedir(), '.qwen'); + } + + /** + * Get all session list (optional: current project only or all projects) + */ + async getAllSessions( + workingDir?: string, + allProjects: boolean = false, + ): Promise { + try { + const sessions: QwenSession[] = []; + + if (!allProjects && workingDir) { + // Current project only + const projectHash = await this.getProjectHash(workingDir); + const chatsDir = path.join(this.qwenDir, 'tmp', projectHash, 'chats'); + const projectSessions = await this.readSessionsFromDir(chatsDir); + sessions.push(...projectSessions); + } else { + // All projects + const tmpDir = path.join(this.qwenDir, 'tmp'); + if (!fs.existsSync(tmpDir)) { + console.log('[QwenSessionReader] Tmp directory not found:', tmpDir); + return []; + } + + const projectDirs = fs.readdirSync(tmpDir); + for (const projectHash of projectDirs) { + const chatsDir = path.join(tmpDir, projectHash, 'chats'); + const projectSessions = await this.readSessionsFromDir(chatsDir); + sessions.push(...projectSessions); + } + } + + // Sort by last updated time + sessions.sort( + (a, b) => + new Date(b.lastUpdated).getTime() - new Date(a.lastUpdated).getTime(), + ); + + return sessions; + } catch (error) { + console.error('[QwenSessionReader] Failed to get sessions:', error); + return []; + } + } + + /** + * Read all sessions from specified directory + */ + private async readSessionsFromDir(chatsDir: string): Promise { + const sessions: QwenSession[] = []; + + if (!fs.existsSync(chatsDir)) { + return sessions; + } + + const files = fs.readdirSync(chatsDir); + + const jsonSessionFiles = files.filter( + (f) => f.startsWith('session-') && f.endsWith('.json'), + ); + + const jsonlSessionFiles = files.filter((f) => + /^[0-9a-fA-F-]{32,36}\.jsonl$/.test(f), + ); + + for (const file of jsonSessionFiles) { + const filePath = path.join(chatsDir, file); + try { + const content = fs.readFileSync(filePath, 'utf-8'); + const session = JSON.parse(content) as QwenSession; + session.filePath = filePath; + sessions.push(session); + } catch (error) { + console.error( + '[QwenSessionReader] Failed to read session file:', + filePath, + error, + ); + } + } + + // Support new JSONL session format produced by the CLI + for (const file of jsonlSessionFiles) { + const filePath = path.join(chatsDir, file); + try { + const session = await this.readJsonlSession(filePath, false); + if (session) { + sessions.push(session); + } + } catch (error) { + console.error( + '[QwenSessionReader] Failed to read JSONL session file:', + filePath, + error, + ); + } + } + + return sessions; + } + + /** + * Get details of specific session + */ + async getSession( + sessionId: string, + _workingDir?: string, + ): Promise { + // First try to find in all projects + const sessions = await this.getAllSessions(undefined, true); + const found = sessions.find((s) => s.sessionId === sessionId); + + if (!found) { + return null; + } + + // If the session points to a JSONL file, load full content on demand + if ( + found.filePath && + found.filePath.endsWith('.jsonl') && + found.messages.length === 0 + ) { + const hydrated = await this.readJsonlSession(found.filePath, true); + if (hydrated) { + return hydrated; + } + } + + 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) + */ + getSessionTitle(session: QwenSession): string { + // Prefer cached prompt text to avoid loading messages for JSONL sessions + if (session.firstUserText) { + return ( + session.firstUserText.substring(0, 50) + + (session.firstUserText.length > 50 ? '...' : '') + ); + } + + const firstUserMessage = session.messages.find((m) => m.type === 'user'); + if (firstUserMessage) { + // Extract first 50 characters as title + return ( + firstUserMessage.content.substring(0, 50) + + (firstUserMessage.content.length > 50 ? '...' : '') + ); + } + return 'Untitled Session'; + } + + /** + * Parse a JSONL session file written by the CLI. + * When includeMessages is false, only lightweight metadata is returned. + */ + private async readJsonlSession( + filePath: string, + includeMessages: boolean, + ): Promise { + try { + if (!fs.existsSync(filePath)) { + return null; + } + + const stats = fs.statSync(filePath); + const fileStream = fs.createReadStream(filePath, { encoding: 'utf-8' }); + const rl = readline.createInterface({ + input: fileStream, + crlfDelay: Infinity, + }); + + const messages: QwenMessage[] = []; + const seenUuids = new Set(); + let sessionId: string | undefined; + let startTime: string | undefined; + let firstUserText: string | undefined; + let cwd: string | undefined; + + for await (const line of rl) { + const trimmed = line.trim(); + if (!trimmed) { + continue; + } + + let obj: Record; + try { + obj = JSON.parse(trimmed) as Record; + } catch { + continue; + } + + if (!sessionId && typeof obj.sessionId === 'string') { + sessionId = obj.sessionId; + } + if (!startTime && typeof obj.timestamp === 'string') { + startTime = obj.timestamp; + } + if (!cwd && typeof obj.cwd === 'string') { + cwd = obj.cwd; + } + + const type = typeof obj.type === 'string' ? obj.type : ''; + if (type === 'user' || type === 'assistant') { + const uuid = typeof obj.uuid === 'string' ? obj.uuid : undefined; + if (uuid) { + seenUuids.add(uuid); + } + + const text = this.contentToText(obj.message); + if (includeMessages) { + messages.push({ + id: uuid || `${messages.length}`, + timestamp: typeof obj.timestamp === 'string' ? obj.timestamp : '', + type: type === 'user' ? 'user' : 'qwen', + content: text, + }); + } + + if (!firstUserText && type === 'user' && text) { + firstUserText = text; + } + } + } + + // Ensure stream is closed + rl.close(); + + if (!sessionId) { + return null; + } + + const projectHash = cwd + ? await this.getProjectHash(cwd) + : path.basename(path.dirname(path.dirname(filePath))); + + return { + sessionId, + projectHash, + startTime: startTime || new Date(stats.birthtimeMs).toISOString(), + lastUpdated: new Date(stats.mtimeMs).toISOString(), + messages: includeMessages ? messages : [], + filePath, + messageCount: seenUuids.size, + firstUserText, + cwd, + }; + } catch (error) { + console.error( + '[QwenSessionReader] Failed to parse JSONL session:', + error, + ); + return null; + } + } + + // Extract plain text from CLI Content structure + private contentToText(message: unknown): string { + try { + if (typeof message !== 'object' || message === null) { + return ''; + } + + const typed = message as { parts?: unknown[] }; + const parts = Array.isArray(typed.parts) ? typed.parts : []; + const texts: string[] = []; + for (const part of parts) { + if (typeof part !== 'object' || part === null) { + continue; + } + const p = part as Record; + if (typeof p.text === 'string') { + texts.push(p.text); + } else if (typeof p.data === 'string') { + texts.push(p.data); + } + } + return texts.join('\n'); + } catch { + return ''; + } + } + + /** + * Delete session file + */ + async deleteSession( + sessionId: string, + _workingDir: string, + ): Promise { + try { + const session = await this.getSession(sessionId, _workingDir); + if (session && session.filePath) { + fs.unlinkSync(session.filePath); + return true; + } + return false; + } catch (error) { + console.error('[QwenSessionReader] Failed to delete session:', error); + return false; + } + } +} diff --git a/packages/vscode-ide-companion/src/services/qwenSessionUpdateHandler.ts b/packages/vscode-ide-companion/src/services/qwenSessionUpdateHandler.ts new file mode 100644 index 000000000..d7b24bb2c --- /dev/null +++ b/packages/vscode-ide-companion/src/services/qwenSessionUpdateHandler.ts @@ -0,0 +1,163 @@ +/** + * @license + * Copyright 2025 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + */ + +/** + * Qwen Session Update Handler + * + * Handles session updates from ACP and dispatches them to appropriate callbacks + */ + +import type { AcpSessionUpdate } from '../types/acpTypes.js'; +import type { ApprovalModeValue } from '../types/approvalModeValueTypes.js'; +import type { QwenAgentCallbacks } from '../types/chatTypes.js'; + +/** + * Qwen Session Update Handler class + * Processes various session update events and calls appropriate callbacks + */ +export class QwenSessionUpdateHandler { + private callbacks: QwenAgentCallbacks; + + constructor(callbacks: QwenAgentCallbacks) { + this.callbacks = callbacks; + } + + /** + * Update callbacks + * + * @param callbacks - New callback collection + */ + updateCallbacks(callbacks: QwenAgentCallbacks): void { + this.callbacks = callbacks; + } + + /** + * Handle session update + * + * @param data - ACP session update data + */ + handleSessionUpdate(data: AcpSessionUpdate): void { + const update = data.update; + console.log( + '[SessionUpdateHandler] Processing update type:', + update.sessionUpdate, + ); + + switch (update.sessionUpdate) { + case 'user_message_chunk': + if (update.content?.text && this.callbacks.onStreamChunk) { + this.callbacks.onStreamChunk(update.content.text); + } + break; + + case 'agent_message_chunk': + if (update.content?.text && this.callbacks.onStreamChunk) { + this.callbacks.onStreamChunk(update.content.text); + } + break; + + case 'agent_thought_chunk': + if (update.content?.text) { + if (this.callbacks.onThoughtChunk) { + this.callbacks.onThoughtChunk(update.content.text); + } else if (this.callbacks.onStreamChunk) { + // Fallback to regular stream processing + console.log( + '[SessionUpdateHandler] 🧠 Falling back to onStreamChunk', + ); + this.callbacks.onStreamChunk(update.content.text); + } + } + break; + + case 'tool_call': { + // Handle new tool call + if (this.callbacks.onToolCall && 'toolCallId' in update) { + this.callbacks.onToolCall({ + toolCallId: update.toolCallId as string, + kind: (update.kind as string) || undefined, + title: (update.title as string) || undefined, + status: (update.status as string) || undefined, + rawInput: update.rawInput, + content: update.content as + | Array> + | undefined, + locations: update.locations as + | Array<{ path: string; line?: number | null }> + | undefined, + }); + } + break; + } + + case 'tool_call_update': { + if (this.callbacks.onToolCall && 'toolCallId' in update) { + this.callbacks.onToolCall({ + toolCallId: update.toolCallId as string, + kind: (update.kind as string) || undefined, + title: (update.title as string) || undefined, + status: (update.status as string) || undefined, + rawInput: update.rawInput, + content: update.content as + | Array> + | undefined, + locations: update.locations as + | Array<{ path: string; line?: number | null }> + | undefined, + }); + } + break; + } + + case 'plan': { + if ('entries' in update) { + const entries = update.entries as Array<{ + content: string; + priority: 'high' | 'medium' | 'low'; + status: 'pending' | 'in_progress' | 'completed'; + }>; + + if (this.callbacks.onPlan) { + this.callbacks.onPlan(entries); + } else if (this.callbacks.onStreamChunk) { + // Fallback to stream processing + const planText = + '\n📋 Plan:\n' + + entries + .map( + (entry, i) => + `${i + 1}. [${entry.priority}] ${entry.content}`, + ) + .join('\n'); + this.callbacks.onStreamChunk(planText); + } + } + break; + } + + case 'current_mode_update': { + // Notify UI about mode change + try { + const modeId = (update as unknown as { modeId?: ApprovalModeValue }) + .modeId; + if (modeId && this.callbacks.onModeChanged) { + this.callbacks.onModeChanged(modeId); + } + } catch (err) { + console.warn( + '[SessionUpdateHandler] Failed to handle mode update', + err, + ); + } + break; + } + + default: + console.log('[QwenAgentManager] Unhandled session update type'); + break; + } + } +} diff --git a/packages/vscode-ide-companion/src/types/acpTypes.ts b/packages/vscode-ide-companion/src/types/acpTypes.ts new file mode 100644 index 000000000..5ddbfd06d --- /dev/null +++ b/packages/vscode-ide-companion/src/types/acpTypes.ts @@ -0,0 +1,209 @@ +/** + * @license + * Copyright 2025 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + */ +import type { ApprovalModeValue } from './approvalModeValueTypes.js'; + +export const JSONRPC_VERSION = '2.0' as const; +export const authMethod = 'qwen-oauth'; + +export interface AcpRequest { + jsonrpc: typeof JSONRPC_VERSION; + id: number; + method: string; + params?: unknown; +} + +export interface AcpResponse { + jsonrpc: typeof JSONRPC_VERSION; + id: number; + result?: unknown; + capabilities?: { + [key: string]: unknown; + }; + error?: { + code: number; + message: string; + data?: unknown; + }; +} + +export interface AcpNotification { + jsonrpc: typeof JSONRPC_VERSION; + method: string; + params?: unknown; +} + +export interface BaseSessionUpdate { + sessionId: string; +} + +// Content block type (simplified version, use schema.ContentBlock for validation) +export interface ContentBlock { + type: 'text' | 'image'; + text?: string; + data?: string; + mimeType?: string; + uri?: string; +} + +export interface UserMessageChunkUpdate extends BaseSessionUpdate { + update: { + sessionUpdate: 'user_message_chunk'; + content: ContentBlock; + }; +} + +export interface AgentMessageChunkUpdate extends BaseSessionUpdate { + update: { + sessionUpdate: 'agent_message_chunk'; + content: ContentBlock; + }; +} + +export interface AgentThoughtChunkUpdate extends BaseSessionUpdate { + update: { + sessionUpdate: 'agent_thought_chunk'; + content: ContentBlock; + }; +} + +export interface ToolCallUpdate extends BaseSessionUpdate { + update: { + sessionUpdate: 'tool_call'; + toolCallId: string; + status: 'pending' | 'in_progress' | 'completed' | 'failed'; + title: string; + kind: + | 'read' + | 'edit' + | 'execute' + | 'delete' + | 'move' + | 'search' + | 'fetch' + | 'think' + | 'other'; + rawInput?: unknown; + content?: Array<{ + type: 'content' | 'diff'; + content?: { + type: 'text'; + text: string; + }; + path?: string; + oldText?: string | null; + newText?: string; + }>; + locations?: Array<{ + path: string; + line?: number | null; + }>; + }; +} + +export interface ToolCallStatusUpdate extends BaseSessionUpdate { + update: { + sessionUpdate: 'tool_call_update'; + toolCallId: string; + status?: 'pending' | 'in_progress' | 'completed' | 'failed'; + title?: string; + kind?: string; + rawInput?: unknown; + content?: Array<{ + type: 'content' | 'diff'; + content?: { + type: 'text'; + text: string; + }; + path?: string; + oldText?: string | null; + newText?: string; + }>; + locations?: Array<{ + path: string; + line?: number | null; + }>; + }; +} + +export interface PlanUpdate extends BaseSessionUpdate { + update: { + sessionUpdate: 'plan'; + entries: Array<{ + content: string; + priority: 'high' | 'medium' | 'low'; + status: 'pending' | 'in_progress' | 'completed'; + }>; + }; +} + +export { + ApprovalMode, + APPROVAL_MODE_MAP, + APPROVAL_MODE_INFO, + getApprovalModeInfoFromString, +} from './approvalModeTypes.js'; + +// Cyclic next-mode mapping used by UI toggles and other consumers +export const NEXT_APPROVAL_MODE: { + [k in ApprovalModeValue]: ApprovalModeValue; +} = { + // Hide "plan" from the public toggle sequence for now + // Cycle: default -> auto-edit -> yolo -> default + default: 'auto-edit', + 'auto-edit': 'yolo', + plan: 'yolo', + yolo: 'default', +}; + +// Current mode update (sent by agent when mode changes) +export interface CurrentModeUpdate extends BaseSessionUpdate { + update: { + sessionUpdate: 'current_mode_update'; + modeId: ApprovalModeValue; + }; +} + +// Authenticate update (sent by agent during authentication process) +export interface AuthenticateUpdateNotification { + _meta: { + authUri: string; + }; +} + +export type AcpSessionUpdate = + | UserMessageChunkUpdate + | AgentMessageChunkUpdate + | AgentThoughtChunkUpdate + | ToolCallUpdate + | ToolCallStatusUpdate + | PlanUpdate + | CurrentModeUpdate; + +// Permission request (simplified version, use schema.RequestPermissionRequest for validation) +export interface AcpPermissionRequest { + sessionId: string; + options: Array<{ + optionId: string; + name: string; + kind: 'allow_once' | 'allow_always' | 'reject_once' | 'reject_always'; + }>; + toolCall: { + toolCallId: string; + rawInput?: { + command?: string; + description?: string; + [key: string]: unknown; + }; + title?: string; + kind?: string; + }; +} + +export type AcpMessage = + | AcpRequest + | AcpNotification + | AcpResponse + | AcpSessionUpdate; diff --git a/packages/vscode-ide-companion/src/types/approvalModeTypes.ts b/packages/vscode-ide-companion/src/types/approvalModeTypes.ts new file mode 100644 index 000000000..ac9b22e53 --- /dev/null +++ b/packages/vscode-ide-companion/src/types/approvalModeTypes.ts @@ -0,0 +1,79 @@ +/** + * @license + * Copyright 2025 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + */ + +/** + * Enum for approval modes with UI-friendly labels + * Represents the different approval modes available in the ACP protocol + * with their corresponding user-facing display names + */ +export enum ApprovalMode { + PLAN = 'plan', + DEFAULT = 'default', + AUTO_EDIT = 'auto-edit', + YOLO = 'yolo', +} + +/** + * Mapping from string values to enum values for runtime conversion + */ +export const APPROVAL_MODE_MAP: Record = { + plan: ApprovalMode.PLAN, + default: ApprovalMode.DEFAULT, + 'auto-edit': ApprovalMode.AUTO_EDIT, + yolo: ApprovalMode.YOLO, +}; + +/** + * UI display information for each approval mode + */ +export const APPROVAL_MODE_INFO: Record< + ApprovalMode, + { + label: string; + title: string; + iconType?: 'edit' | 'auto' | 'plan' | 'yolo'; + } +> = { + [ApprovalMode.PLAN]: { + label: 'Plan mode', + title: 'Qwen will plan before executing. Click to switch modes.', + iconType: 'plan', + }, + [ApprovalMode.DEFAULT]: { + label: 'Ask before edits', + title: 'Qwen will ask before each edit. Click to switch modes.', + iconType: 'edit', + }, + [ApprovalMode.AUTO_EDIT]: { + label: 'Edit automatically', + title: 'Qwen will edit files automatically. Click to switch modes.', + iconType: 'auto', + }, + [ApprovalMode.YOLO]: { + label: 'YOLO', + title: 'Automatically approve all tools. Click to switch modes.', + iconType: 'yolo', + }, +}; + +/** + * Get UI display information for an approval mode from string value + */ +export function getApprovalModeInfoFromString(mode: string): { + label: string; + title: string; + iconType?: 'edit' | 'auto' | 'plan' | 'yolo'; +} { + const enumValue = APPROVAL_MODE_MAP[mode]; + if (enumValue !== undefined) { + return APPROVAL_MODE_INFO[enumValue]; + } + return { + label: 'Unknown mode', + title: 'Unknown edit mode', + iconType: undefined, + }; +} diff --git a/packages/vscode-ide-companion/src/types/approvalModeValueTypes.ts b/packages/vscode-ide-companion/src/types/approvalModeValueTypes.ts new file mode 100644 index 000000000..fe1f37e11 --- /dev/null +++ b/packages/vscode-ide-companion/src/types/approvalModeValueTypes.ts @@ -0,0 +1,11 @@ +/** + * @license + * Copyright 2025 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + */ + +/** + * Type for approval mode values + * Used in ACP protocol for controlling agent behavior + */ +export type ApprovalModeValue = 'plan' | 'default' | 'auto-edit' | 'yolo'; diff --git a/packages/vscode-ide-companion/src/types/chatTypes.ts b/packages/vscode-ide-companion/src/types/chatTypes.ts new file mode 100644 index 000000000..4cffd4ebc --- /dev/null +++ b/packages/vscode-ide-companion/src/types/chatTypes.ts @@ -0,0 +1,74 @@ +/** + * @license + * Copyright 2025 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + */ +import type { AcpPermissionRequest } from './acpTypes.js'; +import type { ApprovalModeValue } from './approvalModeValueTypes.js'; + +export interface ChatMessage { + role: 'user' | 'assistant'; + content: string; + timestamp: number; +} + +export interface PlanEntry { + content: string; + priority?: 'high' | 'medium' | 'low'; + status: 'pending' | 'in_progress' | 'completed'; +} + +export interface ToolCallUpdateData { + toolCallId: string; + kind?: string; + title?: string; + status?: string; + rawInput?: unknown; + content?: Array>; + locations?: Array<{ path: string; line?: number | null }>; +} + +export interface QwenAgentCallbacks { + onMessage?: (message: ChatMessage) => void; + onStreamChunk?: (chunk: string) => void; + onThoughtChunk?: (chunk: string) => void; + onToolCall?: (update: ToolCallUpdateData) => void; + onPlan?: (entries: PlanEntry[]) => void; + onPermissionRequest?: (request: AcpPermissionRequest) => Promise; + onEndTurn?: (reason?: string) => void; + onModeInfo?: (info: { + currentModeId?: ApprovalModeValue; + availableModes?: Array<{ + id: ApprovalModeValue; + name: string; + description: string; + }>; + }) => void; + onModeChanged?: (modeId: ApprovalModeValue) => void; +} + +export interface ToolCallUpdate { + type: 'tool_call' | 'tool_call_update'; + toolCallId: string; + kind?: string; + title?: string; + status?: 'pending' | 'in_progress' | 'completed' | 'failed'; + rawInput?: unknown; + content?: Array<{ + type: 'content' | 'diff'; + content?: { + type: string; + text?: string; + [key: string]: unknown; + }; + path?: string; + oldText?: string | null; + newText?: string; + [key: string]: unknown; + }>; + locations?: Array<{ + path: string; + line?: number | null; + }>; + timestamp?: number; // Add timestamp field for message ordering +} diff --git a/packages/vscode-ide-companion/src/types/completionItemTypes.ts b/packages/vscode-ide-companion/src/types/completionItemTypes.ts new file mode 100644 index 000000000..8bc884b34 --- /dev/null +++ b/packages/vscode-ide-companion/src/types/completionItemTypes.ts @@ -0,0 +1,19 @@ +/** + * @license + * Copyright 2025 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + */ + +import type React from 'react'; + +export interface CompletionItem { + id: string; + label: string; + description?: string; + icon?: React.ReactNode; + type: 'file' | 'folder' | 'symbol' | 'command' | 'variable' | 'info'; + // Value inserted into the input when selected (e.g., filename or command) + value?: string; + // Optional full path for files (used to build @filename -> full path mapping) + path?: string; +} diff --git a/packages/vscode-ide-companion/src/types/connectionTypes.ts b/packages/vscode-ide-companion/src/types/connectionTypes.ts new file mode 100644 index 000000000..7ada3aedf --- /dev/null +++ b/packages/vscode-ide-companion/src/types/connectionTypes.ts @@ -0,0 +1,36 @@ +/** + * @license + * Copyright 2025 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + */ + +import type { ChildProcess } from 'child_process'; +import type { + AcpSessionUpdate, + AcpPermissionRequest, + AuthenticateUpdateNotification, +} from './acpTypes.js'; + +export interface PendingRequest { + resolve: (value: T) => void; + reject: (error: Error) => void; + timeoutId?: NodeJS.Timeout; + method: string; +} + +export interface AcpConnectionCallbacks { + onSessionUpdate: (data: AcpSessionUpdate) => void; + onPermissionRequest: (data: AcpPermissionRequest) => Promise<{ + optionId: string; + }>; + onAuthenticateUpdate: (data: AuthenticateUpdateNotification) => void; + onEndTurn: (reason?: string) => void; +} + +export interface AcpConnectionState { + child: ChildProcess | null; + pendingRequests: Map>; + nextRequestId: number; + sessionId: string | null; + isInitialized: boolean; +} diff --git a/packages/vscode-ide-companion/src/utils/authErrors.ts b/packages/vscode-ide-companion/src/utils/authErrors.ts new file mode 100644 index 000000000..8b0e6af9d --- /dev/null +++ b/packages/vscode-ide-companion/src/utils/authErrors.ts @@ -0,0 +1,34 @@ +/** + * @license + * Copyright 2025 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + */ + +const AUTH_ERROR_PATTERNS = [ + 'Authentication required', // Standard authentication request message + '(code: -32000)', // RPC error code -32000 indicates authentication failure + 'Unauthorized', // HTTP unauthorized error + 'Invalid token', // Invalid token + 'Session expired', // Session expired +]; + +/** + * Determines if the given error is authentication-related + */ +export const isAuthenticationRequiredError = (error: unknown): boolean => { + // Null check to avoid unnecessary processing + if (!error) { + return false; + } + + // Extract error message text + const message = + error instanceof Error + ? error.message + : typeof error === 'string' + ? error + : String(error); + + // Match authentication-related errors using predefined patterns + return AUTH_ERROR_PATTERNS.some((pattern) => message.includes(pattern)); +}; diff --git a/packages/vscode-ide-companion/src/utils/authNotificationHandler.ts b/packages/vscode-ide-companion/src/utils/authNotificationHandler.ts new file mode 100644 index 000000000..362867c24 --- /dev/null +++ b/packages/vscode-ide-companion/src/utils/authNotificationHandler.ts @@ -0,0 +1,50 @@ +/** + * @license + * Copyright 2025 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + */ + +import * as vscode from 'vscode'; +import type { AuthenticateUpdateNotification } from '../types/acpTypes.js'; + +// Store reference to the current notification +let currentNotification: Thenable | null = null; + +/** + * Handle authentication update notifications by showing a VS Code notification + * with the authentication URI and action buttons. + * + * @param data - Authentication update notification data containing the auth URI + */ +export function handleAuthenticateUpdate( + data: AuthenticateUpdateNotification, +): void { + const authUri = data._meta.authUri; + + // Store reference to the current notification + currentNotification = vscode.window.showInformationMessage( + `Qwen Code needs authentication. Click an action below:`, + 'Open in Browser', + 'Copy Link', + 'Dismiss', + ); + + currentNotification.then((selection) => { + if (selection === 'Open in Browser') { + // Open the authentication URI in the default browser + vscode.env.openExternal(vscode.Uri.parse(authUri)); + vscode.window.showInformationMessage( + 'Opening authentication page in your browser...', + ); + } else if (selection === 'Copy Link') { + // Copy the authentication URI to clipboard + vscode.env.clipboard.writeText(authUri); + vscode.window.showInformationMessage( + 'Authentication link copied to clipboard!', + ); + } + + // Clear the notification reference after user interaction + currentNotification = null; + }); +} diff --git a/packages/vscode-ide-companion/src/utils/editorGroupUtils.ts b/packages/vscode-ide-companion/src/utils/editorGroupUtils.ts new file mode 100644 index 000000000..3bfc675f8 --- /dev/null +++ b/packages/vscode-ide-companion/src/utils/editorGroupUtils.ts @@ -0,0 +1,116 @@ +/** + * @license + * Copyright 2025 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + */ + +import * as vscode from 'vscode'; +import { openChatCommand } from '../commands/index.js'; + +/** + * Find the editor group immediately to the left of the Qwen chat webview. + * - If the chat webview group is the leftmost group, returns undefined. + * - Uses the webview tab viewType 'mainThreadWebview-qwenCode.chat'. + */ +export function findLeftGroupOfChatWebview(): vscode.ViewColumn | undefined { + try { + const groups = vscode.window.tabGroups.all; + + // Locate the group that contains our chat webview + const webviewGroup = groups.find((group) => + group.tabs.some((tab) => { + const input: unknown = (tab as { input?: unknown }).input; + const isWebviewInput = (inp: unknown): inp is { viewType: string } => + !!inp && typeof inp === 'object' && 'viewType' in inp; + return ( + isWebviewInput(input) && + input.viewType === 'mainThreadWebview-qwenCode.chat' + ); + }), + ); + + if (!webviewGroup) { + return undefined; + } + + // Among all groups to the left (smaller viewColumn), choose the one with + // the largest viewColumn value (i.e. the immediate neighbor on the left). + let candidate: + | { group: vscode.TabGroup; viewColumn: vscode.ViewColumn } + | undefined; + for (const g of groups) { + if (g.viewColumn < webviewGroup.viewColumn) { + if (!candidate || g.viewColumn > candidate.viewColumn) { + candidate = { group: g, viewColumn: g.viewColumn }; + } + } + } + + return candidate?.viewColumn; + } catch (_err) { + // Best-effort only; fall back to default behavior if anything goes wrong + return undefined; + } +} + +/** + * Ensure there is an editor group directly to the left of the Qwen chat webview. + * - If one exists, return its ViewColumn. + * - If none exists, focus the chat panel and create a new group on its left, + * then return the new group's ViewColumn (which equals the chat's previous column). + * - If the chat webview cannot be located, returns undefined. + */ +export async function ensureLeftGroupOfChatWebview(): Promise< + vscode.ViewColumn | undefined +> { + // First try to find an existing left neighbor + const existing = findLeftGroupOfChatWebview(); + if (existing !== undefined) { + return existing; + } + + // Locate the chat webview group + const groups = vscode.window.tabGroups.all; + const webviewGroup = groups.find((group) => + group.tabs.some((tab) => { + const input: unknown = (tab as { input?: unknown }).input; + const isWebviewInput = (inp: unknown): inp is { viewType: string } => + !!inp && typeof inp === 'object' && 'viewType' in inp; + return ( + isWebviewInput(input) && + input.viewType === 'mainThreadWebview-qwenCode.chat' + ); + }), + ); + + if (!webviewGroup) { + return undefined; + } + + const previousChatColumn = webviewGroup.viewColumn; + + // Make the chat group active by revealing the panel + try { + await vscode.commands.executeCommand(openChatCommand); + } catch { + // Best-effort; continue even if this fails + } + + // Create a new group to the left of the chat group + try { + await vscode.commands.executeCommand('workbench.action.newGroupLeft'); + } catch { + // If we fail to create a group, fall back to default behavior + return undefined; + } + + // Restore focus to chat (optional), so we don't disturb user focus + try { + await vscode.commands.executeCommand(openChatCommand); + } catch { + // Ignore + } + + // The new left group's column equals the chat's previous column + return previousChatColumn; +} diff --git a/packages/vscode-ide-companion/src/webview/App.tsx b/packages/vscode-ide-companion/src/webview/App.tsx new file mode 100644 index 000000000..5eacdabf0 --- /dev/null +++ b/packages/vscode-ide-companion/src/webview/App.tsx @@ -0,0 +1,829 @@ +/** + * @license + * Copyright 2025 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + */ + +import React, { + useState, + useEffect, + useRef, + useCallback, + useMemo, + useLayoutEffect, +} from 'react'; +import { useVSCode } from './hooks/useVSCode.js'; +import { useSessionManagement } from './hooks/session/useSessionManagement.js'; +import { useFileContext } from './hooks/file/useFileContext.js'; +import { useMessageHandling } from './hooks/message/useMessageHandling.js'; +import { useToolCalls } from './hooks/useToolCalls.js'; +import { useWebViewMessages } from './hooks/useWebViewMessages.js'; +import { useMessageSubmit } from './hooks/useMessageSubmit.js'; +import type { + PermissionOption, + ToolCall as PermissionToolCall, +} from './components/PermissionDrawer/PermissionRequest.js'; +import type { TextMessage } from './hooks/message/useMessageHandling.js'; +import type { ToolCallData } from './components/messages/toolcalls/ToolCall.js'; +import { PermissionDrawer } from './components/PermissionDrawer/PermissionDrawer.js'; +import { ToolCall } from './components/messages/toolcalls/ToolCall.js'; +import { hasToolCallOutput } from './components/messages/toolcalls/shared/utils.js'; +import { EmptyState } from './components/layout/EmptyState.js'; +import { Onboarding } from './components/layout/Onboarding.js'; +import { type CompletionItem } from '../types/completionItemTypes.js'; +import { useCompletionTrigger } from './hooks/useCompletionTrigger.js'; +import { ChatHeader } from './components/layout/ChatHeader.js'; +import { + UserMessage, + AssistantMessage, + ThinkingMessage, + WaitingMessage, + InterruptedMessage, +} from './components/messages/index.js'; +import { InputForm } from './components/layout/InputForm.js'; +import { SessionSelector } from './components/layout/SessionSelector.js'; +import { FileIcon, UserIcon } from './components/icons/index.js'; +import { ApprovalMode, NEXT_APPROVAL_MODE } from '../types/acpTypes.js'; +import type { ApprovalModeValue } from '../types/approvalModeValueTypes.js'; +import type { PlanEntry } from '../types/chatTypes.js'; + +export const App: React.FC = () => { + const vscode = useVSCode(); + + // Core hooks + const sessionManagement = useSessionManagement(vscode); + const fileContext = useFileContext(vscode); + const messageHandling = useMessageHandling(); + const { + inProgressToolCalls, + completedToolCalls, + handleToolCallUpdate, + clearToolCalls, + } = useToolCalls(); + + // UI state + const [inputText, setInputText] = useState(''); + const [permissionRequest, setPermissionRequest] = useState<{ + options: PermissionOption[]; + toolCall: PermissionToolCall; + } | null>(null); + const [planEntries, setPlanEntries] = useState([]); + const [isAuthenticated, setIsAuthenticated] = useState(null); + const [isLoading, setIsLoading] = useState(true); // Track if we're still initializing/loading + const messagesEndRef = useRef( + null, + ) as React.RefObject; + // Scroll container for message list; used to keep the view anchored to the latest content + const messagesContainerRef = useRef( + null, + ) as React.RefObject; + const inputFieldRef = useRef( + null, + ) as React.RefObject; + + const [editMode, setEditMode] = useState( + ApprovalMode.DEFAULT, + ); + const [thinkingEnabled, setThinkingEnabled] = useState(false); + const [isComposing, setIsComposing] = useState(false); + // When true, do NOT auto-attach the active editor file/selection to message context + const [skipAutoActiveContext, setSkipAutoActiveContext] = useState(false); + + // Completion system + const getCompletionItems = React.useCallback( + async (trigger: '@' | '/', query: string): Promise => { + if (trigger === '@') { + console.log('[App] getCompletionItems @ called', { + query, + requested: fileContext.hasRequestedFiles, + workspaceFiles: fileContext.workspaceFiles.length, + }); + // 始终根据当前 query 触发请求,让 hook 判断是否需要真正请求 + fileContext.requestWorkspaceFiles(query); + + const fileIcon = ; + const allItems: CompletionItem[] = fileContext.workspaceFiles.map( + (file) => ({ + id: file.id, + label: file.label, + description: file.description, + type: 'file' as const, + icon: fileIcon, + // Insert filename after @, keep path for mapping + value: file.label, + path: file.path, + }), + ); + + if (query && query.length >= 1) { + const lowerQuery = query.toLowerCase(); + return allItems.filter( + (item) => + item.label.toLowerCase().includes(lowerQuery) || + (item.description && + item.description.toLowerCase().includes(lowerQuery)), + ); + } + + // If first time and still loading, show a placeholder + if (allItems.length === 0) { + return [ + { + id: 'loading-files', + label: 'Searching files…', + description: 'Type to filter, or wait a moment…', + type: 'info' as const, + }, + ]; + } + + return allItems; + } else { + // Handle slash commands + const commands: CompletionItem[] = [ + { + id: 'login', + label: '/login', + description: 'Login to Qwen Code', + type: 'command', + icon: , + }, + ]; + + return commands.filter((cmd) => + cmd.label.toLowerCase().includes(query.toLowerCase()), + ); + } + }, + [fileContext], + ); + + const completion = useCompletionTrigger(inputFieldRef, getCompletionItems); + + // Track a lightweight signature of workspace files to detect content changes even when length is unchanged + const workspaceFilesSignature = useMemo( + () => + fileContext.workspaceFiles + .map( + (file) => + `${file.id}|${file.label}|${file.description ?? ''}|${file.path}`, + ) + .join('||'), + [fileContext.workspaceFiles], + ); + + // When workspace files update while menu open for @, refresh items so the first @ shows the list + // Note: Avoid depending on the entire `completion` object here, since its identity + // changes on every render which would retrigger this effect and can cause a refresh loop. + useEffect(() => { + // Only auto-refresh when there's no query (first @ popup) to avoid repeated refreshes during search + if ( + completion.isOpen && + completion.triggerChar === '@' && + !completion.query + ) { + // Only refresh items; do not change other completion state to avoid re-renders loops + completion.refreshCompletion(); + } + // Only re-run when the actual data source changes, not on every render + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [ + workspaceFilesSignature, + completion.isOpen, + completion.triggerChar, + completion.query, + ]); + + // Message submission + const { handleSubmit: submitMessage } = useMessageSubmit({ + inputText, + setInputText, + messageHandling, + fileContext, + skipAutoActiveContext, + vscode, + inputFieldRef, + isStreaming: messageHandling.isStreaming, + isWaitingForResponse: messageHandling.isWaitingForResponse, + }); + + // Handle cancel/stop from the input bar + // Emit a cancel to the extension and immediately reflect interruption locally. + const handleCancel = useCallback(() => { + if (messageHandling.isStreaming || messageHandling.isWaitingForResponse) { + // Proactively end local states and add an 'Interrupted' line + try { + messageHandling.endStreaming?.(); + } catch { + /* no-op */ + } + try { + messageHandling.clearWaitingForResponse?.(); + } catch { + /* no-op */ + } + messageHandling.addMessage({ + role: 'assistant', + content: 'Interrupted', + timestamp: Date.now(), + }); + } + // Notify extension/agent to cancel server-side work + vscode.postMessage({ + type: 'cancelStreaming', + data: {}, + }); + }, [messageHandling, vscode]); + + // Message handling + useWebViewMessages({ + sessionManagement, + fileContext, + messageHandling, + handleToolCallUpdate, + clearToolCalls, + setPlanEntries, + handlePermissionRequest: setPermissionRequest, + inputFieldRef, + setInputText, + setEditMode, + setIsAuthenticated, + }); + + // Auto-scroll handling: keep the view pinned to bottom when new content arrives, + // but don't interrupt the user if they scrolled up. + // We track whether the user is currently "pinned" to the bottom (near the end). + const [pinnedToBottom, setPinnedToBottom] = useState(true); + const prevCountsRef = useRef({ msgLen: 0, inProgLen: 0, doneLen: 0 }); + + // Observe scroll position to know if user has scrolled away from the bottom. + useEffect(() => { + const container = messagesContainerRef.current; + if (!container) { + return; + } + + const onScroll = () => { + // Use a small threshold so slight deltas don't flip the state. + // Note: there's extra bottom padding for the input area, so keep this a bit generous. + const threshold = 80; // px tolerance + const distanceFromBottom = + container.scrollHeight - (container.scrollTop + container.clientHeight); + setPinnedToBottom(distanceFromBottom <= threshold); + }; + + // Initialize once mounted so first render is correct + onScroll(); + container.addEventListener('scroll', onScroll, { passive: true }); + return () => container.removeEventListener('scroll', onScroll); + }, []); + + // When content changes, if the user is pinned to bottom, keep it anchored there. + // Only smooth-scroll when new items are appended; do not smooth for streaming chunk updates. + useLayoutEffect(() => { + const container = messagesContainerRef.current; + if (!container) { + return; + } + + // Detect whether new items were appended (vs. streaming chunk updates) + const prev = prevCountsRef.current; + const newMsg = messageHandling.messages.length > prev.msgLen; + const newInProg = inProgressToolCalls.length > prev.inProgLen; + const newDone = completedToolCalls.length > prev.doneLen; + prevCountsRef.current = { + msgLen: messageHandling.messages.length, + inProgLen: inProgressToolCalls.length, + doneLen: completedToolCalls.length, + }; + + if (!pinnedToBottom) { + // Do nothing if user scrolled away; avoid stealing scroll. + return; + } + + const smooth = newMsg || newInProg || newDone; // avoid smooth on streaming chunks + + // Anchor to the bottom on next frame to avoid layout thrash. + const raf = requestAnimationFrame(() => { + const top = container.scrollHeight - container.clientHeight; + // Use scrollTo to avoid cross-context issues with scrollIntoView. + container.scrollTo({ top, behavior: smooth ? 'smooth' : 'auto' }); + }); + return () => cancelAnimationFrame(raf); + }, [ + pinnedToBottom, + messageHandling.messages, + inProgressToolCalls, + completedToolCalls, + messageHandling.isWaitingForResponse, + messageHandling.loadingMessage, + messageHandling.isStreaming, + planEntries, + ]); + + // When the last rendered item resizes (e.g., images/code blocks load/expand), + // if we're pinned to bottom, keep it anchored there. + useEffect(() => { + const container = messagesContainerRef.current; + const endEl = messagesEndRef.current; + if (!container || !endEl) { + return; + } + + const lastItem = endEl.previousElementSibling as HTMLElement | null; + if (!lastItem) { + return; + } + + let frame = 0; + const ro = new ResizeObserver(() => { + if (!pinnedToBottom) { + return; + } + // Defer to next frame to avoid thrash during rapid size changes + cancelAnimationFrame(frame); + frame = requestAnimationFrame(() => { + const top = container.scrollHeight - container.clientHeight; + container.scrollTo({ top }); + }); + }); + ro.observe(lastItem); + + return () => { + cancelAnimationFrame(frame); + ro.disconnect(); + }; + }, [ + pinnedToBottom, + messageHandling.messages, + inProgressToolCalls, + completedToolCalls, + ]); + + // Set loading state to false after initial mount and when we have authentication info + useEffect(() => { + // If we have determined authentication status, we're done loading + if (isAuthenticated !== null) { + setIsLoading(false); + } + }, [isAuthenticated]); + + // Handle permission response + const handlePermissionResponse = useCallback( + (optionId: string) => { + // Forward the selected optionId directly to extension as ACP permission response + // Expected values include: 'proceed_once', 'proceed_always', 'cancel', 'proceed_always_server', etc. + vscode.postMessage({ + type: 'permissionResponse', + data: { optionId }, + }); + setPermissionRequest(null); + }, + [vscode], + ); + + // Handle completion selection + const handleCompletionSelect = useCallback( + (item: CompletionItem) => { + // Handle completion selection by inserting the value into the input field + const inputElement = inputFieldRef.current; + if (!inputElement) { + return; + } + + // Ignore info items (placeholders like "Searching files…") + if (item.type === 'info') { + completion.closeCompletion(); + return; + } + + // Slash commands can execute immediately + if (item.type === 'command') { + const command = (item.label || '').trim(); + if (command === '/login') { + vscode.postMessage({ type: 'login', data: {} }); + completion.closeCompletion(); + return; + } + } + + // If selecting a file, add @filename -> fullpath mapping + if (item.type === 'file' && item.value && item.path) { + try { + fileContext.addFileReference(item.value, item.path); + } catch (err) { + console.warn('[App] addFileReference failed:', err); + } + } + + const selection = window.getSelection(); + if (!selection || selection.rangeCount === 0) { + return; + } + + // Current text and cursor + const text = inputElement.textContent || ''; + const range = selection.getRangeAt(0); + + // Compute total text offset for contentEditable + let cursorPos = text.length; + if (range.startContainer === inputElement) { + const childIndex = range.startOffset; + let offset = 0; + for ( + let i = 0; + i < childIndex && i < inputElement.childNodes.length; + i++ + ) { + offset += inputElement.childNodes[i].textContent?.length || 0; + } + cursorPos = offset || text.length; + } else if (range.startContainer.nodeType === Node.TEXT_NODE) { + const walker = document.createTreeWalker( + inputElement, + NodeFilter.SHOW_TEXT, + null, + ); + let offset = 0; + let found = false; + let node: Node | null = walker.nextNode(); + while (node) { + if (node === range.startContainer) { + offset += range.startOffset; + found = true; + break; + } + offset += node.textContent?.length || 0; + node = walker.nextNode(); + } + cursorPos = found ? offset : text.length; + } + + // Replace from trigger to cursor with selected value + const textBeforeCursor = text.substring(0, cursorPos); + const atPos = textBeforeCursor.lastIndexOf('@'); + const slashPos = textBeforeCursor.lastIndexOf('/'); + const triggerPos = Math.max(atPos, slashPos); + + if (triggerPos >= 0) { + const insertValue = + typeof item.value === 'string' ? item.value : String(item.label); + const newText = + text.substring(0, triggerPos + 1) + // keep the trigger symbol + insertValue + + ' ' + + text.substring(cursorPos); + + // Update DOM and state, and move caret to end + inputElement.textContent = newText; + setInputText(newText); + + const newRange = document.createRange(); + const sel = window.getSelection(); + newRange.selectNodeContents(inputElement); + newRange.collapse(false); + sel?.removeAllRanges(); + sel?.addRange(newRange); + } + + // Close the completion menu + completion.closeCompletion(); + }, + [completion, inputFieldRef, setInputText, fileContext, vscode], + ); + + // Handle attach context click + const handleAttachContextClick = useCallback(() => { + // Open native file picker (different from '@' completion which searches workspace files) + vscode.postMessage({ + type: 'attachFile', + data: {}, + }); + }, [vscode]); + + // Handle toggle edit mode (Default -> Auto-edit -> YOLO -> Default) + const handleToggleEditMode = useCallback(() => { + setEditMode((prev) => { + const next: ApprovalModeValue = NEXT_APPROVAL_MODE[prev]; + + // Notify extension to set approval mode via ACP + try { + vscode.postMessage({ + type: 'setApprovalMode', + data: { modeId: next }, + }); + } catch { + /* no-op */ + } + return next; + }); + }, [vscode]); + + // Handle toggle thinking + const handleToggleThinking = () => { + setThinkingEnabled((prev) => !prev); + }; + + // When user sends a message after scrolling up, re-pin and jump to the bottom + const handleSubmitWithScroll = useCallback( + (e: React.FormEvent) => { + setPinnedToBottom(true); + + const container = messagesContainerRef.current; + if (container) { + const top = container.scrollHeight - container.clientHeight; + container.scrollTo({ top }); + } + + submitMessage(e); + }, + [submitMessage], + ); + + // Create unified message array containing all types of messages and tool calls + const allMessages = useMemo< + Array<{ + type: 'message' | 'in-progress-tool-call' | 'completed-tool-call'; + data: TextMessage | ToolCallData; + timestamp: number; + }> + >(() => { + // Regular messages + const regularMessages = messageHandling.messages.map((msg) => ({ + type: 'message' as const, + data: msg, + timestamp: msg.timestamp, + })); + + // In-progress tool calls + const inProgressTools = inProgressToolCalls.map((toolCall) => ({ + type: 'in-progress-tool-call' as const, + data: toolCall, + timestamp: toolCall.timestamp || Date.now(), + })); + + // Completed tool calls + const completedTools = completedToolCalls + .filter(hasToolCallOutput) + .map((toolCall) => ({ + type: 'completed-tool-call' as const, + data: toolCall, + timestamp: toolCall.timestamp || Date.now(), + })); + + // Merge and sort by timestamp to ensure messages and tool calls are interleaved + return [...regularMessages, ...inProgressTools, ...completedTools].sort( + (a, b) => (a.timestamp || 0) - (b.timestamp || 0), + ); + }, [messageHandling.messages, inProgressToolCalls, completedToolCalls]); + + console.log('[App] Rendering messages:', allMessages); + + // Render all messages and tool calls + const renderMessages = useCallback<() => React.ReactNode>( + () => + allMessages.map((item, index) => { + switch (item.type) { + case 'message': { + const msg = item.data as TextMessage; + const handleFileClick = (path: string): void => { + vscode.postMessage({ + type: 'openFile', + data: { path }, + }); + }; + + if (msg.role === 'thinking') { + return ( + + ); + } + + if (msg.role === 'user') { + return ( + + ); + } + + { + const content = (msg.content || '').trim(); + if (content === 'Interrupted' || content === 'Tool interrupted') { + return ( + + ); + } + return ( + + ); + } + } + + case 'in-progress-tool-call': + case 'completed-tool-call': { + const prev = allMessages[index - 1]; + const next = allMessages[index + 1]; + const isToolCallType = ( + x: unknown, + ): x is { type: 'in-progress-tool-call' | 'completed-tool-call' } => + !!x && + typeof x === 'object' && + 'type' in (x as Record) && + ((x as { type: string }).type === 'in-progress-tool-call' || + (x as { type: string }).type === 'completed-tool-call'); + const isFirst = !isToolCallType(prev); + const isLast = !isToolCallType(next); + return ( + + ); + } + + default: + return null; + } + }), + [allMessages, vscode], + ); + + const hasContent = + messageHandling.messages.length > 0 || + messageHandling.isStreaming || + inProgressToolCalls.length > 0 || + completedToolCalls.length > 0 || + planEntries.length > 0 || + allMessages.length > 0; + + return ( +
+ {/* Top-level loading overlay */} + {isLoading && ( +
+
+
+

+ Preparing Qwen Code... +

+
+
+ )} + + { + sessionManagement.handleSwitchSession(sessionId); + sessionManagement.setSessionSearchQuery(''); + }} + onClose={() => sessionManagement.setShowSessionSelector(false)} + hasMore={sessionManagement.hasMore} + isLoading={sessionManagement.isLoading} + onLoadMore={sessionManagement.handleLoadMoreSessions} + /> + + + +
+ {!hasContent && !isLoading ? ( + isAuthenticated === false ? ( + { + vscode.postMessage({ type: 'login', data: {} }); + messageHandling.setWaitingForResponse( + 'Logging in to Qwen Code...', + ); + }} + /> + ) : isAuthenticated === null ? ( + + ) : ( + + ) + ) : ( + <> + {/* Render all messages and tool calls */} + {renderMessages()} + + {/* Waiting message positioned fixed above the input form to avoid layout shifts */} + {messageHandling.isWaitingForResponse && + messageHandling.loadingMessage && ( +
+ +
+ )} +
+ + )} +
+ + {isAuthenticated && ( + setIsComposing(true)} + onCompositionEnd={() => setIsComposing(false)} + onKeyDown={() => {}} + onSubmit={handleSubmitWithScroll} + onCancel={handleCancel} + onToggleEditMode={handleToggleEditMode} + onToggleThinking={handleToggleThinking} + onFocusActiveEditor={fileContext.focusActiveEditor} + onToggleSkipAutoActiveContext={() => + setSkipAutoActiveContext((v) => !v) + } + onShowCommandMenu={async () => { + if (inputFieldRef.current) { + inputFieldRef.current.focus(); + + const selection = window.getSelection(); + let position = { top: 0, left: 0 }; + + if (selection && selection.rangeCount > 0) { + try { + const range = selection.getRangeAt(0); + const rangeRect = range.getBoundingClientRect(); + if (rangeRect.top > 0 && rangeRect.left > 0) { + position = { + top: rangeRect.top, + left: rangeRect.left, + }; + } else { + const inputRect = + inputFieldRef.current.getBoundingClientRect(); + position = { top: inputRect.top, left: inputRect.left }; + } + } catch (error) { + console.error('[App] Error getting cursor position:', error); + const inputRect = + inputFieldRef.current.getBoundingClientRect(); + position = { top: inputRect.top, left: inputRect.left }; + } + } else { + const inputRect = inputFieldRef.current.getBoundingClientRect(); + position = { top: inputRect.top, left: inputRect.left }; + } + + await completion.openCompletion('/', '', position); + } + }} + onAttachContext={handleAttachContextClick} + completionIsOpen={completion.isOpen} + completionItems={completion.items} + onCompletionSelect={handleCompletionSelect} + onCompletionClose={completion.closeCompletion} + /> + )} + + {isAuthenticated && permissionRequest && ( + setPermissionRequest(null)} + /> + )} +
+ ); +}; diff --git a/packages/vscode-ide-companion/src/webview/MessageHandler.ts b/packages/vscode-ide-companion/src/webview/MessageHandler.ts new file mode 100644 index 000000000..77d330b6b --- /dev/null +++ b/packages/vscode-ide-companion/src/webview/MessageHandler.ts @@ -0,0 +1,76 @@ +/** + * @license + * Copyright 2025 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + */ + +import type { QwenAgentManager } from '../services/qwenAgentManager.js'; +import type { ConversationStore } from '../services/conversationStore.js'; +import { MessageRouter } from './handlers/MessageRouter.js'; + +/** + * MessageHandler (Refactored Version) + * This is a lightweight wrapper class that internally uses MessageRouter and various sub-handlers + * Maintains interface compatibility with the original code + */ +export class MessageHandler { + private router: MessageRouter; + + constructor( + agentManager: QwenAgentManager, + conversationStore: ConversationStore, + currentConversationId: string | null, + sendToWebView: (message: unknown) => void, + ) { + this.router = new MessageRouter( + agentManager, + conversationStore, + currentConversationId, + sendToWebView, + ); + } + + /** + * Route messages to the corresponding handler + */ + async route(message: { type: string; data?: unknown }): Promise { + await this.router.route(message); + } + + /** + * Set current session ID + */ + setCurrentConversationId(id: string | null): void { + this.router.setCurrentConversationId(id); + } + + /** + * Get current session ID + */ + getCurrentConversationId(): string | null { + return this.router.getCurrentConversationId(); + } + + /** + * Set permission handler + */ + setPermissionHandler( + handler: (message: { type: string; data: { optionId: string } }) => void, + ): void { + this.router.setPermissionHandler(handler); + } + + /** + * Set login handler + */ + setLoginHandler(handler: () => Promise): void { + this.router.setLoginHandler(handler); + } + + /** + * Append stream content + */ + appendStreamContent(chunk: string): void { + this.router.appendStreamContent(chunk); + } +} diff --git a/packages/vscode-ide-companion/src/webview/PanelManager.ts b/packages/vscode-ide-companion/src/webview/PanelManager.ts new file mode 100644 index 000000000..44f1a6ecc --- /dev/null +++ b/packages/vscode-ide-companion/src/webview/PanelManager.ts @@ -0,0 +1,385 @@ +/** + * @license + * Copyright 2025 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + */ + +import * as vscode from 'vscode'; + +/** + * Panel and Tab Manager + * Responsible for managing the creation, display, and tab tracking of WebView Panels + */ +export class PanelManager { + private panel: vscode.WebviewPanel | null = null; + private panelTab: vscode.Tab | null = null; + // Best-effort tracking of the group (by view column) that currently hosts + // the Qwen webview. We update this when creating/revealing the panel and + // whenever we can capture the Tab from the tab model. + private panelGroupViewColumn: vscode.ViewColumn | null = null; + + constructor( + private extensionUri: vscode.Uri, + private onPanelDispose: () => void, + ) {} + + /** + * Get the current Panel + */ + getPanel(): vscode.WebviewPanel | null { + return this.panel; + } + + /** + * Set Panel (for restoration) + */ + setPanel(panel: vscode.WebviewPanel): void { + console.log('[PanelManager] Setting panel for restoration'); + this.panel = panel; + } + + /** + * Create new WebView Panel + * @returns Whether it is a newly created Panel + */ + async createPanel(): Promise { + if (this.panel) { + return false; // Panel already exists + } + + // First, check if there's an existing Qwen Code group + const existingGroup = this.findExistingQwenCodeGroup(); + + if (existingGroup) { + // If Qwen Code webview already exists in a locked group, create the new panel in that same group + console.log( + '[PanelManager] Found existing Qwen Code group, creating panel in same group', + ); + this.panel = vscode.window.createWebviewPanel( + 'qwenCode.chat', + 'Qwen Code', + { viewColumn: existingGroup.viewColumn, preserveFocus: false }, + { + enableScripts: true, + retainContextWhenHidden: true, + localResourceRoots: [ + vscode.Uri.joinPath(this.extensionUri, 'dist'), + vscode.Uri.joinPath(this.extensionUri, 'assets'), + ], + }, + ); + // Track the group column hosting this panel + this.panelGroupViewColumn = existingGroup.viewColumn; + } else { + // If no existing Qwen Code group, create a new group to the right of the active editor group + try { + // Create a new group to the right of the current active group + await vscode.commands.executeCommand('workbench.action.newGroupRight'); + } catch (error) { + console.warn( + '[PanelManager] Failed to create right editor group (continuing):', + error, + ); + // Fallback: create in current group + const activeColumn = + vscode.window.activeTextEditor?.viewColumn || vscode.ViewColumn.One; + this.panel = vscode.window.createWebviewPanel( + 'qwenCode.chat', + 'Qwen Code', + { viewColumn: activeColumn, preserveFocus: false }, + { + enableScripts: true, + retainContextWhenHidden: true, + localResourceRoots: [ + vscode.Uri.joinPath(this.extensionUri, 'dist'), + vscode.Uri.joinPath(this.extensionUri, 'assets'), + ], + }, + ); + // Lock the group after creation + await this.autoLockEditorGroup(); + return true; + } + + // Get the new group's view column (should be the active one after creating right) + const newGroupColumn = vscode.window.tabGroups.activeTabGroup.viewColumn; + + this.panel = vscode.window.createWebviewPanel( + 'qwenCode.chat', + 'Qwen Code', + { viewColumn: newGroupColumn, preserveFocus: false }, + { + enableScripts: true, + retainContextWhenHidden: true, + localResourceRoots: [ + vscode.Uri.joinPath(this.extensionUri, 'dist'), + vscode.Uri.joinPath(this.extensionUri, 'assets'), + ], + }, + ); + + // Lock the group after creation + await this.autoLockEditorGroup(); + + // Track the newly created group's column + this.panelGroupViewColumn = newGroupColumn; + } + + // Set panel icon to Qwen logo + this.panel.iconPath = vscode.Uri.joinPath( + this.extensionUri, + 'assets', + 'icon.png', + ); + + // Try to capture Tab info shortly after creation so we can track the + // precise group even if the user later drags the tab between groups. + this.captureTab(); + + return true; // New panel created + } + + /** + * Find the group and view column where the existing Qwen Code webview is located + * @returns The found group and view column, or undefined if not found + */ + private findExistingQwenCodeGroup(): + | { group: vscode.TabGroup; viewColumn: vscode.ViewColumn } + | undefined { + for (const group of vscode.window.tabGroups.all) { + for (const tab of group.tabs) { + const input: unknown = (tab as { input?: unknown }).input; + const isWebviewInput = (inp: unknown): inp is { viewType: string } => + !!inp && typeof inp === 'object' && 'viewType' in inp; + + if ( + isWebviewInput(input) && + input.viewType === 'mainThreadWebview-qwenCode.chat' + ) { + // Found an existing Qwen Code tab + console.log('[PanelManager] Found existing Qwen Code group:', { + viewColumn: group.viewColumn, + tabCount: group.tabs.length, + isActive: group.isActive, + }); + return { + group, + viewColumn: group.viewColumn, + }; + } + } + } + + return undefined; + } + + /** + * Auto-lock editor group (only called when creating a new Panel) + * After creating/revealing the WebviewPanel, lock the active editor group so + * the group stays dedicated (users can still unlock manually). We still + * temporarily unlock before creation to allow adding tabs to an existing + * group; this method restores the locked state afterwards. + */ + async autoLockEditorGroup(): Promise { + if (!this.panel) { + return; + } + + try { + // The newly created panel is focused (preserveFocus: false), so this + // locks the correct, active editor group. + await vscode.commands.executeCommand('workbench.action.lockEditorGroup'); + console.log('[PanelManager] Group locked after panel creation'); + } catch (error) { + console.warn('[PanelManager] Failed to lock editor group:', error); + } + } + + /** + * Show Panel (reveal if exists, otherwise do nothing) + * @param preserveFocus Whether to preserve focus + */ + revealPanel(preserveFocus: boolean = true): void { + if (this.panel) { + // Prefer revealing in the currently tracked group to avoid reflowing groups. + const trackedColumn = ( + this.panelTab as unknown as { + group?: { viewColumn?: vscode.ViewColumn }; + } + )?.group?.viewColumn as vscode.ViewColumn | undefined; + const targetColumn: vscode.ViewColumn = + trackedColumn ?? + this.panelGroupViewColumn ?? + vscode.window.tabGroups.activeTabGroup.viewColumn; + this.panel.reveal(targetColumn, preserveFocus); + } + } + + /** + * Capture the Tab corresponding to the WebView Panel + * Used for tracking and managing Tab state + */ + captureTab(): void { + if (!this.panel) { + return; + } + + // Defer slightly so the tab model is updated after create/reveal + setTimeout(() => { + const allTabs = vscode.window.tabGroups.all.flatMap((g) => g.tabs); + const match = allTabs.find((t) => { + // Type guard for webview tab input + const input: unknown = (t as { input?: unknown }).input; + const isWebviewInput = (inp: unknown): inp is { viewType: string } => + !!inp && typeof inp === 'object' && 'viewType' in inp; + const isWebview = isWebviewInput(input); + const sameViewType = isWebview && input.viewType === 'qwenCode.chat'; + const sameLabel = t.label === this.panel!.title; + return !!(sameViewType || sameLabel); + }); + this.panelTab = match ?? null; + // Update last-known group column if we can read it from the captured tab + try { + const groupViewColumn = ( + this.panelTab as unknown as { + group?: { viewColumn?: vscode.ViewColumn }; + } + )?.group?.viewColumn; + if (groupViewColumn !== null) { + this.panelGroupViewColumn = groupViewColumn as vscode.ViewColumn; + } + } catch { + // Best effort only; ignore if the API shape differs + } + }, 50); + } + + /** + * Register the dispose event handler for the Panel + * @param disposables Array used to store Disposable objects + */ + registerDisposeHandler(disposables: vscode.Disposable[]): void { + if (!this.panel) { + return; + } + + this.panel.onDidDispose( + () => { + // Capture the group we intend to clean up before we clear fields + const targetColumn: vscode.ViewColumn | null = + // Prefer the group from the captured tab if available + (( + this.panelTab as unknown as { + group?: { viewColumn?: vscode.ViewColumn }; + } + )?.group?.viewColumn as vscode.ViewColumn | undefined) ?? + // Fall back to our last-known group column + this.panelGroupViewColumn ?? + null; + + this.panel = null; + this.panelTab = null; + this.onPanelDispose(); + + // After VS Code updates its tab model, check if that group is now + // empty (and typically locked for Qwen). If so, close the group to + // avoid leaving an empty locked column when the user closes Qwen. + if (targetColumn !== null) { + const column: vscode.ViewColumn = targetColumn; + setTimeout(async () => { + try { + const groups = vscode.window.tabGroups.all; + const group = groups.find((g) => g.viewColumn === column); + // If the group that hosted Qwen is now empty, close it to avoid + // leaving an empty locked column around. VS Code's stable API + // does not expose the lock state on TabGroup, so we only check + // for emptiness here. + if (group && group.tabs.length === 0) { + // Focus the group we want to close + await this.focusGroupByColumn(column); + // Try closeGroup first; fall back to removeActiveEditorGroup + try { + await vscode.commands.executeCommand( + 'workbench.action.closeGroup', + ); + } catch { + try { + await vscode.commands.executeCommand( + 'workbench.action.removeActiveEditorGroup', + ); + } catch (err) { + console.warn( + '[PanelManager] Failed to close empty group after Qwen panel disposed:', + err, + ); + } + } + } + } catch (err) { + console.warn( + '[PanelManager] Error while trying to close empty Qwen group:', + err, + ); + } + }, 50); + } + }, + null, + disposables, + ); + } + + /** + * Focus the editor group at the given view column by stepping left/right. + * This avoids depending on Nth-group focus commands that may not exist. + */ + private async focusGroupByColumn(target: vscode.ViewColumn): Promise { + const maxHops = 20; // safety guard for unusual layouts + let hops = 0; + while ( + vscode.window.tabGroups.activeTabGroup.viewColumn !== target && + hops < maxHops + ) { + const current = vscode.window.tabGroups.activeTabGroup.viewColumn; + if (current < target) { + await vscode.commands.executeCommand( + 'workbench.action.focusRightGroup', + ); + } else if (current > target) { + await vscode.commands.executeCommand('workbench.action.focusLeftGroup'); + } else { + break; + } + hops++; + } + } + + /** + * Register the view state change event handler + * @param disposables Array used to store Disposable objects + */ + registerViewStateChangeHandler(disposables: vscode.Disposable[]): void { + if (!this.panel) { + return; + } + + this.panel.onDidChangeViewState( + () => { + if (this.panel && this.panel.visible) { + this.captureTab(); + } + }, + null, + disposables, + ); + } + + /** + * Dispose Panel + */ + dispose(): void { + this.panel?.dispose(); + this.panel = null; + this.panelTab = null; + } +} diff --git a/packages/vscode-ide-companion/src/webview/WebViewContent.ts b/packages/vscode-ide-companion/src/webview/WebViewContent.ts new file mode 100644 index 000000000..8f802c84f --- /dev/null +++ b/packages/vscode-ide-companion/src/webview/WebViewContent.ts @@ -0,0 +1,50 @@ +/** + * @license + * Copyright 2025 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + */ + +import * as vscode from 'vscode'; +import { escapeHtml } from './utils/webviewUtils.js'; + +/** + * WebView HTML Content Generator + * Responsible for generating the HTML content of the WebView + */ +export class WebViewContent { + /** + * Generate HTML content for the WebView + * @param panel WebView Panel + * @param extensionUri Extension URI + * @returns HTML string + */ + static generate( + panel: vscode.WebviewPanel, + extensionUri: vscode.Uri, + ): string { + const scriptUri = panel.webview.asWebviewUri( + vscode.Uri.joinPath(extensionUri, 'dist', 'webview.js'), + ); + + // Convert extension URI for webview access - this allows frontend to construct resource paths + const extensionUriForWebview = panel.webview.asWebviewUri(extensionUri); + + // Escape URI for HTML to prevent potential injection attacks + const safeExtensionUri = escapeHtml(extensionUriForWebview.toString()); + const safeScriptUri = escapeHtml(scriptUri.toString()); + + return ` + + + + + + Qwen Code + + +
+ + +`; + } +} diff --git a/packages/vscode-ide-companion/src/webview/WebViewProvider.ts b/packages/vscode-ide-companion/src/webview/WebViewProvider.ts new file mode 100644 index 000000000..4ab55283c --- /dev/null +++ b/packages/vscode-ide-companion/src/webview/WebViewProvider.ts @@ -0,0 +1,1207 @@ +/** + * @license + * Copyright 2025 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + */ + +import * as vscode from 'vscode'; +import { QwenAgentManager } from '../services/qwenAgentManager.js'; +import { ConversationStore } from '../services/conversationStore.js'; +import type { AcpPermissionRequest } from '../types/acpTypes.js'; +import { PanelManager } from '../webview/PanelManager.js'; +import { MessageHandler } from '../webview/MessageHandler.js'; +import { WebViewContent } from '../webview/WebViewContent.js'; +import { getFileName } from './utils/webviewUtils.js'; +import { type ApprovalModeValue } from '../types/approvalModeValueTypes.js'; +import { isAuthenticationRequiredError } from '../utils/authErrors.js'; + +export class WebViewProvider { + private panelManager: PanelManager; + private messageHandler: MessageHandler; + private agentManager: QwenAgentManager; + private conversationStore: ConversationStore; + private disposables: vscode.Disposable[] = []; + private agentInitialized = false; // Track if agent has been initialized + // Track a pending permission request and its resolver so extension commands + // can "simulate" user choice from the command palette (e.g. after accepting + // a diff, auto-allow read/execute, or auto-reject on cancel). + private pendingPermissionRequest: AcpPermissionRequest | null = null; + private pendingPermissionResolve: ((optionId: string) => void) | null = null; + // Track current ACP mode id to influence permission/diff behavior + private currentModeId: ApprovalModeValue | null = null; + + constructor( + private context: vscode.ExtensionContext, + private extensionUri: vscode.Uri, + ) { + this.agentManager = new QwenAgentManager(); + this.conversationStore = new ConversationStore(context); + this.panelManager = new PanelManager(extensionUri, () => { + // Panel dispose callback + this.disposables.forEach((d) => d.dispose()); + }); + this.messageHandler = new MessageHandler( + this.agentManager, + this.conversationStore, + null, + (message) => this.sendMessageToWebView(message), + ); + + // Set login handler for /login command - direct force re-login + this.messageHandler.setLoginHandler(async () => { + await this.forceReLogin(); + }); + + // Setup agent callbacks + this.agentManager.onMessage((message) => { + // Do not suppress messages during checkpoint saves. + // Checkpoint persistence now writes directly to disk and should not + // generate ACP session/update traffic. Suppressing here could drop + // legitimate history replay messages (e.g., session/load) or + // assistant replies when a new prompt starts while an async save is + // still finishing. + this.sendMessageToWebView({ + type: 'message', + data: message, + }); + }); + + this.agentManager.onStreamChunk((chunk: string) => { + // Always forward stream chunks; do not gate on checkpoint saves. + // See note in onMessage() above. + this.messageHandler.appendStreamContent(chunk); + this.sendMessageToWebView({ + type: 'streamChunk', + data: { chunk }, + }); + }); + + // Setup thought chunk handler + this.agentManager.onThoughtChunk((chunk: string) => { + // Always forward thought chunks; do not gate on checkpoint saves. + this.messageHandler.appendStreamContent(chunk); + this.sendMessageToWebView({ + type: 'thoughtChunk', + data: { chunk }, + }); + }); + + // Surface available modes and current mode (from ACP initialize) + this.agentManager.onModeInfo((info) => { + try { + const current = (info?.currentModeId || null) as + | 'plan' + | 'default' + | 'auto-edit' + | 'yolo' + | null; + this.currentModeId = current; + } catch (_error) { + // Ignore error when parsing mode info + } + this.sendMessageToWebView({ + type: 'modeInfo', + data: info || {}, + }); + }); + + // Surface mode changes (from ACP or immediate set_mode response) + this.agentManager.onModeChanged((modeId) => { + try { + this.currentModeId = modeId; + } catch (_error) { + // Ignore error when setting mode id + } + this.sendMessageToWebView({ + type: 'modeChanged', + data: { modeId }, + }); + }); + + // Setup end-turn handler from ACP stopReason notifications + this.agentManager.onEndTurn((reason) => { + // Ensure WebView exits streaming state even if no explicit streamEnd was emitted elsewhere + this.sendMessageToWebView({ + type: 'streamEnd', + data: { + timestamp: Date.now(), + reason: reason || 'end_turn', + }, + }); + }); + + // Note: Tool call updates are handled in handleSessionUpdate within QwenAgentManager + // and sent via onStreamChunk callback + this.agentManager.onToolCall((update) => { + // Always surface tool calls; they are part of the live assistant flow. + // Cast update to access sessionUpdate property + const updateData = update as unknown as Record; + + // Determine message type from sessionUpdate field + // If sessionUpdate is missing, infer from content: + // - If has kind/title/rawInput, it's likely initial tool_call + // - If only has status/content updates, it's tool_call_update + let messageType = updateData.sessionUpdate as string | undefined; + if (!messageType) { + // Infer type: if has kind or title, assume initial call; otherwise update + if (updateData.kind || updateData.title || updateData.rawInput) { + messageType = 'tool_call'; + } else { + messageType = 'tool_call_update'; + } + } + + this.sendMessageToWebView({ + type: 'toolCall', + data: { + type: messageType, + ...updateData, + }, + }); + }); + + // Setup plan handler + this.agentManager.onPlan((entries) => { + this.sendMessageToWebView({ + type: 'plan', + data: { entries }, + }); + }); + + this.agentManager.onPermissionRequest( + async (request: AcpPermissionRequest) => { + // Auto-approve in auto/yolo mode (no UI, no diff) + if (this.isAutoMode()) { + const options = request.options || []; + const pick = (substr: string) => + options.find((o) => + (o.optionId || '').toLowerCase().includes(substr), + )?.optionId; + const pickByKind = (k: string) => + options.find((o) => (o.kind || '').toLowerCase().includes(k)) + ?.optionId; + const optionId = + pick('allow_once') || + pickByKind('allow') || + pick('proceed') || + options[0]?.optionId || + 'allow_once'; + return optionId; + } + + // Send permission request to WebView + this.sendMessageToWebView({ + type: 'permissionRequest', + data: request, + }); + + // Wait for user response + return new Promise((resolve) => { + // cache the pending request and its resolver so commands can resolve it + this.pendingPermissionRequest = request; + this.pendingPermissionResolve = (optionId: string) => { + try { + resolve(optionId); + } finally { + // Always clear pending state + this.pendingPermissionRequest = null; + this.pendingPermissionResolve = null; + // Also instruct the webview UI to close its drawer if it is open + this.sendMessageToWebView({ + type: 'permissionResolved', + data: { optionId }, + }); + // If allowed/proceeded, close any open qwen-diff editors and suppress re-open briefly + const isCancel = + optionId === 'cancel' || + optionId.toLowerCase().includes('reject'); + if (!isCancel) { + try { + void vscode.commands.executeCommand('qwen.diff.closeAll'); + } catch (err) { + console.warn( + '[WebViewProvider] Failed to close diffs after allow (resolver):', + err, + ); + } + try { + void vscode.commands.executeCommand( + 'qwen.diff.suppressBriefly', + ); + } catch (err) { + console.warn( + '[WebViewProvider] Failed to suppress diffs briefly:', + err, + ); + } + } + } + }; + const handler = (message: { + type: string; + data: { optionId: string }; + }) => { + if (message.type !== 'permissionResponse') { + return; + } + + const optionId = message.data.optionId || ''; + + // 1) First resolve the optionId back to ACP so the agent isn't blocked + this.pendingPermissionResolve?.(optionId); + + // 2) If user cancelled/rejected, proactively stop current generation + const isCancel = + optionId === 'cancel' || + optionId.toLowerCase().includes('reject'); + + if (isCancel) { + // Fire and forget – do not block the ACP resolve + (async () => { + try { + // Stop server-side generation + await this.agentManager.cancelCurrentPrompt(); + } catch (err) { + console.warn( + '[WebViewProvider] cancelCurrentPrompt error:', + err, + ); + } + + // Ensure the webview exits streaming state immediately + this.sendMessageToWebView({ + type: 'streamEnd', + data: { timestamp: Date.now(), reason: 'user_cancelled' }, + }); + + // Synthesize a failed tool_call_update to match CLI UX + try { + const toolCallId = + (request.toolCall as { toolCallId?: string } | undefined) + ?.toolCallId || ''; + const title = + (request.toolCall as { title?: string } | undefined) + ?.title || ''; + // Normalize kind for UI – fall back to 'execute' + let kind = (( + request.toolCall as { kind?: string } | undefined + )?.kind || 'execute') as string; + if (!kind && title) { + const t = title.toLowerCase(); + if (t.includes('read') || t.includes('cat')) { + kind = 'read'; + } else if (t.includes('write') || t.includes('edit')) { + kind = 'edit'; + } else { + kind = 'execute'; + } + } + + this.sendMessageToWebView({ + type: 'toolCall', + data: { + type: 'tool_call_update', + toolCallId, + title, + kind, + status: 'failed', + // Best-effort pass-through (used by UI hints) + rawInput: (request.toolCall as { rawInput?: unknown }) + ?.rawInput, + locations: ( + request.toolCall as { + locations?: Array<{ + path: string; + line?: number | null; + }>; + } + )?.locations, + }, + }); + } catch (err) { + console.warn( + '[WebViewProvider] failed to synthesize failed tool_call_update:', + err, + ); + } + })(); + } + // If user allowed/proceeded, proactively close any open qwen-diff editors and suppress re-open briefly + else { + try { + void vscode.commands.executeCommand('qwen.diff.closeAll'); + } catch (err) { + console.warn( + '[WebViewProvider] Failed to close diffs after allow:', + err, + ); + } + try { + void vscode.commands.executeCommand( + 'qwen.diff.suppressBriefly', + ); + } catch (err) { + console.warn( + '[WebViewProvider] Failed to suppress diffs briefly:', + err, + ); + } + } + }; + // Store handler in message handler + this.messageHandler.setPermissionHandler(handler); + }); + }, + ); + } + + async show(): Promise { + const panel = this.panelManager.getPanel(); + + if (panel) { + // Reveal the existing panel + this.panelManager.revealPanel(true); + this.panelManager.captureTab(); + return; + } + + // Create new panel + const isNewPanel = await this.panelManager.createPanel(); + + if (!isNewPanel) { + return; // Failed to create panel + } + + const newPanel = this.panelManager.getPanel(); + if (!newPanel) { + return; + } + + // Set up state serialization + newPanel.onDidChangeViewState(() => { + console.log( + '[WebViewProvider] Panel view state changed, triggering serialization check', + ); + }); + + // Capture the Tab that corresponds to our WebviewPanel + this.panelManager.captureTab(); + + // Auto-lock editor group when opened in new column + await this.panelManager.autoLockEditorGroup(); + + newPanel.webview.html = WebViewContent.generate( + newPanel, + this.extensionUri, + ); + + // Handle messages from WebView + newPanel.webview.onDidReceiveMessage( + async (message: { type: string; data?: unknown }) => { + // Suppress UI-originated diff opens in auto/yolo mode + if (message.type === 'openDiff' && this.isAutoMode()) { + return; + } + // Allow webview to request updating the VS Code tab title + if (message.type === 'updatePanelTitle') { + const title = String( + (message.data as { title?: unknown } | undefined)?.title ?? '', + ).trim(); + const panelRef = this.panelManager.getPanel(); + if (panelRef) { + panelRef.title = title || 'Qwen Code'; + } + return; + } + await this.messageHandler.route(message); + }, + null, + this.disposables, + ); + + // Listen for view state changes (no pin/lock; just keep tab reference fresh) + this.panelManager.registerViewStateChangeHandler(this.disposables); + + // Register panel dispose handler + this.panelManager.registerDisposeHandler(this.disposables); + + // Listen for active editor changes and notify WebView + const editorChangeDisposable = vscode.window.onDidChangeActiveTextEditor( + (editor) => { + // If switching to a non-text editor (like webview), keep the last state + if (!editor) { + // Don't update - keep previous state + return; + } + + const filePath = editor.document.uri.fsPath || null; + const fileName = filePath ? getFileName(filePath) : null; + + // Get selection info if there is any selected text + let selectionInfo = null; + if (editor && !editor.selection.isEmpty) { + const selection = editor.selection; + selectionInfo = { + startLine: selection.start.line + 1, + endLine: selection.end.line + 1, + }; + } + + // Update last known state + + this.sendMessageToWebView({ + type: 'activeEditorChanged', + data: { fileName, filePath, selection: selectionInfo }, + }); + }, + ); + this.disposables.push(editorChangeDisposable); + + // Listen for text selection changes + const selectionChangeDisposable = + vscode.window.onDidChangeTextEditorSelection((event) => { + const editor = event.textEditor; + if (editor === vscode.window.activeTextEditor) { + const filePath = editor.document.uri.fsPath || null; + const fileName = filePath ? getFileName(filePath) : null; + + // Get selection info if there is any selected text + let selectionInfo = null; + if (!event.selections[0].isEmpty) { + const selection = event.selections[0]; + selectionInfo = { + startLine: selection.start.line + 1, + endLine: selection.end.line + 1, + }; + } + + // Update last known state + + this.sendMessageToWebView({ + type: 'activeEditorChanged', + data: { fileName, filePath, selection: selectionInfo }, + }); + + // Mode callbacks are registered in constructor; no-op here + } + }); + this.disposables.push(selectionChangeDisposable); + + // Send initial active editor state to WebView + const initialEditor = vscode.window.activeTextEditor; + if (initialEditor) { + const filePath = initialEditor.document.uri.fsPath || null; + const fileName = filePath ? getFileName(filePath) : null; + + let selectionInfo = null; + if (!initialEditor.selection.isEmpty) { + const selection = initialEditor.selection; + selectionInfo = { + startLine: selection.start.line + 1, + endLine: selection.end.line + 1, + }; + } + + this.sendMessageToWebView({ + type: 'activeEditorChanged', + data: { fileName, filePath, selection: selectionInfo }, + }); + } + + // Attempt to restore authentication state and initialize connection + console.log( + '[WebViewProvider] Attempting to restore auth state and connection...', + ); + await this.attemptAuthStateRestoration(); + } + + /** + * Attempt to restore authentication state and initialize connection + * This is called when the webview is first shown + */ + private async attemptAuthStateRestoration(): Promise { + try { + console.log('[WebViewProvider] Attempting connection...'); + // Attempt a connection to detect prior auth without forcing login + await this.initializeAgentConnection({ autoAuthenticate: false }); + } catch (error) { + console.error( + '[WebViewProvider] Error in attemptAuthStateRestoration:', + error, + ); + await this.initializeEmptyConversation(); + } + } + + /** + * Initialize agent connection and session + * Can be called from show() or via /login command + */ + async initializeAgentConnection(options?: { + autoAuthenticate?: boolean; + }): Promise { + return this.doInitializeAgentConnection(options); + } + + /** + * Internal: perform actual connection/initialization (no auth locking). + */ + private async doInitializeAgentConnection(options?: { + autoAuthenticate?: boolean; + }): Promise { + const autoAuthenticate = options?.autoAuthenticate ?? true; + const run = async () => { + const workspaceFolder = vscode.workspace.workspaceFolders?.[0]; + const workingDir = workspaceFolder?.uri.fsPath || process.cwd(); + + console.log( + '[WebViewProvider] Starting initialization, workingDir:', + workingDir, + ); + console.log( + `[WebViewProvider] Using CLI-managed authentication (autoAuth=${autoAuthenticate})`, + ); + + const bundledCliEntry = vscode.Uri.joinPath( + this.extensionUri, + 'dist', + 'qwen-cli', + 'cli.js', + ).fsPath; + + try { + console.log('[WebViewProvider] Connecting to agent...'); + + // Pass the detected CLI path to ensure we use the correct installation + const connectResult = await this.agentManager.connect( + workingDir, + bundledCliEntry, + options, + ); + console.log('[WebViewProvider] Agent connected successfully'); + this.agentInitialized = true; + + // If authentication is required and autoAuthenticate is false, + // send authState message and return without creating session + if (connectResult.requiresAuth && !autoAuthenticate) { + console.log( + '[WebViewProvider] Authentication required but auto-auth disabled, sending authState and returning', + ); + this.sendMessageToWebView({ + type: 'authState', + data: { authenticated: false }, + }); + // Initialize empty conversation to allow browsing history + await this.initializeEmptyConversation(); + return; + } + + if (connectResult.requiresAuth) { + this.sendMessageToWebView({ + type: 'authState', + data: { authenticated: false }, + }); + } + + // Load messages from the current Qwen session + const sessionReady = await this.loadCurrentSessionMessages(options); + + if (sessionReady) { + // Notify webview that agent is connected + this.sendMessageToWebView({ + type: 'agentConnected', + data: {}, + }); + } else { + console.log( + '[WebViewProvider] Session creation deferred until user logs in.', + ); + } + } catch (_error) { + console.error('[WebViewProvider] Agent connection error:', _error); + vscode.window.showWarningMessage( + `Failed to connect to Qwen CLI: ${_error}\nYou can still use the chat UI, but messages won't be sent to AI.`, + ); + // Fallback to empty conversation + await this.initializeEmptyConversation(); + + // Notify webview that agent connection failed + this.sendMessageToWebView({ + type: 'agentConnectionError', + data: { + message: _error instanceof Error ? _error.message : String(_error), + }, + }); + } + }; + + return run(); + } + + /** + * Force re-login by clearing auth cache and reconnecting + * Called when user explicitly uses /login command + */ + async forceReLogin(): Promise { + console.log('[WebViewProvider] Force re-login requested'); + + return vscode.window.withProgress( + { + location: vscode.ProgressLocation.Notification, + cancellable: false, + }, + async (progress) => { + try { + progress.report({ message: 'Preparing sign-in...' }); + + // Disconnect existing connection if any + if (this.agentInitialized) { + try { + this.agentManager.disconnect(); + console.log('[WebViewProvider] Existing connection disconnected'); + } catch (_error) { + console.log('[WebViewProvider] Error disconnecting:', _error); + } + this.agentInitialized = false; + } + + // Wait a moment for cleanup to complete + await new Promise((resolve) => setTimeout(resolve, 300)); + + progress.report({ + message: 'Connecting to CLI and starting sign-in...', + }); + + // Reinitialize connection (will trigger fresh authentication) + await this.doInitializeAgentConnection({ autoAuthenticate: true }); + console.log( + '[WebViewProvider] Force re-login completed successfully', + ); + + // Send success notification to WebView + this.sendMessageToWebView({ + type: 'loginSuccess', + data: { message: 'Successfully logged in!' }, + }); + } catch (_error) { + console.error('[WebViewProvider] Force re-login failed:', _error); + console.error( + '[WebViewProvider] Error stack:', + _error instanceof Error ? _error.stack : 'N/A', + ); + + // Send error notification to WebView + this.sendMessageToWebView({ + type: 'loginError', + data: { + message: `Login failed: ${_error instanceof Error ? _error.message : String(_error)}`, + }, + }); + + throw _error; + } + }, + ); + } + + /** + * Refresh connection without clearing auth cache + * Called when restoring WebView after VSCode restart + */ + async refreshConnection(): Promise { + console.log('[WebViewProvider] Refresh connection requested'); + + // Disconnect existing connection if any + if (this.agentInitialized) { + try { + this.agentManager.disconnect(); + console.log('[WebViewProvider] Existing connection disconnected'); + } catch (_error) { + console.log('[WebViewProvider] Error disconnecting:', _error); + } + this.agentInitialized = false; + } + + // Wait a moment for cleanup to complete + await new Promise((resolve) => setTimeout(resolve, 500)); + + // Reinitialize connection (will use cached auth if available) + try { + await this.initializeAgentConnection(); + console.log( + '[WebViewProvider] Connection refresh completed successfully', + ); + + // Notify webview that agent is connected after refresh + this.sendMessageToWebView({ + type: 'agentConnected', + data: {}, + }); + } catch (_error) { + console.error('[WebViewProvider] Connection refresh failed:', _error); + + // Notify webview that agent connection failed after refresh + this.sendMessageToWebView({ + type: 'agentConnectionError', + data: { + message: _error instanceof Error ? _error.message : String(_error), + }, + }); + + throw _error; + } + } + + /** + * Load messages from current Qwen session + * Skips session restoration and creates a new session directly + */ + private async loadCurrentSessionMessages(options?: { + autoAuthenticate?: boolean; + }): Promise { + const autoAuthenticate = options?.autoAuthenticate ?? true; + let sessionReady = false; + try { + console.log( + '[WebViewProvider] Initializing with new session (skipping restoration)', + ); + + const workspaceFolder = vscode.workspace.workspaceFolders?.[0]; + const workingDir = workspaceFolder?.uri.fsPath || process.cwd(); + + // avoid creating another session if connect() already created one. + if (!this.agentManager.currentSessionId) { + if (!autoAuthenticate) { + console.log( + '[WebViewProvider] Skipping ACP session creation until user logs in.', + ); + this.sendMessageToWebView({ + type: 'authState', + data: { authenticated: false }, + }); + } else { + try { + await this.agentManager.createNewSession(workingDir, { + autoAuthenticate, + }); + console.log('[WebViewProvider] ACP session created successfully'); + sessionReady = true; + } catch (sessionError) { + const requiresAuth = isAuthenticationRequiredError(sessionError); + if (requiresAuth && !autoAuthenticate) { + console.log( + '[WebViewProvider] ACP session requires authentication; waiting for explicit login.', + ); + this.sendMessageToWebView({ + type: 'authState', + data: { authenticated: false }, + }); + } else { + console.error( + '[WebViewProvider] Failed to create ACP session:', + sessionError, + ); + vscode.window.showWarningMessage( + `Failed to create ACP session: ${sessionError}. You may need to authenticate first.`, + ); + } + } + } + } else { + console.log( + '[WebViewProvider] Existing ACP session detected, skipping new session creation', + ); + sessionReady = true; + } + + await this.initializeEmptyConversation(); + } catch (_error) { + console.error( + '[WebViewProvider] Failed to load session messages:', + _error, + ); + vscode.window.showErrorMessage( + `Failed to load session messages: ${_error}`, + ); + await this.initializeEmptyConversation(); + return false; + } + + return sessionReady; + } + + /** + * Initialize an empty conversation + * Creates a new conversation and notifies WebView + */ + private async initializeEmptyConversation(): Promise { + try { + console.log('[WebViewProvider] Initializing empty conversation'); + const newConv = await this.conversationStore.createConversation(); + this.messageHandler.setCurrentConversationId(newConv.id); + this.sendMessageToWebView({ + type: 'conversationLoaded', + data: newConv, + }); + console.log( + '[WebViewProvider] Empty conversation initialized:', + this.messageHandler.getCurrentConversationId(), + ); + } catch (_error) { + console.error( + '[WebViewProvider] Failed to initialize conversation:', + _error, + ); + // Send empty state to WebView as fallback + this.sendMessageToWebView({ + type: 'conversationLoaded', + data: { id: 'temp', messages: [] }, + }); + } + } + + /** + * Send message to WebView + */ + private sendMessageToWebView(message: unknown): void { + const panel = this.panelManager.getPanel(); + panel?.webview.postMessage(message); + } + + /** + * Whether there is a pending permission decision awaiting an option. + */ + hasPendingPermission(): boolean { + return !!this.pendingPermissionResolve; + } + + /** Get current ACP mode id (if known). */ + getCurrentModeId(): ApprovalModeValue | null { + return this.currentModeId; + } + + /** True if diffs/permissions should be auto-handled without prompting. */ + isAutoMode(): boolean { + return this.currentModeId === 'auto-edit' || this.currentModeId === 'yolo'; + } + + /** Used by extension to decide if diffs should be suppressed. */ + shouldSuppressDiff(): boolean { + return this.isAutoMode(); + } + + /** + * Simulate selecting a permission option while a request drawer is open. + * The choice can be a concrete optionId or a shorthand intent. + */ + respondToPendingPermission( + choice: { optionId: string } | 'accept' | 'allow' | 'reject' | 'cancel', + ): void { + if (!this.pendingPermissionResolve || !this.pendingPermissionRequest) { + return; // nothing to do + } + + const options = this.pendingPermissionRequest.options || []; + + const pickByKind = (substr: string, preferOnce = false) => { + const lc = substr.toLowerCase(); + const filtered = options.filter((o) => + (o.kind || '').toLowerCase().includes(lc), + ); + if (preferOnce) { + const once = filtered.find((o) => + (o.optionId || '').toLowerCase().includes('once'), + ); + if (once) { + return once.optionId; + } + } + return filtered[0]?.optionId; + }; + + const pickByOptionId = (substr: string) => + options.find((o) => (o.optionId || '').toLowerCase().includes(substr)) + ?.optionId; + + let optionId: string | undefined; + + if (typeof choice === 'object') { + optionId = choice.optionId; + } else { + const c = choice.toLowerCase(); + if (c === 'accept' || c === 'allow') { + // Prefer an allow_once/proceed_once style option, then any allow/proceed + optionId = + pickByKind('allow', true) || + pickByOptionId('proceed_once') || + pickByKind('allow') || + pickByOptionId('proceed') || + options[0]?.optionId; // last resort: first option + } else if (c === 'cancel' || c === 'reject') { + // Prefer explicit cancel, then a reject option + optionId = + options.find((o) => o.optionId === 'cancel')?.optionId || + pickByKind('reject') || + pickByOptionId('cancel') || + pickByOptionId('reject') || + 'cancel'; + } + } + + if (!optionId) { + return; + } + + try { + this.pendingPermissionResolve(optionId); + } catch (_error) { + console.warn( + '[WebViewProvider] respondToPendingPermission failed:', + _error, + ); + } + } + + /** + * Reset agent initialization state + * Call this when auth cache is cleared to force re-authentication + */ + resetAgentState(): void { + console.log('[WebViewProvider] Resetting agent state'); + this.agentInitialized = false; + // Disconnect existing connection + this.agentManager.disconnect(); + } + + /** + * Restore an existing WebView panel (called during VSCode restart) + * This sets up the panel with all event listeners + */ + async restorePanel(panel: vscode.WebviewPanel): Promise { + console.log('[WebViewProvider] Restoring WebView panel'); + console.log( + '[WebViewProvider] Using CLI-managed authentication in restore', + ); + this.panelManager.setPanel(panel); + + // Ensure restored tab title starts from default label + try { + panel.title = 'Qwen Code'; + } catch (e) { + console.warn( + '[WebViewProvider] Failed to reset restored panel title:', + e, + ); + } + + panel.webview.html = WebViewContent.generate(panel, this.extensionUri); + + // Handle messages from WebView (restored panel) + panel.webview.onDidReceiveMessage( + async (message: { type: string; data?: unknown }) => { + // Suppress UI-originated diff opens in auto/yolo mode + if (message.type === 'openDiff' && this.isAutoMode()) { + return; + } + if (message.type === 'updatePanelTitle') { + const title = String( + (message.data as { title?: unknown } | undefined)?.title ?? '', + ).trim(); + const panelRef = this.panelManager.getPanel(); + if (panelRef) { + panelRef.title = title || 'Qwen Code'; + } + return; + } + await this.messageHandler.route(message); + }, + null, + this.disposables, + ); + + // Register view state change handler + this.panelManager.registerViewStateChangeHandler(this.disposables); + + // Register dispose handler + this.panelManager.registerDisposeHandler(this.disposables); + + // Listen for active editor changes and notify WebView + const editorChangeDisposable = vscode.window.onDidChangeActiveTextEditor( + (editor) => { + // If switching to a non-text editor (like webview), keep the last state + if (!editor) { + // Don't update - keep previous state + return; + } + + const filePath = editor.document.uri.fsPath || null; + const fileName = filePath ? getFileName(filePath) : null; + + // Get selection info if there is any selected text + let selectionInfo = null; + if (editor && !editor.selection.isEmpty) { + const selection = editor.selection; + selectionInfo = { + startLine: selection.start.line + 1, + endLine: selection.end.line + 1, + }; + } + + // Update last known state + + this.sendMessageToWebView({ + type: 'activeEditorChanged', + data: { fileName, filePath, selection: selectionInfo }, + }); + }, + ); + this.disposables.push(editorChangeDisposable); + + // Send initial active editor state to WebView + const initialEditor = vscode.window.activeTextEditor; + if (initialEditor) { + const filePath = initialEditor.document.uri.fsPath || null; + const fileName = filePath ? getFileName(filePath) : null; + + let selectionInfo = null; + if (!initialEditor.selection.isEmpty) { + const selection = initialEditor.selection; + selectionInfo = { + startLine: selection.start.line + 1, + endLine: selection.end.line + 1, + }; + } + + this.sendMessageToWebView({ + type: 'activeEditorChanged', + data: { fileName, filePath, selection: selectionInfo }, + }); + } + + // Listen for text selection changes (restore path) + const selectionChangeDisposableRestore = + vscode.window.onDidChangeTextEditorSelection((event) => { + const editor = event.textEditor; + if (editor === vscode.window.activeTextEditor) { + const filePath = editor.document.uri.fsPath || null; + const fileName = filePath ? getFileName(filePath) : null; + + // Get selection info if there is any selected text + let selectionInfo = null; + if (!event.selections[0].isEmpty) { + const selection = event.selections[0]; + selectionInfo = { + startLine: selection.start.line + 1, + endLine: selection.end.line + 1, + }; + } + + // Update last known state + + this.sendMessageToWebView({ + type: 'activeEditorChanged', + data: { fileName, filePath, selection: selectionInfo }, + }); + } + }); + this.disposables.push(selectionChangeDisposableRestore); + + // Capture the tab reference on restore + this.panelManager.captureTab(); + + console.log('[WebViewProvider] Panel restored successfully'); + + // Attempt to restore authentication state and initialize connection + console.log( + '[WebViewProvider] Attempting to restore auth state and connection after restore...', + ); + await this.attemptAuthStateRestoration(); + } + + /** + * Get the current state for serialization + * This is used when VSCode restarts to restore the WebView + */ + getState(): { + conversationId: string | null; + agentInitialized: boolean; + } { + console.log('[WebViewProvider] Getting state for serialization'); + console.log( + '[WebViewProvider] Current conversationId:', + this.messageHandler.getCurrentConversationId(), + ); + console.log( + '[WebViewProvider] Current agentInitialized:', + this.agentInitialized, + ); + const state = { + conversationId: this.messageHandler.getCurrentConversationId(), + agentInitialized: this.agentInitialized, + }; + console.log('[WebViewProvider] Returning state:', state); + return state; + } + + /** + * Get the current panel + */ + getPanel(): vscode.WebviewPanel | null { + return this.panelManager.getPanel(); + } + + /** + * Restore state after VSCode restart + */ + restoreState(state: { + conversationId: string | null; + agentInitialized: boolean; + }): void { + console.log('[WebViewProvider] Restoring state:', state); + this.messageHandler.setCurrentConversationId(state.conversationId); + this.agentInitialized = state.agentInitialized; + console.log( + '[WebViewProvider] State restored. agentInitialized:', + this.agentInitialized, + ); + + // Reload content after restore + const panel = this.panelManager.getPanel(); + if (panel) { + panel.webview.html = WebViewContent.generate(panel, this.extensionUri); + } + } + + /** + * Create a new session in the current panel + * This is called when the user clicks the "New Session" button + */ + async createNewSession(): Promise { + // WebView mode - create new session via agent manager + try { + const workspaceFolder = vscode.workspace.workspaceFolders?.[0]; + const workingDir = workspaceFolder?.uri.fsPath || process.cwd(); + + // Create new Qwen session via agent manager + await this.agentManager.createNewSession(workingDir); + + // Clear current conversation UI + this.sendMessageToWebView({ + type: 'conversationCleared', + data: {}, + }); + } catch (_error) { + console.error('[WebViewProvider] Failed to create new session:', _error); + vscode.window.showErrorMessage(`Failed to create new session: ${_error}`); + } + } + + /** + * Dispose the WebView provider and clean up resources + */ + dispose(): void { + this.panelManager.dispose(); + this.agentManager.disconnect(); + this.disposables.forEach((d) => d.dispose()); + } +} diff --git a/packages/vscode-ide-companion/src/webview/components/PermissionDrawer/PermissionDrawer.tsx b/packages/vscode-ide-companion/src/webview/components/PermissionDrawer/PermissionDrawer.tsx new file mode 100644 index 000000000..00e5bccaa --- /dev/null +++ b/packages/vscode-ide-companion/src/webview/components/PermissionDrawer/PermissionDrawer.tsx @@ -0,0 +1,312 @@ +/** + * @license + * Copyright 2025 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + */ + +import type React from 'react'; +import { useEffect, useState, useRef } from 'react'; +import type { PermissionOption, ToolCall } from './PermissionRequest.js'; + +interface PermissionDrawerProps { + isOpen: boolean; + options: PermissionOption[]; + toolCall: ToolCall; + onResponse: (optionId: string) => void; + onClose?: () => void; +} + +export const PermissionDrawer: React.FC = ({ + isOpen, + options, + toolCall, + onResponse, + onClose, +}) => { + const [focusedIndex, setFocusedIndex] = useState(0); + const [customMessage, setCustomMessage] = useState(''); + const containerRef = useRef(null); + // Correct the ref type for custom input to HTMLInputElement to avoid subsequent forced casting + const customInputRef = useRef(null); + + console.log('PermissionDrawer rendered with isOpen:', isOpen, toolCall); + // Prefer file name from locations, fall back to content[].path if present + const getAffectedFileName = (): string => { + const fromLocations = toolCall.locations?.[0]?.path; + if (fromLocations) { + return fromLocations.split('/').pop() || fromLocations; + } + // Some tool calls (e.g. write/edit with diff content) only include path in content + const fromContent = Array.isArray(toolCall.content) + ? ( + toolCall.content.find( + (c: unknown) => + typeof c === 'object' && + c !== null && + 'path' in (c as Record), + ) as { path?: unknown } | undefined + )?.path + : undefined; + if (typeof fromContent === 'string' && fromContent.length > 0) { + return fromContent.split('/').pop() || fromContent; + } + return 'file'; + }; + + // Get the title for the permission request + const getTitle = () => { + if (toolCall.kind === 'edit' || toolCall.kind === 'write') { + const fileName = getAffectedFileName(); + return ( + <> + Make this edit to{' '} + + {fileName} + + ? + + ); + } + if (toolCall.kind === 'execute' || toolCall.kind === 'bash') { + return 'Allow this bash command?'; + } + if (toolCall.kind === 'read') { + const fileName = getAffectedFileName(); + return ( + <> + Allow read from{' '} + + {fileName} + + ? + + ); + } + return toolCall.title || 'Permission Required'; + }; + + // Handle keyboard navigation + useEffect(() => { + const handleKeyDown = (e: KeyboardEvent) => { + if (!isOpen) { + return; + } + + // Number keys 1-9 for quick select + const numMatch = e.key.match(/^[1-9]$/); + if ( + numMatch && + !customInputRef.current?.contains(document.activeElement) + ) { + const index = parseInt(e.key, 10) - 1; + if (index < options.length) { + e.preventDefault(); + onResponse(options[index].optionId); + } + return; + } + + // Arrow keys for navigation + if (e.key === 'ArrowDown' || e.key === 'ArrowUp') { + e.preventDefault(); + const totalItems = options.length + 1; // +1 for custom input + if (e.key === 'ArrowDown') { + setFocusedIndex((prev) => (prev + 1) % totalItems); + } else { + setFocusedIndex((prev) => (prev - 1 + totalItems) % totalItems); + } + } + + // Enter to select + if ( + e.key === 'Enter' && + !customInputRef.current?.contains(document.activeElement) + ) { + e.preventDefault(); + if (focusedIndex < options.length) { + onResponse(options[focusedIndex].optionId); + } + } + + // Escape to cancel permission and close (align with CLI behavior) + if (e.key === 'Escape') { + e.preventDefault(); + const rejectOptionId = + options.find((o) => o.kind.includes('reject'))?.optionId || + options.find((o) => o.optionId === 'cancel')?.optionId || + 'cancel'; + onResponse(rejectOptionId); + if (onClose) { + onClose(); + } + } + }; + + window.addEventListener('keydown', handleKeyDown); + return () => window.removeEventListener('keydown', handleKeyDown); + }, [isOpen, options, onResponse, onClose, focusedIndex]); + + // Focus container when opened + useEffect(() => { + if (isOpen && containerRef.current) { + containerRef.current.focus(); + } + }, [isOpen]); + + // Reset focus to the first option when the drawer opens or the options change + useEffect(() => { + if (isOpen) { + setFocusedIndex(0); + } + }, [isOpen, options.length]); + + if (!isOpen) { + return null; + } + + return ( +
+ {/* Main container */} +
+ {/* Background layer */} +
+ + {/* Title + Description (from toolCall.title) */} +
+
+ {getTitle()} +
+ {(toolCall.kind === 'edit' || + toolCall.kind === 'write' || + toolCall.kind === 'read' || + toolCall.kind === 'execute' || + toolCall.kind === 'bash') && + toolCall.title && ( +
+ {toolCall.title} +
+ )} +
+ + {/* Options */} +
+ {options.map((option, index) => { + const isFocused = focusedIndex === index; + + return ( + + ); + })} + + {/* Custom message input (extracted component) */} + {(() => { + const isFocused = focusedIndex === options.length; + const rejectOptionId = options.find((o) => + o.kind.includes('reject'), + )?.optionId; + return ( + setFocusedIndex(options.length)} + onSubmitReject={() => { + if (rejectOptionId) { + onResponse(rejectOptionId); + } + }} + inputRef={customInputRef} + /> + ); + })()} +
+
+ + {/* Moved slide-up keyframes to Tailwind theme (tailwind.config.js) */} +
+ ); +}; + +/** + * CustomMessageInputRow: Reusable custom input row component (without hooks) + */ +interface CustomMessageInputRowProps { + isFocused: boolean; + customMessage: string; + setCustomMessage: (val: string) => void; + onFocusRow: () => void; // Set focus when mouse enters or input box is focused + onSubmitReject: () => void; // Triggered when Enter is pressed (selecting reject option) + inputRef: React.RefObject; +} + +const CustomMessageInputRow: React.FC = ({ + isFocused, + customMessage, + setCustomMessage, + onFocusRow, + onSubmitReject, + inputRef, +}) => ( +
inputRef.current?.focus()} + > + | undefined} + type="text" + placeholder="Tell Qwen what to do instead" + spellCheck={false} + className="flex-1 bg-transparent border-0 outline-none text-sm placeholder:opacity-70" + style={{ color: 'var(--app-input-foreground)' }} + value={customMessage} + onChange={(e) => setCustomMessage(e.target.value)} + onFocus={onFocusRow} + onKeyDown={(e) => { + if (e.key === 'Enter' && !e.shiftKey && customMessage.trim()) { + e.preventDefault(); + onSubmitReject(); + } + }} + /> +
+); diff --git a/packages/vscode-ide-companion/src/webview/components/PermissionDrawer/PermissionRequest.tsx b/packages/vscode-ide-companion/src/webview/components/PermissionDrawer/PermissionRequest.tsx new file mode 100644 index 000000000..a7b7356cb --- /dev/null +++ b/packages/vscode-ide-companion/src/webview/components/PermissionDrawer/PermissionRequest.tsx @@ -0,0 +1,37 @@ +/** + * @license + * Copyright 2025 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + */ + +export interface PermissionOption { + name: string; + kind: string; + optionId: string; +} + +export interface ToolCall { + title?: string; + kind?: string; + toolCallId?: string; + rawInput?: { + command?: string; + description?: string; + [key: string]: unknown; + }; + content?: Array<{ + type: string; + [key: string]: unknown; + }>; + locations?: Array<{ + path: string; + line?: number | null; + }>; + status?: string; +} + +export interface PermissionRequestProps { + options: PermissionOption[]; + toolCall: ToolCall; + onResponse: (optionId: string) => void; +} diff --git a/packages/vscode-ide-companion/src/webview/components/icons/EditIcons.tsx b/packages/vscode-ide-companion/src/webview/components/icons/EditIcons.tsx new file mode 100644 index 000000000..f5e12b330 --- /dev/null +++ b/packages/vscode-ide-companion/src/webview/components/icons/EditIcons.tsx @@ -0,0 +1,215 @@ +/** + * @license + * Copyright 2025 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + * + * Edit mode related icons + */ + +import type React from 'react'; +import type { IconProps } from './types.js'; + +/** + * Edit pencil icon (16x16) + * Used for "Ask before edits" mode + */ +export const EditPencilIcon: React.FC = ({ + size = 16, + className, + ...props +}) => ( + +); + +/** + * Auto/fast-forward icon (16x16) + * Used for "Edit automatically" mode + */ +export const AutoEditIcon: React.FC = ({ + size = 16, + className, + ...props +}) => ( + +); + +/** + * Plan mode/bars icon (16x16) + * Used for "Plan mode" + */ +export const PlanModeIcon: React.FC = ({ + size = 16, + className, + ...props +}) => ( + +); + +/** + * Code brackets icon (20x20) + * Used for active file indicator + */ +export const CodeBracketsIcon: React.FC = ({ + size = 20, + className, + ...props +}) => ( + +); + +/** + * Hide context (eye slash) icon (20x20) + * Used to indicate the active selection will NOT be auto-loaded into context + */ +export const HideContextIcon: React.FC = ({ + size = 20, + className, + ...props +}) => ( + +); + +/** + * Slash command icon (20x20) + * Used for command menu button + */ +export const SlashCommandIcon: React.FC = ({ + size = 20, + className, + ...props +}) => ( + +); + +/** + * Link/attachment icon (20x20) + * Used for attach context button + */ +export const LinkIcon: React.FC = ({ + size = 20, + className, + ...props +}) => ( + +); + +/** + * Open diff icon (16x16) + * Used for opening diff in VS Code + */ +export const OpenDiffIcon: React.FC = ({ + size = 16, + className, + ...props +}) => ( + +); diff --git a/packages/vscode-ide-companion/src/webview/components/icons/FileIcons.tsx b/packages/vscode-ide-companion/src/webview/components/icons/FileIcons.tsx new file mode 100644 index 000000000..38bf27f7a --- /dev/null +++ b/packages/vscode-ide-companion/src/webview/components/icons/FileIcons.tsx @@ -0,0 +1,103 @@ +/** + * @license + * Copyright 2025 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + * + * File and document related icons + */ + +import type React from 'react'; +import type { IconProps } from './types.js'; + +/** + * File document icon (16x16) + * Used for file completion menu + */ +export const FileIcon: React.FC = ({ + size = 16, + className, + ...props +}) => ( + +); + +export const FileListIcon: React.FC = ({ + size = 16, + className, + ...props +}) => ( + +); + +/** + * Save document icon (16x16) + * Used for save session button + */ +export const SaveDocumentIcon: React.FC = ({ + size = 16, + className, + ...props +}) => ( + +); + +/** + * Folder icon (16x16) + * Useful for directory entries in completion lists + */ +export const FolderIcon: React.FC = ({ + size = 16, + className, + ...props +}) => ( + +); diff --git a/packages/vscode-ide-companion/src/webview/components/icons/NavigationIcons.tsx b/packages/vscode-ide-companion/src/webview/components/icons/NavigationIcons.tsx new file mode 100644 index 000000000..9a4e52fbd --- /dev/null +++ b/packages/vscode-ide-companion/src/webview/components/icons/NavigationIcons.tsx @@ -0,0 +1,212 @@ +/** + * @license + * Copyright 2025 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + * + * Navigation and action icons + */ + +import type React from 'react'; +import type { IconProps } from './types.js'; + +/** + * Chevron down icon (20x20) + * Used for dropdown arrows + */ +export const ChevronDownIcon: React.FC = ({ + size = 20, + className, + ...props +}) => ( + +); + +/** + * Plus icon (20x20) + * Used for new session button + */ +export const PlusIcon: React.FC = ({ + size = 20, + className, + ...props +}) => ( + +); + +/** + * Small plus icon (16x16) + * Used for default attachment type + */ +export const PlusSmallIcon: React.FC = ({ + size = 16, + className, + ...props +}) => ( + +); + +/** + * Arrow up icon (20x20) + * Used for send message button + */ +export const ArrowUpIcon: React.FC = ({ + size = 20, + className, + ...props +}) => ( + +); + +/** + * Close X icon (14x14) + * Used for close buttons in banners and dialogs + */ +export const CloseIcon: React.FC = ({ + size = 14, + className, + ...props +}) => ( + +); + +export const CloseSmallIcon: React.FC = ({ + size = 16, + className, + ...props +}) => ( + +); + +/** + * Search/magnifying glass icon (20x20) + * Used for search input + */ +export const SearchIcon: React.FC = ({ + size = 20, + className, + ...props +}) => ( + +); + +/** + * Refresh/reload icon (16x16) + * Used for refresh session list + */ +export const RefreshIcon: React.FC = ({ + size = 16, + className, + ...props +}) => ( + +); diff --git a/packages/vscode-ide-companion/src/webview/components/icons/SpecialIcons.tsx b/packages/vscode-ide-companion/src/webview/components/icons/SpecialIcons.tsx new file mode 100644 index 000000000..48c5db846 --- /dev/null +++ b/packages/vscode-ide-companion/src/webview/components/icons/SpecialIcons.tsx @@ -0,0 +1,79 @@ +/** + * @license + * Copyright 2025 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + * + * Special UI icons + */ + +import type React from 'react'; +import type { IconProps } from './types.js'; + +interface ThinkingIconProps extends IconProps { + /** + * Whether thinking is enabled (affects styling) + */ + enabled?: boolean; +} + +export const ThinkingIcon: React.FC = ({ + size = 16, + className, + enabled = false, + style, + ...props +}) => ( + +); + +export const TerminalIcon: React.FC = ({ + size = 20, + className, + ...props +}) => ( + +); diff --git a/packages/vscode-ide-companion/src/webview/components/icons/StatusIcons.tsx b/packages/vscode-ide-companion/src/webview/components/icons/StatusIcons.tsx new file mode 100644 index 000000000..fdaa29434 --- /dev/null +++ b/packages/vscode-ide-companion/src/webview/components/icons/StatusIcons.tsx @@ -0,0 +1,188 @@ +/** + * @license + * Copyright 2025 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + * + * Status and state related icons + */ + +import type React from 'react'; +import type { IconProps } from './types.js'; + +/** + * Plan completed icon (14x14) + * Used for completed plan items + */ +export const PlanCompletedIcon: React.FC = ({ + size = 14, + className, + ...props +}) => ( + +); + +/** + * Plan in progress icon (14x14) + * Used for in-progress plan items + */ +export const PlanInProgressIcon: React.FC = ({ + size = 14, + className, + ...props +}) => ( + +); + +/** + * Plan pending icon (14x14) + * Used for pending plan items + */ +export const PlanPendingIcon: React.FC = ({ + size = 14, + className, + ...props +}) => ( + +); + +/** + * Warning triangle icon (20x20) + * Used for warning messages + */ +export const WarningTriangleIcon: React.FC = ({ + size = 20, + className, + ...props +}) => ( + +); + +/** + * User profile icon (16x16) + * Used for login command + */ +export const UserIcon: React.FC = ({ + size = 16, + className, + ...props +}) => ( + +); + +export const SymbolIcon: React.FC = ({ + size = 16, + className, + ...props +}) => ( + +); + +export const SelectionIcon: React.FC = ({ + size = 16, + className, + ...props +}) => ( + +); diff --git a/packages/vscode-ide-companion/src/webview/components/icons/StopIcon.tsx b/packages/vscode-ide-companion/src/webview/components/icons/StopIcon.tsx new file mode 100644 index 000000000..40c232502 --- /dev/null +++ b/packages/vscode-ide-companion/src/webview/components/icons/StopIcon.tsx @@ -0,0 +1,33 @@ +/** + * @license + * Copyright 2025 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + * + * Stop icon for canceling operations + */ + +import type React from 'react'; +import type { IconProps } from './types.js'; + +/** + * Stop/square icon (16x16) + * Used for stop/cancel operations + */ +export const StopIcon: React.FC = ({ + size = 16, + className, + ...props +}) => ( + +); diff --git a/packages/vscode-ide-companion/src/webview/components/icons/index.ts b/packages/vscode-ide-companion/src/webview/components/icons/index.ts new file mode 100644 index 000000000..ffecbbced --- /dev/null +++ b/packages/vscode-ide-companion/src/webview/components/icons/index.ts @@ -0,0 +1,49 @@ +/** + * @license + * Copyright 2025 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + */ + +export type { IconProps } from './types.js'; +export { FileIcon, FileListIcon, FolderIcon } from './FileIcons.js'; + +// Navigation icons +export { + ChevronDownIcon, + PlusIcon, + PlusSmallIcon, + ArrowUpIcon, + CloseIcon, + CloseSmallIcon, + SearchIcon, + RefreshIcon, +} from './NavigationIcons.js'; + +// Edit mode icons +export { + EditPencilIcon, + AutoEditIcon, + PlanModeIcon, + CodeBracketsIcon, + HideContextIcon, + SlashCommandIcon, + LinkIcon, + OpenDiffIcon, +} from './EditIcons.js'; + +// Status icons +export { + PlanCompletedIcon, + PlanInProgressIcon, + PlanPendingIcon, + WarningTriangleIcon, + UserIcon, + SymbolIcon, + SelectionIcon, +} from './StatusIcons.js'; + +// Special icons +export { ThinkingIcon, TerminalIcon } from './SpecialIcons.js'; + +// Stop icon +export { StopIcon } from './StopIcon.js'; diff --git a/packages/vscode-ide-companion/src/webview/components/icons/types.ts b/packages/vscode-ide-companion/src/webview/components/icons/types.ts new file mode 100644 index 000000000..6290d7206 --- /dev/null +++ b/packages/vscode-ide-companion/src/webview/components/icons/types.ts @@ -0,0 +1,22 @@ +/** + * @license + * Copyright 2025 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + * + * Common icon props interface + */ + +import type React from 'react'; + +export interface IconProps extends React.SVGProps { + /** + * Icon size (width and height) + * @default 16 + */ + size?: number; + + /** + * Additional CSS classes + */ + className?: string; +} diff --git a/packages/vscode-ide-companion/src/webview/components/layout/ChatHeader.tsx b/packages/vscode-ide-companion/src/webview/components/layout/ChatHeader.tsx new file mode 100644 index 000000000..82cc905fb --- /dev/null +++ b/packages/vscode-ide-companion/src/webview/components/layout/ChatHeader.tsx @@ -0,0 +1,47 @@ +/** + * @license + * Copyright 2025 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + */ + +import type React from 'react'; +import { ChevronDownIcon, PlusIcon } from '../icons/index.js'; + +interface ChatHeaderProps { + currentSessionTitle: string; + onLoadSessions: () => void; + onNewSession: () => void; +} + +export const ChatHeader: React.FC = ({ + currentSessionTitle, + onLoadSessions, + onNewSession, +}) => ( +
+ + +
+ + +
+); diff --git a/packages/vscode-ide-companion/src/webview/components/layout/CompletionMenu.tsx b/packages/vscode-ide-companion/src/webview/components/layout/CompletionMenu.tsx new file mode 100644 index 000000000..f667b849a --- /dev/null +++ b/packages/vscode-ide-companion/src/webview/components/layout/CompletionMenu.tsx @@ -0,0 +1,171 @@ +/** + * @license + * Copyright 2025 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + */ + +import type React from 'react'; +import { useEffect, useRef, useState } from 'react'; +import type { CompletionItem } from '../../../types/completionItemTypes.js'; + +interface CompletionMenuProps { + items: CompletionItem[]; + onSelect: (item: CompletionItem) => void; + onClose: () => void; + title?: string; + selectedIndex?: number; +} + +export const CompletionMenu: React.FC = ({ + items, + onSelect, + onClose, + title, + selectedIndex = 0, +}) => { + const containerRef = useRef(null); + const [selected, setSelected] = useState(selectedIndex); + // Mount state to drive a simple Tailwind transition (replaces CSS keyframes) + const [mounted, setMounted] = useState(false); + + useEffect(() => setSelected(selectedIndex), [selectedIndex]); + useEffect(() => setMounted(true), []); + + useEffect(() => { + const handleClickOutside = (event: MouseEvent) => { + if ( + containerRef.current && + !containerRef.current.contains(event.target as Node) + ) { + onClose(); + } + }; + + const handleKeyDown = (event: KeyboardEvent) => { + switch (event.key) { + case 'ArrowDown': + event.preventDefault(); + setSelected((prev) => Math.min(prev + 1, items.length - 1)); + break; + case 'ArrowUp': + event.preventDefault(); + setSelected((prev) => Math.max(prev - 1, 0)); + break; + case 'Enter': + event.preventDefault(); + if (items[selected]) { + onSelect(items[selected]); + } + break; + case 'Escape': + event.preventDefault(); + onClose(); + break; + default: + break; + } + }; + + document.addEventListener('mousedown', handleClickOutside); + document.addEventListener('keydown', handleKeyDown); + return () => { + document.removeEventListener('mousedown', handleClickOutside); + document.removeEventListener('keydown', handleKeyDown); + }; + }, [items, selected, onSelect, onClose]); + + useEffect(() => { + const selectedEl = containerRef.current?.querySelector( + `[data-index="${selected}"]`, + ); + if (selectedEl) { + selectedEl.scrollIntoView({ block: 'nearest' }); + } + }, [selected]); + + if (!items.length) { + return null; + } + + return ( +
+ {/* Optional top spacer for visual separation from the input */} +
+
+ {title && ( +
+ {title} +
+ )} + {items.map((item, index) => { + const isActive = index === selected; + return ( +
onSelect(item)} + onMouseEnter={() => setSelected(index)} + className={[ + // Semantic + 'completion-menu-item', + // Hit area + 'mx-1 cursor-pointer rounded-[var(--app-list-border-radius)]', + 'p-[var(--app-list-item-padding)]', + // Active background + isActive ? 'bg-[var(--app-list-active-background)]' : '', + ].join(' ')} + > +
+ {item.icon && ( + + {item.icon} + + )} + + {item.label} + + {item.description && ( + + {item.description} + + )} +
+
+ ); + })} +
+
+ ); +}; diff --git a/packages/vscode-ide-companion/src/webview/components/layout/EmptyState.tsx b/packages/vscode-ide-companion/src/webview/components/layout/EmptyState.tsx new file mode 100644 index 000000000..1b424e249 --- /dev/null +++ b/packages/vscode-ide-companion/src/webview/components/layout/EmptyState.tsx @@ -0,0 +1,66 @@ +/** + * @license + * Copyright 2025 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + */ + +import type React from 'react'; +import { generateIconUrl } from '../../utils/resourceUrl.js'; + +interface EmptyStateProps { + isAuthenticated?: boolean; + loadingMessage?: string; +} + +export const EmptyState: React.FC = ({ + isAuthenticated = false, + loadingMessage, +}) => { + // Generate icon URL using the utility function + const iconUri = generateIconUrl('icon.png'); + + const description = loadingMessage + ? 'Preparing Qwen Code…' + : isAuthenticated + ? 'What would you like to do? Ask about this codebase or we can start writing code.' + : 'Welcome! Please log in to start using Qwen Code.'; + + return ( +
+
+ {/* Qwen Logo */} +
+ {iconUri ? ( + Qwen Logo { + // Fallback to a div with text if image fails to load + const target = e.target as HTMLImageElement; + target.style.display = 'none'; + const parent = target.parentElement; + if (parent) { + const fallback = document.createElement('div'); + fallback.className = + 'w-[60px] h-[60px] flex items-center justify-center text-2xl font-bold'; + fallback.textContent = 'Q'; + parent.appendChild(fallback); + } + }} + /> + ) : ( +
+ Q +
+ )} +
+
+ {description} +
+
+
+
+
+ ); +}; diff --git a/packages/vscode-ide-companion/src/webview/components/layout/FileLink.tsx b/packages/vscode-ide-companion/src/webview/components/layout/FileLink.tsx new file mode 100644 index 000000000..356ffaf42 --- /dev/null +++ b/packages/vscode-ide-companion/src/webview/components/layout/FileLink.tsx @@ -0,0 +1,148 @@ +/** + * @license + * Copyright 2025 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + * + * FileLink component - Clickable file path links + * Supports clicking to open files and jump to specified line and column numbers + */ + +import type React from 'react'; +import { useVSCode } from '../../hooks/useVSCode.js'; +// Tailwind rewrite: styles from FileLink.css are now expressed as utility classes + +/** + * Props for FileLink + */ +interface FileLinkProps { + /** File path */ + path: string; + /** Optional line number (starting from 1) */ + line?: number | null; + /** Optional column number (starting from 1) */ + column?: number | null; + /** Whether to show full path, default false (show filename only) */ + showFullPath?: boolean; + /** Optional custom class name */ + className?: string; + /** Whether to disable click behavior (use when parent element handles clicks) */ + disableClick?: boolean; +} + +/** + * Extract filename from full path + * @param path File path + * @returns Filename + */ +function getFileName(path: string): string { + const segments = path.split(/[/\\]/); + return segments[segments.length - 1] || path; +} + +/** + * FileLink component - Clickable file link + * + * Features: + * - Click to open file + * - Support line and column number navigation + * - Hover to show full path + * - Optional display mode (full path vs filename only) + * + * @example + * ```tsx + * + * + * ``` + */ +export const FileLink: React.FC = ({ + path, + line, + column, + showFullPath = false, + className = '', + disableClick = false, +}) => { + const vscode = useVSCode(); + + /** + * Handle click event - Send message to VSCode to open file + */ + const handleClick = (e: React.MouseEvent) => { + // Always prevent default behavior (prevent tag # navigation) + e.preventDefault(); + + if (disableClick) { + // If click is disabled, return directly without stopping propagation + // This allows parent elements to handle click events + return; + } + + // If click is enabled, stop event propagation + e.stopPropagation(); + + // Build full path including line and column numbers + let fullPath = path; + if (line !== null && line !== undefined) { + fullPath += `:${line}`; + if (column !== null && column !== undefined) { + fullPath += `:${column}`; + } + } + + console.log('[FileLink] Opening file:', fullPath); + + vscode.postMessage({ + type: 'openFile', + data: { path: fullPath }, + }); + }; + + // Build display text + const displayPath = showFullPath ? path : getFileName(path); + + // Build hover tooltip (always show full path) + const fullDisplayText = + line !== null && line !== undefined + ? column !== null && column !== undefined + ? `${path}:${line}:${column}` + : `${path}:${line}` + : path; + + return ( + + {displayPath} + {line !== null && line !== undefined && ( + + :{line} + {column !== null && column !== undefined && <>:{column}} + + )} + + ); +}; diff --git a/packages/vscode-ide-companion/src/webview/components/layout/InputForm.tsx b/packages/vscode-ide-companion/src/webview/components/layout/InputForm.tsx new file mode 100644 index 000000000..6bd3289af --- /dev/null +++ b/packages/vscode-ide-companion/src/webview/components/layout/InputForm.tsx @@ -0,0 +1,298 @@ +/** + * @license + * Copyright 2025 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + */ + +import type React from 'react'; +import { + EditPencilIcon, + AutoEditIcon, + PlanModeIcon, + CodeBracketsIcon, + HideContextIcon, + // ThinkingIcon, // Temporarily disabled + SlashCommandIcon, + LinkIcon, + ArrowUpIcon, + StopIcon, +} from '../icons/index.js'; +import { CompletionMenu } from '../layout/CompletionMenu.js'; +import type { CompletionItem } from '../../../types/completionItemTypes.js'; +import { getApprovalModeInfoFromString } from '../../../types/acpTypes.js'; +import type { ApprovalModeValue } from '../../../types/approvalModeValueTypes.js'; + +interface InputFormProps { + inputText: string; + // Note: RefObject carries nullability in its `current` property, so the + // generic should be `HTMLDivElement` (not `HTMLDivElement | null`). + inputFieldRef: React.RefObject; + isStreaming: boolean; + isWaitingForResponse: boolean; + isComposing: boolean; + editMode: ApprovalModeValue; + thinkingEnabled: boolean; + activeFileName: string | null; + activeSelection: { startLine: number; endLine: number } | null; + // Whether to auto-load the active editor selection/path into context + skipAutoActiveContext: boolean; + onInputChange: (text: string) => void; + onCompositionStart: () => void; + onCompositionEnd: () => void; + onKeyDown: (e: React.KeyboardEvent) => void; + onSubmit: (e: React.FormEvent) => void; + onCancel: () => void; + onToggleEditMode: () => void; + onToggleThinking: () => void; + onFocusActiveEditor: () => void; + onToggleSkipAutoActiveContext: () => void; + onShowCommandMenu: () => void; + onAttachContext: () => void; + completionIsOpen: boolean; + completionItems?: CompletionItem[]; + onCompletionSelect?: (item: CompletionItem) => void; + onCompletionClose?: () => void; +} + +// Get edit mode display info using helper function +const getEditModeInfo = (editMode: ApprovalModeValue) => { + const info = getApprovalModeInfoFromString(editMode); + + // Map icon types to actual icons + let icon = null; + switch (info.iconType) { + case 'edit': + icon = ; + break; + case 'auto': + icon = ; + break; + case 'plan': + icon = ; + break; + case 'yolo': + icon = ; + break; + default: + icon = null; + break; + } + + return { + text: info.label, + title: info.title, + icon, + }; +}; + +export const InputForm: React.FC = ({ + inputText, + inputFieldRef, + isStreaming, + isWaitingForResponse, + isComposing, + editMode, + // thinkingEnabled, // Temporarily disabled + activeFileName, + activeSelection, + skipAutoActiveContext, + onInputChange, + onCompositionStart, + onCompositionEnd, + onKeyDown, + onSubmit, + onCancel, + onToggleEditMode, + // onToggleThinking, // Temporarily disabled + onToggleSkipAutoActiveContext, + onShowCommandMenu, + onAttachContext, + completionIsOpen, + completionItems, + onCompletionSelect, + onCompletionClose, +}) => { + const editModeInfo = getEditModeInfo(editMode); + const composerDisabled = isStreaming || isWaitingForResponse; + + const handleKeyDown = (e: React.KeyboardEvent) => { + // ESC should cancel the current interaction (stop generation) + if (e.key === 'Escape') { + e.preventDefault(); + onCancel(); + return; + } + // If composing (Chinese IME input), don't process Enter key + if (e.key === 'Enter' && !e.shiftKey && !isComposing) { + // If CompletionMenu is open, let it handle Enter key + if (completionIsOpen) { + return; + } + e.preventDefault(); + onSubmit(e); + } + onKeyDown(e); + }; + + // Selection label like "6 lines selected"; no line numbers + const selectedLinesCount = activeSelection + ? Math.max(1, activeSelection.endLine - activeSelection.startLine + 1) + : 0; + const selectedLinesText = + selectedLinesCount > 0 + ? `${selectedLinesCount} ${selectedLinesCount === 1 ? 'line' : 'lines'} selected` + : ''; + + return ( +
+
+
+ {/* Inner background layer */} +
+ + {/* Banner area */} +
+ +
+ {completionIsOpen && + completionItems && + completionItems.length > 0 && + onCompletionSelect && + onCompletionClose && ( + + )} + +
into contentEditable (so :empty no longer matches) + data-empty={ + inputText.replace(/\u200B/g, '').trim().length === 0 + ? 'true' + : 'false' + } + onInput={(e) => { + const target = e.target as HTMLDivElement; + // Filter out zero-width space that we use to maintain height + const text = target.textContent?.replace(/\u200B/g, '') || ''; + onInputChange(text); + }} + onCompositionStart={onCompositionStart} + onCompositionEnd={onCompositionEnd} + onKeyDown={handleKeyDown} + suppressContentEditableWarning + /> +
+ +
+ {/* Edit mode button */} + + + {/* Active file indicator */} + {activeFileName && ( + + )} + + {/* Spacer */} +
+ + {/* @yiliang114. closed temporarily */} + {/* Thinking button */} + {/* */} + + {/* Command button */} + + + {/* Attach button */} + + + {/* Send/Stop button */} + {isStreaming || isWaitingForResponse ? ( + + ) : ( + + )} +
+ +
+
+ ); +}; diff --git a/packages/vscode-ide-companion/src/webview/components/layout/Onboarding.tsx b/packages/vscode-ide-companion/src/webview/components/layout/Onboarding.tsx new file mode 100644 index 000000000..2eddc4d39 --- /dev/null +++ b/packages/vscode-ide-companion/src/webview/components/layout/Onboarding.tsx @@ -0,0 +1,49 @@ +/** + * @license + * Copyright 2025 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + */ + +import { generateIconUrl } from '../../utils/resourceUrl.js'; + +interface OnboardingPageProps { + onLogin: () => void; +} + +export const Onboarding: React.FC = ({ onLogin }) => { + const iconUri = generateIconUrl('icon.png'); + + return ( +
+
+
+ {/* Application icon container */} +
+ Qwen Code Logo +
+ +
+

+ Welcome to Qwen Code +

+

+ Unlock the power of AI to understand, navigate, and transform your + codebase faster than ever before. +

+
+ + +
+
+
+ ); +}; diff --git a/packages/vscode-ide-companion/src/webview/components/layout/SessionSelector.tsx b/packages/vscode-ide-companion/src/webview/components/layout/SessionSelector.tsx new file mode 100644 index 000000000..1b744c1d2 --- /dev/null +++ b/packages/vscode-ide-companion/src/webview/components/layout/SessionSelector.tsx @@ -0,0 +1,156 @@ +/** + * @license + * Copyright 2025 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + */ + +import React from 'react'; +import { + getTimeAgo, + groupSessionsByDate, +} from '../../utils/sessionGrouping.js'; +import { SearchIcon } from '../icons/index.js'; + +interface SessionSelectorProps { + visible: boolean; + sessions: Array>; + currentSessionId: string | null; + searchQuery: string; + onSearchChange: (query: string) => void; + onSelectSession: (sessionId: string) => void; + onClose: () => void; + hasMore?: boolean; + isLoading?: boolean; + onLoadMore?: () => void; +} + +/** + * Session selector component + * Display session list and support search and selection + */ +export const SessionSelector: React.FC = ({ + visible, + sessions, + currentSessionId, + searchQuery, + onSearchChange, + onSelectSession, + onClose, + hasMore = false, + isLoading = false, + onLoadMore, +}) => { + if (!visible) { + return null; + } + + const hasNoSessions = sessions.length === 0; + + return ( + <> +
+
e.stopPropagation()} + > + {/* Search Box */} +
+ + onSearchChange(e.target.value)} + /> +
+ + {/* Session List with Grouping */} +
{ + const el = e.currentTarget; + const distanceToBottom = + el.scrollHeight - (el.scrollTop + el.clientHeight); + if (distanceToBottom < 48 && hasMore && !isLoading) { + onLoadMore?.(); + } + }} + > + {hasNoSessions ? ( +
+ {searchQuery ? 'No matching sessions' : 'No sessions available'} +
+ ) : ( + groupSessionsByDate(sessions).map((group) => ( + +
+ {group.label} +
+
+ {group.sessions.map((session) => { + const sessionId = + (session.id as string) || + (session.sessionId as string) || + ''; + const title = + (session.title as string) || + (session.name as string) || + 'Untitled'; + const lastUpdated = + (session.lastUpdated as string) || + (session.startTime as string) || + ''; + const isActive = sessionId === currentSessionId; + + return ( + + ); + })} +
+
+ )) + )} + {hasMore && ( +
+ {isLoading ? 'Loading…' : ''} +
+ )} +
+
+ + ); +}; diff --git a/packages/vscode-ide-companion/src/webview/components/messages/Assistant/AssistantMessage.css b/packages/vscode-ide-companion/src/webview/components/messages/Assistant/AssistantMessage.css new file mode 100644 index 000000000..676758166 --- /dev/null +++ b/packages/vscode-ide-companion/src/webview/components/messages/Assistant/AssistantMessage.css @@ -0,0 +1,52 @@ +/** + * @license + * Copyright 2025 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + * + * AssistantMessage Component Styles + * Pseudo-elements (::before) for bullet points and (::after) for timeline connectors + */ + +/* Bullet point indicator using ::before pseudo-element */ +.assistant-message-container.assistant-message-default::before, +.assistant-message-container.assistant-message-success::before, +.assistant-message-container.assistant-message-error::before, +.assistant-message-container.assistant-message-warning::before, +.assistant-message-container.assistant-message-loading::before { + content: '\25cf'; + position: absolute; + left: 8px; + padding-top: 2px; + font-size: 10px; + z-index: 1; +} + +/* Default state - secondary foreground color */ +.assistant-message-container.assistant-message-default::before { + color: var(--app-secondary-foreground); +} + +/* Success state - green bullet (maps to .ge) */ +.assistant-message-container.assistant-message-success::before { + color: #74c991; +} + +/* Error state - red bullet (maps to .be) */ +.assistant-message-container.assistant-message-error::before { + color: #c74e39; +} + +/* Warning state - yellow/orange bullet (maps to .ue) */ +.assistant-message-container.assistant-message-warning::before { + color: #e1c08d; +} + +/* Loading state - static bullet (maps to .he) */ +.assistant-message-container.assistant-message-loading::before { + color: var(--app-secondary-foreground); + background-color: var(--app-secondary-background); +} + +.assistant-message-container.assistant-message-loading::after { + display: none; +} diff --git a/packages/vscode-ide-companion/src/webview/components/messages/Assistant/AssistantMessage.tsx b/packages/vscode-ide-companion/src/webview/components/messages/Assistant/AssistantMessage.tsx new file mode 100644 index 000000000..84712efaf --- /dev/null +++ b/packages/vscode-ide-companion/src/webview/components/messages/Assistant/AssistantMessage.tsx @@ -0,0 +1,87 @@ +/** + * @license + * Copyright 2025 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + */ + +import type React from 'react'; +import { MessageContent } from '../MessageContent.js'; +import './AssistantMessage.css'; + +interface AssistantMessageProps { + content: string; + timestamp: number; + onFileClick?: (path: string) => void; + status?: 'default' | 'success' | 'error' | 'warning' | 'loading'; + // When true, render without the left status bullet (no ::before dot) + hideStatusIcon?: boolean; +} + +/** + * AssistantMessage component - renders AI responses with Qwen Code styling + * Supports different states: default, success, error, warning, loading + */ +export const AssistantMessage: React.FC = ({ + content, + timestamp: _timestamp, + onFileClick, + status = 'default', + hideStatusIcon = false, +}) => { + // Empty content not rendered directly, avoid poor visual experience from only showing ::before dot + if (!content || content.trim().length === 0) { + return null; + } + + // Map status to CSS class (only for ::before pseudo-element) + const getStatusClass = () => { + if (hideStatusIcon) { + return ''; + } + switch (status) { + case 'success': + return 'assistant-message-success'; + case 'error': + return 'assistant-message-error'; + case 'warning': + return 'assistant-message-warning'; + case 'loading': + return 'assistant-message-loading'; + default: + return 'assistant-message-default'; + } + }; + + return ( +
+ +
+ +
+
+
+ ); +}; diff --git a/packages/vscode-ide-companion/src/webview/components/messages/MarkdownRenderer/MarkdownRenderer.css b/packages/vscode-ide-companion/src/webview/components/messages/MarkdownRenderer/MarkdownRenderer.css new file mode 100644 index 000000000..37a3485a3 --- /dev/null +++ b/packages/vscode-ide-companion/src/webview/components/messages/MarkdownRenderer/MarkdownRenderer.css @@ -0,0 +1,223 @@ +/** + * @license + * Copyright 2025 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + * + * Styles for MarkdownRenderer component + */ + +.markdown-content { + /* Base styles for markdown content */ + line-height: 1.6; + color: var(--app-primary-foreground); +} + +.markdown-content h1, +.markdown-content h2, +.markdown-content h3, +.markdown-content h4, +.markdown-content h5, +.markdown-content h6 { + margin-top: 1.5em; + margin-bottom: 0.5em; + font-weight: 600; +} + +.markdown-content h1 { + font-size: 1.75em; + border-bottom: 1px solid var(--app-primary-border-color); + padding-bottom: 0.3em; +} + +.markdown-content h2 { + font-size: 1.5em; + border-bottom: 1px solid var(--app-primary-border-color); + padding-bottom: 0.3em; +} + +.markdown-content h3 { + font-size: 1.25em; +} + +.markdown-content h4 { + font-size: 1.1em; +} + +.markdown-content h5, +.markdown-content h6 { + font-size: 1em; +} + +.markdown-content p { + margin-top: 0; + /* margin-bottom: 1em; */ +} + +.markdown-content ul, +.markdown-content ol { + margin-top: 1em; + margin-bottom: 1em; + padding-left: 2em; +} + +/* Ensure list markers are visible even with global CSS resets */ +.markdown-content ul { + list-style-type: disc; + list-style-position: outside; +} + +.markdown-content ol { + list-style-type: decimal; + list-style-position: outside; +} + +/* Nested list styles */ +.markdown-content ul ul { + list-style-type: circle; +} + +.markdown-content ul ul ul { + list-style-type: square; +} + +.markdown-content ol ol { + list-style-type: lower-alpha; +} + +.markdown-content ol ol ol { + list-style-type: lower-roman; +} + +/* Style the marker explicitly so themes don't hide it */ +.markdown-content li::marker { + color: var(--app-secondary-foreground); +} + +.markdown-content li { + margin-bottom: 0.25em; +} + +.markdown-content li > p { + margin-top: 0.5em; + margin-bottom: 0.5em; +} + +.markdown-content blockquote { + margin: 0 0 1em; + padding: 0 1em; + border-left: 0.25em solid var(--app-primary-border-color); + color: var(--app-secondary-foreground); +} + +.markdown-content a { + color: var(--app-link-foreground, #007acc); + text-decoration: none; +} + +.markdown-content a:hover { + color: var(--app-link-active-foreground, #005a9e); + text-decoration: underline; +} + +.markdown-content code { + font-family: var( + --app-monospace-font-family, + 'SF Mono', + Monaco, + 'Cascadia Code', + 'Roboto Mono', + Consolas, + 'Courier New', + monospace + ); + font-size: 0.9em; + background-color: var(--app-code-background, rgba(0, 0, 0, 0.05)); + border: 1px solid var(--app-primary-border-color); + border-radius: var(--corner-radius-small, 4px); + padding: 0.2em 0.4em; + white-space: pre-wrap; /* Support automatic line wrapping */ + word-break: break-word; /* Break words when necessary */ +} + +.markdown-content pre { + margin: 1em 0; + padding: 1em; + overflow-x: auto; + background-color: var(--app-code-background, rgba(0, 0, 0, 0.05)); + border: 1px solid var(--app-primary-border-color); + border-radius: var(--corner-radius-small, 4px); + font-family: var( + --app-monospace-font-family, + 'SF Mono', + Monaco, + 'Cascadia Code', + 'Roboto Mono', + Consolas, + 'Courier New', + monospace + ); + font-size: 0.9em; + line-height: 1.5; +} + +.markdown-content pre code { + background: none; + border: none; + padding: 0; + white-space: pre-wrap; /* Support automatic line wrapping */ + word-break: break-word; /* Break words when necessary */ +} + +.markdown-content .file-path-link { + background: transparent; + border: none; + padding: 0; + font-family: var( + --app-monospace-font-family, + 'SF Mono', + Monaco, + 'Cascadia Code', + 'Roboto Mono', + Consolas, + 'Courier New', + monospace + ); + font-size: 0.95em; + color: var(--app-link-foreground, #007acc); + text-decoration: underline; + cursor: pointer; + transition: color 0.1s ease; +} + +.markdown-content .file-path-link:hover { + color: var(--app-link-active-foreground, #005a9e); +} + +.markdown-content hr { + border: none; + border-top: 1px solid var(--app-primary-border-color); + margin: 1.5em 0; +} + +.markdown-content img { + max-width: 100%; + height: auto; +} + +.markdown-content table { + width: 100%; + border-collapse: collapse; + margin: 1em 0; +} + +.markdown-content th, +.markdown-content td { + padding: 0.5em 1em; + border: 1px solid var(--app-primary-border-color); + text-align: left; +} + +.markdown-content th { + background-color: var(--app-secondary-background); + font-weight: 600; +} diff --git a/packages/vscode-ide-companion/src/webview/components/messages/MarkdownRenderer/MarkdownRenderer.tsx b/packages/vscode-ide-companion/src/webview/components/messages/MarkdownRenderer/MarkdownRenderer.tsx new file mode 100644 index 000000000..112464202 --- /dev/null +++ b/packages/vscode-ide-companion/src/webview/components/messages/MarkdownRenderer/MarkdownRenderer.tsx @@ -0,0 +1,392 @@ +/** + * @license + * Copyright 2025 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + * + * MarkdownRenderer component - renders markdown content with syntax highlighting and clickable file paths + */ + +import type React from 'react'; +import MarkdownIt from 'markdown-it'; +import type { Options as MarkdownItOptions } from 'markdown-it'; +import './MarkdownRenderer.css'; + +interface MarkdownRendererProps { + content: string; + onFileClick?: (filePath: string) => void; + /** When false, do not convert file paths into clickable links. Default: true */ + enableFileLinks?: boolean; +} + +/** + * Regular expressions for parsing content + */ +// Match absolute file paths like: /path/to/file.ts or C:\path\to\file.ts +const FILE_PATH_REGEX = + /(?:[a-zA-Z]:)?[/\\](?:[\w\-. ]+[/\\])+[\w\-. ]+\.(tsx?|jsx?|css|scss|json|md|py|java|go|rs|c|cpp|h|hpp|sh|yaml|yml|toml|xml|html|vue|svelte)/gi; +// Match file paths with optional line numbers like: /path/to/file.ts#7-14 or C:\path\to\file.ts#7 +const FILE_PATH_WITH_LINES_REGEX = + /(?:[a-zA-Z]:)?[/\\](?:[\w\-. ]+[/\\])+[\w\-. ]+\.(tsx?|jsx?|css|scss|json|md|py|java|go|rs|c|cpp|h|hpp|sh|yaml|yml|toml|xml|html|vue|svelte)#(\d+)(?:-(\d+))?/gi; + +/** + * MarkdownRenderer component - renders markdown content with enhanced features + */ +export const MarkdownRenderer: React.FC = ({ + content, + onFileClick, + enableFileLinks = true, +}) => { + /** + * Initialize markdown-it with plugins + */ + const getMarkdownInstance = (): MarkdownIt => { + // Create markdown-it instance with options + const md = new MarkdownIt({ + html: false, // Disable HTML for security + xhtmlOut: false, + breaks: true, + linkify: true, + typographer: true, + } as MarkdownItOptions); + + return md; + }; + + /** + * Render markdown content to HTML + */ + const renderMarkdown = (): string => { + try { + const md = getMarkdownInstance(); + + // Process the markdown content + let html = md.render(content); + + // Post-process to add file path click handlers unless disabled + if (enableFileLinks) { + html = processFilePaths(html); + } + + return html; + } catch (error) { + console.error('Error rendering markdown:', error); + // Fallback to plain text if markdown rendering fails + return escapeHtml(content); + } + }; + + /** + * Escape HTML characters for security + */ + const escapeHtml = (unsafe: string): string => + unsafe + .replace(/&/g, '&') + .replace(//g, '>') + .replace(/"/g, '"') + .replace(/'/g, '''); + + /** + * Process file paths in HTML to make them clickable + */ + const processFilePaths = (html: string): string => { + // If DOM is not available, bail out to avoid breaking SSR + if (typeof document === 'undefined') { + return html; + } + + // Build non-global variants to avoid .test() statefulness + const FILE_PATH_NO_G = new RegExp( + FILE_PATH_REGEX.source, + FILE_PATH_REGEX.flags.replace('g', ''), + ); + const FILE_PATH_WITH_LINES_NO_G = new RegExp( + FILE_PATH_WITH_LINES_REGEX.source, + FILE_PATH_WITH_LINES_REGEX.flags.replace('g', ''), + ); + // Match a bare file name like README.md (no leading slash) + const BARE_FILE_REGEX = + /[\w\-. ]+\.(tsx?|jsx?|css|scss|json|md|py|java|go|rs|c|cpp|h|hpp|sh|ya?ml|toml|xml|html|vue|svelte)/i; + + // Parse HTML into a DOM tree so we don't replace inside attributes + const container = document.createElement('div'); + container.innerHTML = html; + + const union = new RegExp( + `${FILE_PATH_WITH_LINES_REGEX.source}|${FILE_PATH_REGEX.source}|${BARE_FILE_REGEX.source}`, + 'gi', + ); + + // Convert a "path#fragment" into VS Code friendly "path:line" (we only keep the start line) + const normalizePathAndLine = ( + raw: string, + ): { displayText: string; dataPath: string } => { + const displayText = raw; + let base = raw; + // Extract hash fragment like #12, #L12 or #12-34 and keep only the first number + const hashIndex = raw.indexOf('#'); + if (hashIndex >= 0) { + const frag = raw.slice(hashIndex + 1); + // Accept L12, 12 or 12-34 + const m = frag.match(/^L?(\d+)(?:-\d+)?$/i); + if (m) { + const line = parseInt(m[1], 10); + base = raw.slice(0, hashIndex); + return { displayText, dataPath: `${base}:${line}` }; + } + } + return { displayText, dataPath: base }; + }; + + const makeLink = (text: string) => { + const link = document.createElement('a'); + // Pass base path (with optional :line) to the handler; keep the full text as label + const { dataPath } = normalizePathAndLine(text); + link.className = 'file-path-link'; + link.textContent = text; + link.setAttribute('href', '#'); + link.setAttribute('title', `Open ${text}`); + // Carry file path via data attribute; click handled by event delegation + link.setAttribute('data-file-path', dataPath); + return link; + }; + + const upgradeAnchorIfFilePath = (a: HTMLAnchorElement) => { + const href = a.getAttribute('href') || ''; + const text = (a.textContent || '').trim(); + + // Helper: identify dot-chained code refs (e.g. vscode.commands.register) + // but DO NOT treat filenames/paths as code refs. + const isCodeReference = (str: string): boolean => { + if (BARE_FILE_REGEX.test(str)) { + return false; // looks like a filename + } + if (/[/\\]/.test(str)) { + return false; // contains a path separator + } + const codeRefPattern = /^[a-zA-Z_$][\w$]*(\.[a-zA-Z_$][\w$]*)+$/; + return codeRefPattern.test(str); + }; + + // If linkify turned a bare filename (e.g. README.md) into http://, convert it back + const httpMatch = href.match(/^https?:\/\/(.+)$/i); + if (httpMatch) { + try { + const url = new URL(href); + const host = url.hostname || ''; + const pathname = url.pathname || ''; + const noPath = pathname === '' || pathname === '/'; + + // Case 1: anchor text itself is a bare filename and equals the host (e.g. README.md) + if ( + noPath && + BARE_FILE_REGEX.test(text) && + host.toLowerCase() === text.toLowerCase() + ) { + const { dataPath } = normalizePathAndLine(text); + a.classList.add('file-path-link'); + a.setAttribute('href', '#'); + a.setAttribute('title', `Open ${text}`); + a.setAttribute('data-file-path', dataPath); + return; + } + + // Case 2: host itself looks like a filename (rare but happens), use it + if (noPath && BARE_FILE_REGEX.test(host)) { + const { dataPath } = normalizePathAndLine(host); + a.classList.add('file-path-link'); + a.setAttribute('href', '#'); + a.setAttribute('title', `Open ${text || host}`); + a.setAttribute('data-file-path', dataPath); + return; + } + } catch { + // fall through; unparseable URL + } + } + + // Ignore other external protocols + if (/^(https?|mailto|ftp|data):/i.test(href)) { + return; + } + + const candidate = href || text; + + // Skip if it looks like a code reference + if (isCodeReference(candidate)) { + return; + } + + if ( + FILE_PATH_WITH_LINES_NO_G.test(candidate) || + FILE_PATH_NO_G.test(candidate) + ) { + const { dataPath } = normalizePathAndLine(candidate); + a.classList.add('file-path-link'); + a.setAttribute('href', '#'); + a.setAttribute('title', `Open ${text || href}`); + a.setAttribute('data-file-path', dataPath); + return; + } + + // Bare file name or relative path (e.g. README.md or docs/README.md) + if (BARE_FILE_REGEX.test(candidate)) { + const { dataPath } = normalizePathAndLine(candidate); + a.classList.add('file-path-link'); + a.setAttribute('href', '#'); + a.setAttribute('title', `Open ${text || href}`); + a.setAttribute('data-file-path', dataPath); + } + }; + + // Helper: identify dot-chained code refs (e.g. vscode.commands.register) + // but DO NOT treat filenames/paths as code refs. + const isCodeReference = (str: string): boolean => { + if (BARE_FILE_REGEX.test(str)) { + return false; // looks like a filename + } + if (/[/\\]/.test(str)) { + return false; // contains a path separator + } + const codeRefPattern = /^[a-zA-Z_$][\w$]*(\.[a-zA-Z_$][\w$]*)+$/; + return codeRefPattern.test(str); + }; + + const walk = (node: Node) => { + // Do not transform inside existing anchors + if (node.nodeType === Node.ELEMENT_NODE) { + const el = node as HTMLElement; + if (el.tagName.toLowerCase() === 'a') { + upgradeAnchorIfFilePath(el as HTMLAnchorElement); + return; // Don't descend into + } + // Avoid transforming inside code/pre blocks + const tag = el.tagName.toLowerCase(); + if (tag === 'code' || tag === 'pre') { + return; + } + } + + for (let child = node.firstChild; child; ) { + const next = child.nextSibling; // child may be replaced + if (child.nodeType === Node.TEXT_NODE) { + const text = child.nodeValue || ''; + union.lastIndex = 0; + const hasMatch = union.test(text); + union.lastIndex = 0; + if (hasMatch) { + const frag = document.createDocumentFragment(); + let lastIndex = 0; + let m: RegExpExecArray | null; + while ((m = union.exec(text))) { + const matchText = m[0]; + const idx = m.index; + + // Skip if it looks like a code reference + if (isCodeReference(matchText)) { + // Just add the text as-is without creating a link + if (idx > lastIndex) { + frag.appendChild( + document.createTextNode(text.slice(lastIndex, idx)), + ); + } + frag.appendChild(document.createTextNode(matchText)); + lastIndex = idx + matchText.length; + continue; + } + + if (idx > lastIndex) { + frag.appendChild( + document.createTextNode(text.slice(lastIndex, idx)), + ); + } + frag.appendChild(makeLink(matchText)); + lastIndex = idx + matchText.length; + } + if (lastIndex < text.length) { + frag.appendChild(document.createTextNode(text.slice(lastIndex))); + } + node.replaceChild(frag, child); + } + } else if (child.nodeType === Node.ELEMENT_NODE) { + walk(child); + } + child = next; + } + }; + + walk(container); + return container.innerHTML; + }; + + // Event delegation: intercept clicks on generated file-path links + const handleContainerClick = ( + e: React.MouseEvent, + ) => { + // If file links disabled, do nothing + if (!enableFileLinks) { + return; + } + const target = e.target as HTMLElement | null; + if (!target) { + return; + } + + // Find nearest anchor with our marker class + const anchor = (target.closest && + target.closest('a.file-path-link')) as HTMLAnchorElement | null; + if (anchor) { + const filePath = anchor.getAttribute('data-file-path'); + if (!filePath) { + return; + } + e.preventDefault(); + e.stopPropagation(); + onFileClick?.(filePath); + return; + } + + // Fallback: intercept "http://README.md" style links that slipped through + const anyAnchor = (target.closest && + target.closest('a')) as HTMLAnchorElement | null; + if (!anyAnchor) { + return; + } + + const href = anyAnchor.getAttribute('href') || ''; + if (!/^https?:\/\//i.test(href)) { + return; + } + try { + const url = new URL(href); + const host = url.hostname || ''; + const path = url.pathname || ''; + const noPath = path === '' || path === '/'; + + // Basic bare filename heuristic on the host part (e.g. README.md) + if (noPath && /\.[a-z0-9]+$/i.test(host)) { + // Prefer the readable text content if it looks like a file + const text = (anyAnchor.textContent || '').trim(); + const candidate = /\.[a-z0-9]+$/i.test(text) ? text : host; + e.preventDefault(); + e.stopPropagation(); + onFileClick?.(candidate); + } + } catch { + // ignore + } + }; + + return ( +
+ ); +}; diff --git a/packages/vscode-ide-companion/src/webview/components/messages/MessageContent.tsx b/packages/vscode-ide-companion/src/webview/components/messages/MessageContent.tsx new file mode 100644 index 000000000..3381e90d5 --- /dev/null +++ b/packages/vscode-ide-companion/src/webview/components/messages/MessageContent.tsx @@ -0,0 +1,26 @@ +/** + * @license + * Copyright 2025 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + */ + +import type React from 'react'; +import { MarkdownRenderer } from './MarkdownRenderer/MarkdownRenderer.js'; + +interface MessageContentProps { + content: string; + onFileClick?: (filePath: string) => void; + enableFileLinks?: boolean; +} + +export const MessageContent: React.FC = ({ + content, + onFileClick, + enableFileLinks, +}) => ( + +); diff --git a/packages/vscode-ide-companion/src/webview/components/messages/ThinkingMessage.tsx b/packages/vscode-ide-companion/src/webview/components/messages/ThinkingMessage.tsx new file mode 100644 index 000000000..1f92e1f4e --- /dev/null +++ b/packages/vscode-ide-companion/src/webview/components/messages/ThinkingMessage.tsx @@ -0,0 +1,41 @@ +/** + * @license + * Copyright 2025 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + */ + +import type React from 'react'; +import { MessageContent } from './MessageContent.js'; + +interface ThinkingMessageProps { + content: string; + timestamp: number; + onFileClick?: (path: string) => void; +} + +export const ThinkingMessage: React.FC = ({ + content, + timestamp: _timestamp, + onFileClick, +}) => ( +
+
+ + + + + + +
+
+); diff --git a/packages/vscode-ide-companion/src/webview/components/messages/UserMessage.tsx b/packages/vscode-ide-companion/src/webview/components/messages/UserMessage.tsx new file mode 100644 index 000000000..1014736a1 --- /dev/null +++ b/packages/vscode-ide-companion/src/webview/components/messages/UserMessage.tsx @@ -0,0 +1,98 @@ +/** + * @license + * Copyright 2025 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + */ + +import type React from 'react'; +import { MessageContent } from './MessageContent.js'; + +interface FileContext { + fileName: string; + filePath: string; + startLine?: number; + endLine?: number; +} + +interface UserMessageProps { + content: string; + timestamp: number; + onFileClick?: (path: string) => void; + fileContext?: FileContext; +} + +export const UserMessage: React.FC = ({ + content, + timestamp: _timestamp, + onFileClick, + fileContext, +}) => { + // Generate display text for file context + const getFileContextDisplay = () => { + if (!fileContext) { + return null; + } + const { fileName, startLine, endLine } = fileContext; + if (startLine && endLine) { + return startLine === endLine + ? `${fileName}#${startLine}` + : `${fileName}#${startLine}-${endLine}`; + } + return fileName; + }; + + const fileContextDisplay = getFileContextDisplay(); + + return ( +
+
+ {/* For user messages, do NOT convert filenames to clickable links */} + +
+ + {/* File context indicator */} + {fileContextDisplay && ( +
+
fileContext && onFileClick?.(fileContext.filePath)} + onKeyDown={(e) => { + if (e.key === 'Enter' || e.key === ' ') { + fileContext && onFileClick?.(fileContext.filePath); + } + }} + > +
+ {fileContextDisplay} +
+
+
+ )} +
+ ); +}; diff --git a/packages/vscode-ide-companion/src/webview/components/messages/Waiting/InterruptedMessage.tsx b/packages/vscode-ide-companion/src/webview/components/messages/Waiting/InterruptedMessage.tsx new file mode 100644 index 000000000..0c0e4c8d3 --- /dev/null +++ b/packages/vscode-ide-companion/src/webview/components/messages/Waiting/InterruptedMessage.tsx @@ -0,0 +1,22 @@ +/** + * @license + * Copyright 2025 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + */ + +import type React from 'react'; + +interface InterruptedMessageProps { + text?: string; +} + +// A lightweight status line similar to WaitingMessage but without the left status icon. +export const InterruptedMessage: React.FC = ({ + text = 'Interrupted', +}) => ( +
+
+ {text} +
+
+); diff --git a/packages/vscode-ide-companion/src/webview/components/messages/Waiting/WaitingMessage.css b/packages/vscode-ide-companion/src/webview/components/messages/Waiting/WaitingMessage.css new file mode 100644 index 000000000..9a109a082 --- /dev/null +++ b/packages/vscode-ide-companion/src/webview/components/messages/Waiting/WaitingMessage.css @@ -0,0 +1,38 @@ +/** + * @license + * Copyright 2025 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + */ + +@import url('../Assistant/AssistantMessage.css'); + +/* Subtle shimmering highlight across the loading text */ +@keyframes waitingMessageShimmer { + 0% { + background-position: -200% 0; + } + 100% { + background-position: 200% 0; + } +} + +.loading-text-shimmer { + /* Use the theme foreground as the base color, with a moving light band */ + background-image: linear-gradient( + 90deg, + var(--app-secondary-foreground) 0%, + var(--app-secondary-foreground) 40%, + rgba(255, 255, 255, 0.95) 50%, + var(--app-secondary-foreground) 60%, + var(--app-secondary-foreground) 100% + ); + background-size: 200% 100%; + -webkit-background-clip: text; + background-clip: text; + color: transparent; /* text color comes from the gradient */ + animation: waitingMessageShimmer 1.6s linear infinite; +} + +.interrupted-item::after { + display: none; +} diff --git a/packages/vscode-ide-companion/src/webview/components/messages/Waiting/WaitingMessage.tsx b/packages/vscode-ide-companion/src/webview/components/messages/Waiting/WaitingMessage.tsx new file mode 100644 index 000000000..68aceac8f --- /dev/null +++ b/packages/vscode-ide-companion/src/webview/components/messages/Waiting/WaitingMessage.tsx @@ -0,0 +1,77 @@ +/** + * @license + * Copyright 2025 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + */ + +import type React from 'react'; +import { useEffect, useMemo, useState } from 'react'; +import './WaitingMessage.css'; +import { WITTY_LOADING_PHRASES } from '../../../../constants/loadingMessages.js'; + +interface WaitingMessageProps { + loadingMessage: string; +} + +// Rotate message every few seconds while waiting +const ROTATE_INTERVAL_MS = 3000; // rotate every 3s per request + +export const WaitingMessage: React.FC = ({ + loadingMessage, +}) => { + // Build a phrase list that starts with the provided message (if any), then witty fallbacks + const phrases = useMemo(() => { + const set = new Set(); + const list: string[] = []; + if (loadingMessage && loadingMessage.trim()) { + list.push(loadingMessage); + set.add(loadingMessage); + } + for (const p of WITTY_LOADING_PHRASES) { + if (!set.has(p)) { + list.push(p); + } + } + return list; + }, [loadingMessage]); + + const [index, setIndex] = useState(0); + + // Reset to the first phrase whenever the incoming message changes + useEffect(() => { + setIndex(0); + }, [phrases]); + + // Periodically rotate to a different phrase + useEffect(() => { + if (phrases.length <= 1) { + return; + } + const id = setInterval(() => { + setIndex((prev) => { + // pick a different random index to avoid immediate repeats + let next = Math.floor(Math.random() * phrases.length); + if (phrases.length > 1) { + let guard = 0; + while (next === prev && guard < 5) { + next = Math.floor(Math.random() * phrases.length); + guard++; + } + } + return next; + }); + }, ROTATE_INTERVAL_MS); + return () => clearInterval(id); + }, [phrases]); + + return ( +
+ {/* Use the same left status icon (pseudo-element) style as assistant-message-container */} +
+ + {phrases[index]} + +
+
+ ); +}; diff --git a/packages/vscode-ide-companion/src/webview/components/messages/index.tsx b/packages/vscode-ide-companion/src/webview/components/messages/index.tsx new file mode 100644 index 000000000..2ec06e87e --- /dev/null +++ b/packages/vscode-ide-companion/src/webview/components/messages/index.tsx @@ -0,0 +1,11 @@ +/** + * @license + * Copyright 2025 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + */ + +export { UserMessage } from './UserMessage.js'; +export { AssistantMessage } from './Assistant/AssistantMessage.js'; +export { ThinkingMessage } from './ThinkingMessage.js'; +export { WaitingMessage } from './Waiting/WaitingMessage.js'; +export { InterruptedMessage } from './Waiting/InterruptedMessage.js'; diff --git a/packages/vscode-ide-companion/src/webview/components/messages/toolcalls/Bash/Bash.css b/packages/vscode-ide-companion/src/webview/components/messages/toolcalls/Bash/Bash.css new file mode 100644 index 000000000..bbd8080db --- /dev/null +++ b/packages/vscode-ide-companion/src/webview/components/messages/toolcalls/Bash/Bash.css @@ -0,0 +1,102 @@ +/** + * @license + * Copyright 2025 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + * + * Execute tool call styles - Enhanced styling with semantic class names + */ + +/* Root container for execute tool call output */ +.bash-toolcall-card { + border: 0.5px solid var(--app-input-border); + border-radius: 5px; + background: var(--app-tool-background); + margin: 8px 0; + max-width: 100%; + font-size: 1em; + align-items: start; +} + +/* Content wrapper inside the card */ +.bash-toolcall-content { + display: flex; + flex-direction: column; + gap: 3px; + padding: 4px; +} + +/* Individual input/output row */ +.bash-toolcall-row { + display: grid; + grid-template-columns: max-content 1fr; + border-top: 0.5px solid var(--app-input-border); + padding: 4px; +} + +/* First row has no top border */ +.bash-toolcall-row:first-child { + border-top: none; +} + +/* Row label (IN/OUT/ERROR) */ +.bash-toolcall-label { + grid-column: 1; + color: var(--app-secondary-foreground); + text-align: left; + opacity: 50%; + padding: 4px 8px 4px 4px; + font-family: var(--app-monospace-font-family); + font-size: 0.85em; +} + +/* Row content area */ +.bash-toolcall-row-content { + grid-column: 2; + white-space: pre-wrap; + word-break: break-word; + margin: 0; + padding: 4px; +} + +/* Truncated content styling */ +.bash-toolcall-row-content:not(.bash-toolcall-full) { + max-height: 60px; + mask-image: linear-gradient( + to bottom, + var(--app-primary-background) 40px, + transparent 60px + ); + overflow: hidden; +} + +/* Preformatted content */ +.bash-toolcall-pre { + margin-block: 0; + overflow: hidden; + font-family: var(--app-monospace-font-family); + font-size: 0.85em; +} + +/* Code content */ +.bash-toolcall-code { + margin: 0; + padding: 0; + font-family: var(--app-monospace-font-family); + font-size: 0.85em; +} + +/* Output content with subtle styling */ +.bash-toolcall-output-subtle { + background-color: var(--app-code-background); + white-space: pre; + overflow-x: auto; + max-width: 100%; + min-width: 0; + width: 100%; + box-sizing: border-box; +} + +/* Error content styling */ +.bash-toolcall-error-content { + color: #c74e39; +} diff --git a/packages/vscode-ide-companion/src/webview/components/messages/toolcalls/Bash/Bash.tsx b/packages/vscode-ide-companion/src/webview/components/messages/toolcalls/Bash/Bash.tsx new file mode 100644 index 000000000..acd1fe26b --- /dev/null +++ b/packages/vscode-ide-companion/src/webview/components/messages/toolcalls/Bash/Bash.tsx @@ -0,0 +1,180 @@ +/** + * @license + * Copyright 2025 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + * + * Execute tool call component - specialized for command execution operations + */ + +import type React from 'react'; +import type { BaseToolCallProps } from '../shared/types.js'; +import { ToolCallContainer } from '../shared/LayoutComponents.js'; +import { safeTitle, groupContent } from '../shared/utils.js'; +import { useVSCode } from '../../../../hooks/useVSCode.js'; +import { createAndOpenTempFile } from '../../../../utils/tempFileManager.js'; +import './Bash.css'; + +/** + * Specialized component for Execute/Bash tool calls + * Shows: Bash bullet + description + IN/OUT card + */ +export const ExecuteToolCall: React.FC = ({ toolCall }) => { + const { title, content, rawInput, toolCallId } = toolCall; + const commandText = safeTitle(title); + const vscode = useVSCode(); + + // Group content by type + const { textOutputs, errors } = groupContent(content); + + // Extract command from rawInput if available + let inputCommand = commandText; + if (rawInput && typeof rawInput === 'object') { + const inputObj = rawInput as { command?: string }; + inputCommand = inputObj.command || commandText; + } else if (typeof rawInput === 'string') { + inputCommand = rawInput; + } + + // Handle click on IN section + const handleInClick = () => { + createAndOpenTempFile( + vscode.postMessage, + inputCommand, + 'bash-input', + '.sh', + ); + }; + + // Handle click on OUT section + const handleOutClick = () => { + if (textOutputs.length > 0) { + const output = textOutputs.join('\n'); + createAndOpenTempFile(vscode.postMessage, output, 'bash-output', '.txt'); + } + }; + + // Map tool status to container status for proper bullet coloring + const containerStatus: + | 'success' + | 'error' + | 'warning' + | 'loading' + | 'default' = + errors.length > 0 + ? 'error' + : toolCall.status === 'in_progress' || toolCall.status === 'pending' + ? 'loading' + : 'success'; + + // Error case + if (errors.length > 0) { + return ( + + {/* Branch connector summary */} +
+ + {commandText} +
+ {/* Error card - semantic DOM + Tailwind styles */} +
+
+ {/* IN row */} +
+
IN
+
+
{inputCommand}
+
+
+ + {/* ERROR row */} +
+
Error
+
+
+                  {errors.join('\n')}
+                
+
+
+
+
+
+ ); + } + + // Success with output + if (textOutputs.length > 0) { + const output = textOutputs.join('\n'); + const truncatedOutput = + output.length > 500 ? output.substring(0, 500) + '...' : output; + + return ( + + {/* Branch connector summary */} +
+ + {commandText} +
+ {/* Output card - semantic DOM + Tailwind styles */} +
+
+ {/* IN row */} +
+
IN
+
+
{inputCommand}
+
+
+ + {/* OUT row */} +
+
OUT
+
+
+
{truncatedOutput}
+
+
+
+
+
+
+ ); + } + + // Success without output: show command with branch connector + return ( + +
+ + {commandText} +
+
+ ); +}; diff --git a/packages/vscode-ide-companion/src/webview/components/messages/toolcalls/Edit/EditToolCall.tsx b/packages/vscode-ide-companion/src/webview/components/messages/toolcalls/Edit/EditToolCall.tsx new file mode 100644 index 000000000..a84853162 --- /dev/null +++ b/packages/vscode-ide-companion/src/webview/components/messages/toolcalls/Edit/EditToolCall.tsx @@ -0,0 +1,196 @@ +/** + * @license + * Copyright 2025 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + * + * Edit tool call component - specialized for file editing operations + */ + +import { useMemo } from 'react'; +import type { BaseToolCallProps } from '../shared/types.js'; +import { + groupContent, + mapToolStatusToContainerStatus, +} from '../shared/utils.js'; +import { FileLink } from '../../../layout/FileLink.js'; +import type { ToolCallContainerProps } from '../shared/LayoutComponents.js'; + +export const ToolCallContainer: React.FC = ({ + label, + status = 'success', + children, + toolCallId: _toolCallId, + labelSuffix, + className: _className, +}) => ( +
+
+
+ + {label} + + + {labelSuffix} + +
+ {children && ( +
{children}
+ )} +
+
+); + +/** + * Calculate diff summary (added/removed lines) + */ +const getDiffSummary = ( + oldText: string | null | undefined, + newText: string | undefined, +): string => { + const oldLines = oldText ? oldText.split('\n').length : 0; + const newLines = newText ? newText.split('\n').length : 0; + const diff = newLines - oldLines; + + if (diff > 0) { + return `+${diff} lines`; + } else if (diff < 0) { + return `${diff} lines`; + } else { + return 'Modified'; + } +}; + +/** + * Specialized component for Edit tool calls + * Optimized for displaying file editing operations with diffs + */ +export const EditToolCall: React.FC = ({ toolCall }) => { + const { content, locations, toolCallId } = toolCall; + + // Group content by type; memoize to avoid new array identities on every render + const { errors, diffs } = useMemo(() => groupContent(content), [content]); + + // Failed case: show explicit failed message and render inline diffs + if (toolCall.status === 'failed') { + const firstDiff = diffs[0]; + const path = firstDiff?.path || locations?.[0]?.path || ''; + const containerStatus = mapToolStatusToContainerStatus(toolCall.status); + return ( +
+
+
+
+ + Edit + + {path && ( + + )} +
+
+ {/* Failed state text (replace summary) */} +
+ edit failed +
+
+
+ ); + } + + // Error case: show error + if (errors.length > 0) { + const path = diffs[0]?.path || locations?.[0]?.path || ''; + return ( + + ) : undefined + } + > + {errors.join('\n')} + + ); + } + + // Success case with diff: show minimal inline preview; clicking the title opens VS Code diff + if (diffs.length > 0) { + const firstDiff = diffs[0]; + const path = firstDiff.path || (locations && locations[0]?.path) || ''; + const summary = getDiffSummary(firstDiff.oldText, firstDiff.newText); + const containerStatus = mapToolStatusToContainerStatus(toolCall.status); + return ( +
+
+
+
+ {/* Align the inline Edit label styling with shared toolcall label: larger + bold */} + + Edit + + {path && ( + + )} +
+
+
+ + {summary} +
+
+
+ ); + } + + // Success case without diff: show file in compact format + if (locations && locations.length > 0) { + const containerStatus = mapToolStatusToContainerStatus(toolCall.status); + return ( + + } + > +
+ + +
+
+ ); + } + + // No output, don't show anything + return null; +}; diff --git a/packages/vscode-ide-companion/src/webview/components/messages/toolcalls/Execute/Execute.css b/packages/vscode-ide-companion/src/webview/components/messages/toolcalls/Execute/Execute.css new file mode 100644 index 000000000..97a561c57 --- /dev/null +++ b/packages/vscode-ide-companion/src/webview/components/messages/toolcalls/Execute/Execute.css @@ -0,0 +1,102 @@ +/** + * @license + * Copyright 2025 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + * + * Execute tool call styles - Enhanced styling with semantic class names + */ + +/* Root container for execute tool call output */ +.execute-toolcall-card { + border: 0.5px solid var(--app-input-border); + border-radius: 5px; + background: var(--app-tool-background); + margin: 8px 0; + max-width: 100%; + font-size: 1em; + align-items: start; +} + +/* Content wrapper inside the card */ +.execute-toolcall-content { + display: flex; + flex-direction: column; + gap: 3px; + padding: 4px; +} + +/* Individual input/output row */ +.execute-toolcall-row { + display: grid; + grid-template-columns: max-content 1fr; + border-top: 0.5px solid var(--app-input-border); + padding: 4px; +} + +/* First row has no top border */ +.execute-toolcall-row:first-child { + border-top: none; +} + +/* Row label (IN/OUT/ERROR) */ +.execute-toolcall-label { + grid-column: 1; + color: var(--app-secondary-foreground); + text-align: left; + opacity: 50%; + padding: 4px 8px 4px 4px; + font-family: var(--app-monospace-font-family); + font-size: 0.85em; +} + +/* Row content area */ +.execute-toolcall-row-content { + grid-column: 2; + white-space: pre-wrap; + word-break: break-word; + margin: 0; + padding: 4px; +} + +/* Truncated content styling */ +.execute-toolcall-row-content:not(.execute-toolcall-full) { + max-height: 60px; + mask-image: linear-gradient( + to bottom, + var(--app-primary-background) 40px, + transparent 60px + ); + overflow: hidden; +} + +/* Preformatted content */ +.execute-toolcall-pre { + margin-block: 0; + overflow: hidden; + font-family: var(--app-monospace-font-family); + font-size: 0.85em; +} + +/* Code content */ +.execute-toolcall-code { + margin: 0; + padding: 0; + font-family: var(--app-monospace-font-family); + font-size: 0.85em; +} + +/* Output content with subtle styling */ +.execute-toolcall-output-subtle { + background-color: var(--app-code-background); + white-space: pre; + overflow-x: auto; + max-width: 100%; + min-width: 0; + width: 100%; + box-sizing: border-box; +} + +/* Error content styling */ +.execute-toolcall-error-content { + color: #c74e39; +} diff --git a/packages/vscode-ide-companion/src/webview/components/messages/toolcalls/Execute/Execute.tsx b/packages/vscode-ide-companion/src/webview/components/messages/toolcalls/Execute/Execute.tsx new file mode 100644 index 000000000..1a2f27544 --- /dev/null +++ b/packages/vscode-ide-companion/src/webview/components/messages/toolcalls/Execute/Execute.tsx @@ -0,0 +1,173 @@ +/** + * @license + * Copyright 2025 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + * + * Execute tool call component - specialized for command execution operations + */ + +import type React from 'react'; +import type { BaseToolCallProps } from '../shared/types.js'; +import { safeTitle, groupContent } from '../shared/utils.js'; +import './Execute.css'; +import type { ToolCallContainerProps } from '../shared/LayoutComponents.js'; + +export const ToolCallContainer: React.FC = ({ + label, + status = 'success', + children, + toolCallId: _toolCallId, + labelSuffix, + className: _className, +}) => ( +
+
+
+ + {label} + + + {labelSuffix} + +
+ {children && ( +
{children}
+ )} +
+
+); + +/** + * Specialized component for Execute tool calls + * Shows: Execute bullet + description + IN/OUT card + */ +export const ExecuteToolCall: React.FC = ({ toolCall }) => { + const { title, content, rawInput, toolCallId } = toolCall; + const commandText = safeTitle( + (rawInput as Record)?.description || title, + ); + + // Group content by type + const { textOutputs, errors } = groupContent(content); + + // Extract command from rawInput if available + let inputCommand = commandText; + if (rawInput && typeof rawInput === 'object') { + const inputObj = rawInput as Record; + inputCommand = (inputObj.command as string | undefined) || commandText; + } else if (typeof rawInput === 'string') { + inputCommand = rawInput; + } + + // Map tool status to container status for proper bullet coloring + const containerStatus: + | 'success' + | 'error' + | 'warning' + | 'loading' + | 'default' = + errors.length > 0 || toolCall.status === 'failed' + ? 'error' + : toolCall.status === 'in_progress' || toolCall.status === 'pending' + ? 'loading' + : 'success'; + + // Error case + if (errors.length > 0) { + return ( + + {/* Branch connector summary */} +
+ + {commandText} +
+ {/* Error card - semantic DOM + Tailwind styles */} +
+
+ {/* IN row */} +
+
IN
+
+
{inputCommand}
+
+
+ + {/* ERROR row */} +
+
Error
+
+
+                  {errors.join('\n')}
+                
+
+
+
+
+
+ ); + } + + // Success with output + if (textOutputs.length > 0) { + const output = textOutputs.join('\n'); + const truncatedOutput = + output.length > 500 ? output.substring(0, 500) + '...' : output; + + return ( + + {/* Branch connector summary */} +
+ + {commandText} +
+ {/* Output card - semantic DOM + Tailwind styles */} +
+
+ {/* IN row */} +
+
IN
+
+
{inputCommand}
+
+
+ + {/* OUT row */} +
+
OUT
+
+
+
{truncatedOutput}
+
+
+
+
+
+
+ ); + } + + // Success without output: show command with branch connector + return ( + +
+ + {commandText} +
+
+ ); +}; diff --git a/packages/vscode-ide-companion/src/webview/components/messages/toolcalls/GenericToolCall.tsx b/packages/vscode-ide-companion/src/webview/components/messages/toolcalls/GenericToolCall.tsx new file mode 100644 index 000000000..50e884439 --- /dev/null +++ b/packages/vscode-ide-companion/src/webview/components/messages/toolcalls/GenericToolCall.tsx @@ -0,0 +1,119 @@ +/** + * @license + * Copyright 2025 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + * + * Generic tool call component - handles all tool call types as fallback + */ + +import type React from 'react'; +import type { BaseToolCallProps } from './shared/types.js'; +import { + ToolCallContainer, + ToolCallCard, + ToolCallRow, + LocationsList, +} from './shared/LayoutComponents.js'; +import { safeTitle, groupContent } from './shared/utils.js'; + +/** + * Generic tool call component that can display any tool call type + * Used as fallback for unknown tool call kinds + * Minimal display: show description and outcome + */ +export const GenericToolCall: React.FC = ({ toolCall }) => { + const { kind, title, content, locations, toolCallId } = toolCall; + const operationText = safeTitle(title); + + // Group content by type + const { textOutputs, errors } = groupContent(content); + + // Error case: show operation + error in card layout + if (errors.length > 0) { + return ( + + +
{operationText}
+
+ +
{errors.join('\n')}
+
+
+ ); + } + + // Success with output: use card for long output, compact for short + if (textOutputs.length > 0) { + const output = textOutputs.join('\n'); + const isLong = output.length > 150; + + if (isLong) { + const truncatedOutput = + output.length > 300 ? output.substring(0, 300) + '...' : output; + + return ( + + +
{operationText}
+
+ +
+ {truncatedOutput} +
+
+
+ ); + } + + // Short output - compact format + const statusFlag: 'success' | 'error' | 'warning' | 'loading' | 'default' = + toolCall.status === 'in_progress' || toolCall.status === 'pending' + ? 'loading' + : 'success'; + return ( + + {operationText || output} + + ); + } + + // Success with files: show operation + file list in compact format + if (locations && locations.length > 0) { + const statusFlag: 'success' | 'error' | 'warning' | 'loading' | 'default' = + toolCall.status === 'in_progress' || toolCall.status === 'pending' + ? 'loading' + : 'success'; + return ( + + + + ); + } + + // No output - show just the operation + if (operationText) { + const statusFlag: 'success' | 'error' | 'warning' | 'loading' | 'default' = + toolCall.status === 'in_progress' || toolCall.status === 'pending' + ? 'loading' + : 'success'; + return ( + + {operationText} + + ); + } + + return null; +}; diff --git a/packages/vscode-ide-companion/src/webview/components/messages/toolcalls/Read/ReadToolCall.tsx b/packages/vscode-ide-companion/src/webview/components/messages/toolcalls/Read/ReadToolCall.tsx new file mode 100644 index 000000000..fcd1576cc --- /dev/null +++ b/packages/vscode-ide-companion/src/webview/components/messages/toolcalls/Read/ReadToolCall.tsx @@ -0,0 +1,177 @@ +/** + * @license + * Copyright 2025 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + * + * Read tool call component - specialized for file reading operations + */ + +import type React from 'react'; +import { useCallback, useEffect, useMemo } from 'react'; +import type { BaseToolCallProps } from '../shared/types.js'; +import { + groupContent, + mapToolStatusToContainerStatus, +} from '../shared/utils.js'; +import { FileLink } from '../../../layout/FileLink.js'; +import { useVSCode } from '../../../../hooks/useVSCode.js'; +import { handleOpenDiff } from '../../../../utils/diffUtils.js'; +import type { ToolCallContainerProps } from '../shared/LayoutComponents.js'; + +export const ToolCallContainer: React.FC = ({ + label, + status = 'success', + children, + toolCallId: _toolCallId, + labelSuffix, + className: _className, +}) => ( +
+
+
+ + {label} + + + {labelSuffix} + +
+ {children && ( +
+ {children} +
+ )} +
+
+); + +/** + * Specialized component for Read tool calls + * Optimized for displaying file reading operations + * Shows: Read filename (no content preview) + */ +export const ReadToolCall: React.FC = ({ toolCall }) => { + const { content, locations, toolCallId } = toolCall; + const vscode = useVSCode(); + + // Group content by type; memoize to avoid new array identities on every render + const { errors, diffs } = useMemo(() => groupContent(content), [content]); + + // Post a message to the extension host to open a VS Code diff tab + const handleOpenDiffInternal = useCallback( + ( + path: string | undefined, + oldText: string | null | undefined, + newText: string | undefined, + ) => { + handleOpenDiff(vscode, path, oldText, newText); + }, + [vscode], + ); + + // Auto-open diff when a read call returns diff content. + // Only trigger once per toolCallId so we don't spam as in-progress updates stream in. + useEffect(() => { + if (diffs.length > 0) { + const firstDiff = diffs[0]; + const path = firstDiff.path || (locations && locations[0]?.path) || ''; + + if ( + path && + firstDiff.oldText !== undefined && + firstDiff.newText !== undefined + ) { + const timer = setTimeout(() => { + handleOpenDiffInternal(path, firstDiff.oldText, firstDiff.newText); + }, 100); + return () => timer && clearTimeout(timer); + } + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [toolCallId]); + + // Compute container status based on toolCall.status (pending/in_progress -> loading) + const containerStatus: + | 'success' + | 'error' + | 'warning' + | 'loading' + | 'default' = mapToolStatusToContainerStatus(toolCall.status); + + // Error case: show error + if (errors.length > 0) { + const path = locations?.[0]?.path || ''; + return ( + + ) : undefined + } + > + {errors.join('\n')} + + ); + } + + // Success case with diff: keep UI compact; VS Code diff is auto-opened above + if (diffs.length > 0) { + const path = diffs[0]?.path || locations?.[0]?.path || ''; + return ( + + ) : undefined + } + > + {null} + + ); + } + + // Success case: show which file was read with filename in label + if (locations && locations.length > 0) { + const path = locations[0].path; + return ( + + ) : undefined + } + > + {null} + + ); + } + + // No file info, don't show + return null; +}; diff --git a/packages/vscode-ide-companion/src/webview/components/messages/toolcalls/Search/SearchToolCall.tsx b/packages/vscode-ide-companion/src/webview/components/messages/toolcalls/Search/SearchToolCall.tsx new file mode 100644 index 000000000..5a57c4435 --- /dev/null +++ b/packages/vscode-ide-companion/src/webview/components/messages/toolcalls/Search/SearchToolCall.tsx @@ -0,0 +1,227 @@ +/** + * @license + * Copyright 2025 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + * + * Search tool call component - specialized for search operations + */ + +import type React from 'react'; +import type { BaseToolCallProps } from '../shared/types.js'; +import { FileLink } from '../../../layout/FileLink.js'; +import { + safeTitle, + groupContent, + mapToolStatusToContainerStatus, +} from '../shared/utils.js'; + +/** + * Specialized component for Search tool calls + * Optimized for displaying search operations and results + * Shows query + result count or file list + */ +const InlineContainer: React.FC<{ + status: 'success' | 'error' | 'warning' | 'loading' | 'default'; + labelSuffix?: string; + children?: React.ReactNode; + isFirst?: boolean; + isLast?: boolean; +}> = ({ status, labelSuffix, children, isFirst, isLast }) => { + const beforeStatusClass = `toolcall-container toolcall-status-${status}`; + const lineCropTop = isFirst ? 'top-[24px]' : 'top-0'; + const lineCropBottom = isLast + ? 'bottom-auto h-[calc(100%-24px)]' + : 'bottom-0'; + return ( +
+ {/* timeline vertical line */} +
+
+
+ + Search + + {labelSuffix ? ( + + {labelSuffix} + + ) : null} +
+ {children ? ( +
+ {children} +
+ ) : null} +
+
+ ); +}; + +// Local card layout for multi-result or error display +const SearchCard: React.FC<{ + status: 'success' | 'error' | 'warning' | 'loading' | 'default'; + children: React.ReactNode; + isFirst?: boolean; + isLast?: boolean; +}> = ({ status, children, isFirst, isLast }) => { + const beforeStatusClass = + status === 'success' + ? 'before:text-qwen-success' + : status === 'error' + ? 'before:text-qwen-error' + : status === 'warning' + ? 'before:text-qwen-warning' + : 'before:text-qwen-loading before:opacity-70 before:animate-pulse-slow'; + const lineCropTop = isFirst ? 'top-[24px]' : 'top-0'; + const lineCropBottom = isLast + ? 'bottom-auto h-[calc(100%-24px)]' + : 'bottom-0'; + return ( +
+ {/* timeline vertical line */} +
+
+
{children}
+
+
+ ); +}; + +const SearchRow: React.FC<{ label: string; children: React.ReactNode }> = ({ + label, + children, +}) => ( +
+
+ {label} +
+
+ {children} +
+
+); + +const LocationsListLocal: React.FC<{ + locations: Array<{ path: string; line?: number | null }>; +}> = ({ locations }) => ( +
+ {locations.map((loc, idx) => ( + + ))} +
+); + +export const SearchToolCall: React.FC = ({ + toolCall, + isFirst, + isLast, +}) => { + const { title, content, locations } = toolCall; + const queryText = safeTitle(title); + + // Group content by type + const { errors, textOutputs } = groupContent(content); + + // Error case: show search query + error in card layout + if (errors.length > 0) { + return ( + + +
{queryText}
+
+ +
{errors.join('\n')}
+
+
+ ); + } + + // Success case with results: show search query + file list + if (locations && locations.length > 0) { + const containerStatus = mapToolStatusToContainerStatus(toolCall.status); + // If multiple results, use card layout; otherwise use compact format + if (locations.length > 1) { + return ( + + +
{queryText}
+
+ + + +
+ ); + } + // Single result - compact format + return ( + + + + + ); + } + + // Show content text if available (e.g., "Listed 4 item(s).") + if (textOutputs.length > 0) { + const containerStatus = mapToolStatusToContainerStatus(toolCall.status); + return ( + +
+ {textOutputs.map((text, index) => ( +
+ + {text} +
+ ))} +
+
+ ); + } + + // No results - show query only + if (queryText) { + const containerStatus = mapToolStatusToContainerStatus(toolCall.status); + return ( + + {queryText} + + ); + } + + return null; +}; diff --git a/packages/vscode-ide-companion/src/webview/components/messages/toolcalls/Think/ThinkToolCall.tsx b/packages/vscode-ide-companion/src/webview/components/messages/toolcalls/Think/ThinkToolCall.tsx new file mode 100644 index 000000000..4c49b2ccc --- /dev/null +++ b/packages/vscode-ide-companion/src/webview/components/messages/toolcalls/Think/ThinkToolCall.tsx @@ -0,0 +1,72 @@ +/** + * @license + * Copyright 2025 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + * + * Think tool call component - specialized for thinking/reasoning operations + */ + +import type React from 'react'; +import type { BaseToolCallProps } from '../shared/types.js'; +import { + ToolCallContainer, + ToolCallCard, + ToolCallRow, +} from '../shared/LayoutComponents.js'; +import { groupContent } from '../shared/utils.js'; + +/** + * Specialized component for Think tool calls + * Optimized for displaying AI reasoning and thought processes + * Minimal display: just show the thoughts (no context) + */ +export const ThinkToolCall: React.FC = ({ toolCall }) => { + const { content } = toolCall; + + // Group content by type + const { textOutputs, errors } = groupContent(content); + + // Error case (rare for thinking) + if (errors.length > 0) { + return ( + + {errors.join('\n')} + + ); + } + + // Show thoughts - use card for long content, compact for short + if (textOutputs.length > 0) { + const thoughts = textOutputs.join('\n\n'); + const isLong = thoughts.length > 200; + + if (isLong) { + const truncatedThoughts = + thoughts.length > 500 ? thoughts.substring(0, 500) + '...' : thoughts; + + return ( + + +
+ {truncatedThoughts} +
+
+
+ ); + } + + // Short thoughts - compact format + const status = + toolCall.status === 'pending' || toolCall.status === 'in_progress' + ? 'loading' + : 'default'; + return ( + + {thoughts} + + ); + } + + // Empty thoughts + return null; +}; diff --git a/packages/vscode-ide-companion/src/webview/components/messages/toolcalls/ToolCall.tsx b/packages/vscode-ide-companion/src/webview/components/messages/toolcalls/ToolCall.tsx new file mode 100644 index 000000000..6cda54a2f --- /dev/null +++ b/packages/vscode-ide-companion/src/webview/components/messages/toolcalls/ToolCall.tsx @@ -0,0 +1,29 @@ +/** + * @license + * Copyright 2025 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + * + * Main ToolCall component - uses factory pattern to route to specialized components + * + * This file serves as the public API for tool call rendering. + * It re-exports the router and types from the toolcalls module. + */ + +import type React from 'react'; +import { ToolCallRouter } from './index.js'; + +// Re-export types from the toolcalls module for backward compatibility +export type { + ToolCallData, + BaseToolCallProps as ToolCallProps, +} from './shared/types.js'; + +// Re-export the content type for external use +export type { ToolCallContent } from './shared/types.js'; +export const ToolCall: React.FC<{ + toolCall: import('./shared/types.js').ToolCallData; + isFirst?: boolean; + isLast?: boolean; +}> = ({ toolCall, isFirst, isLast }) => ( + +); diff --git a/packages/vscode-ide-companion/src/webview/components/messages/toolcalls/UpdatedPlan/CheckboxDisplay.tsx b/packages/vscode-ide-companion/src/webview/components/messages/toolcalls/UpdatedPlan/CheckboxDisplay.tsx new file mode 100644 index 000000000..d17ed073c --- /dev/null +++ b/packages/vscode-ide-companion/src/webview/components/messages/toolcalls/UpdatedPlan/CheckboxDisplay.tsx @@ -0,0 +1,84 @@ +/** + * @license + * Copyright 2025 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + */ + +import type React from 'react'; + +export interface CheckboxDisplayProps { + checked?: boolean; + indeterminate?: boolean; + disabled?: boolean; + className?: string; + style?: React.CSSProperties; + title?: string; +} + +/** + * Display-only checkbox styled via Tailwind classes. + * - Renders a custom-looking checkbox using appearance-none and pseudo-elements. + * - Supports indeterminate (middle) state using the DOM property and a data- attribute. + * - Intended for read-only display (disabled by default). + */ +export const CheckboxDisplay: React.FC = ({ + checked = false, + indeterminate = false, + disabled = true, + className = '', + style, + title, +}) => { + // Render as a span (not ) so we can draw a checkmark with CSS. + // Pseudo-elements do not reliably render on in Chromium (VS Code webviews), + // which caused the missing icon. This version is font-free and uses borders. + const showCheck = !!checked && !indeterminate; + const showInProgress = !!indeterminate; + + return ( + + {showCheck ? ( + + ) : null} + {showInProgress ? ( + + * + + ) : null} + + ); +}; diff --git a/packages/vscode-ide-companion/src/webview/components/messages/toolcalls/UpdatedPlan/UpdatedPlanToolCall.tsx b/packages/vscode-ide-companion/src/webview/components/messages/toolcalls/UpdatedPlan/UpdatedPlanToolCall.tsx new file mode 100644 index 000000000..70e38bd79 --- /dev/null +++ b/packages/vscode-ide-companion/src/webview/components/messages/toolcalls/UpdatedPlan/UpdatedPlanToolCall.tsx @@ -0,0 +1,166 @@ +/** + * @license + * Copyright 2025 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + * + * UpdatedPlan tool call component - specialized for plan update operations + */ + +import type React from 'react'; +import type { BaseToolCallProps } from '../shared/types.js'; +import type { ToolCallContainerProps } from '../shared/LayoutComponents.js'; +import { groupContent, safeTitle } from '../shared/utils.js'; +import { CheckboxDisplay } from './CheckboxDisplay.js'; +import type { PlanEntry } from '../../../../../types/chatTypes.js'; + +type EntryStatus = 'pending' | 'in_progress' | 'completed'; + +export const ToolCallContainer: React.FC = ({ + label, + status = 'success', + children, + toolCallId: _toolCallId, + labelSuffix, + className: _className, +}) => ( +
+
+
+ + {label} + + + {labelSuffix} + +
+ {children && ( +
+ {children} +
+ )} +
+
+); + +const mapToolStatusToBullet = ( + status: import('../shared/types.js').ToolCallStatus, +): 'success' | 'error' | 'warning' | 'loading' | 'default' => { + switch (status) { + case 'completed': + return 'success'; + case 'failed': + return 'error'; + case 'in_progress': + return 'warning'; + case 'pending': + return 'loading'; + default: + return 'default'; + } +}; + +// Parse plan entries with - [ ] / - [x] from text as much as possible +const parsePlanEntries = (textOutputs: string[]): PlanEntry[] => { + const text = textOutputs.join('\n'); + const lines = text.split(/\r?\n/); + const entries: PlanEntry[] = []; + + // Accept [ ], [x]/[X] and in-progress markers [-] or [*] + const todoRe = /^(?:\s*(?:[-*]|\d+[.)])\s*)?\[( |x|X|-|\*)\]\s+(.*)$/; + for (const line of lines) { + const m = line.match(todoRe); + if (m) { + const mark = m[1]; + const title = m[2].trim(); + const status: EntryStatus = + mark === 'x' || mark === 'X' + ? 'completed' + : mark === '-' || mark === '*' + ? 'in_progress' + : 'pending'; + if (title) { + entries.push({ content: title, status }); + } + } + } + + // If no match is found, fall back to treating non-empty lines as pending items + if (entries.length === 0) { + for (const line of lines) { + const title = line.trim(); + if (title) { + entries.push({ content: title, status: 'pending' }); + } + } + } + + return entries; +}; + +/** + * Specialized component for UpdatedPlan tool calls + * Optimized for displaying plan update operations + */ +export const UpdatedPlanToolCall: React.FC = ({ + toolCall, +}) => { + const { content, status } = toolCall; + const { errors, textOutputs } = groupContent(content); + + // Error-first display + if (errors.length > 0) { + return ( + + {errors.join('\n')} + + ); + } + + const entries = parsePlanEntries(textOutputs); + + const label = safeTitle(toolCall.title) || 'Updated Plan'; + + return ( + +
    + {entries.map((entry, idx) => { + const isDone = entry.status === 'completed'; + const isIndeterminate = entry.status === 'in_progress'; + return ( +
  • + + +
    + {entry.content} +
    +
  • + ); + })} +
+
+ ); +}; diff --git a/packages/vscode-ide-companion/src/webview/components/messages/toolcalls/Write/WriteToolCall.tsx b/packages/vscode-ide-companion/src/webview/components/messages/toolcalls/Write/WriteToolCall.tsx new file mode 100644 index 000000000..d0e6307b3 --- /dev/null +++ b/packages/vscode-ide-companion/src/webview/components/messages/toolcalls/Write/WriteToolCall.tsx @@ -0,0 +1,125 @@ +/** + * @license + * Copyright 2025 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + * + * Write tool call component - specialized for file writing operations + */ + +import type React from 'react'; +import type { BaseToolCallProps } from '../shared/types.js'; +import { ToolCallContainer } from '../shared/LayoutComponents.js'; +import { + groupContent, + mapToolStatusToContainerStatus, +} from '../shared/utils.js'; +import { FileLink } from '../../../layout/FileLink.js'; + +/** + * Specialized component for Write tool calls + * Shows: Write filename + error message + content preview + */ +export const WriteToolCall: React.FC = ({ toolCall }) => { + const { content, locations, rawInput, toolCallId } = toolCall; + + // Group content by type + const { errors, textOutputs } = groupContent(content); + + // Extract filename from path + // const getFileName = (path: string): string => path.split('/').pop() || path; + + // Extract content to write from rawInput + let writeContent = ''; + if (rawInput && typeof rawInput === 'object') { + const inputObj = rawInput as { content?: string }; + writeContent = inputObj.content || ''; + } else if (typeof rawInput === 'string') { + writeContent = rawInput; + } + + // Error case: show filename + error message + content preview + if (errors.length > 0) { + const path = locations?.[0]?.path || ''; + const errorMessage = errors.join('\n'); + + // Truncate content preview + const truncatedContent = + writeContent.length > 200 + ? writeContent.substring(0, 200) + '...' + : writeContent; + + return ( + + ) : undefined + } + > +
+ + {errorMessage} +
+ {truncatedContent && ( +
+
+              {truncatedContent}
+            
+
+ )} +
+ ); + } + + // Success case: show filename + line count + if (locations && locations.length > 0) { + const path = locations[0].path; + const lineCount = writeContent.split('\n').length; + const containerStatus = mapToolStatusToContainerStatus(toolCall.status); + return ( + + ) : undefined + } + > +
+ + {lineCount} lines +
+
+ ); + } + + // Fallback: show generic success + if (textOutputs.length > 0) { + const containerStatus = mapToolStatusToContainerStatus(toolCall.status); + return ( + + {textOutputs.join('\n')} + + ); + } + + // No output, don't show anything + return null; +}; diff --git a/packages/vscode-ide-companion/src/webview/components/messages/toolcalls/index.tsx b/packages/vscode-ide-companion/src/webview/components/messages/toolcalls/index.tsx new file mode 100644 index 000000000..3f7a2bc0d --- /dev/null +++ b/packages/vscode-ide-companion/src/webview/components/messages/toolcalls/index.tsx @@ -0,0 +1,90 @@ +/** + * @license + * Copyright 2025 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + * + * Tool call component factory - routes to specialized components by kind + */ + +import type React from 'react'; +import type { BaseToolCallProps } from './shared/types.js'; +import { shouldShowToolCall } from './shared/utils.js'; +import { GenericToolCall } from './GenericToolCall.js'; +import { ReadToolCall } from './Read/ReadToolCall.js'; +import { WriteToolCall } from './Write/WriteToolCall.js'; +import { EditToolCall } from './Edit/EditToolCall.js'; +import { ExecuteToolCall as BashExecuteToolCall } from './Bash/Bash.js'; +import { ExecuteToolCall } from './Execute/Execute.js'; +import { UpdatedPlanToolCall } from './UpdatedPlan/UpdatedPlanToolCall.js'; +import { SearchToolCall } from './Search/SearchToolCall.js'; +import { ThinkToolCall } from './Think/ThinkToolCall.js'; + +/** + * Factory function that returns the appropriate tool call component based on kind + */ +export const getToolCallComponent = ( + kind: string, +): React.FC => { + const normalizedKind = kind.toLowerCase(); + + // Route to specialized components + switch (normalizedKind) { + case 'read': + return ReadToolCall; + + case 'write': + return WriteToolCall; + + case 'edit': + return EditToolCall; + + case 'execute': + return ExecuteToolCall; + + case 'bash': + case 'command': + return BashExecuteToolCall; + + case 'updated_plan': + case 'updatedplan': + case 'todo_write': + case 'update_todos': + case 'todowrite': + return UpdatedPlanToolCall; + + case 'search': + case 'grep': + case 'glob': + case 'find': + return SearchToolCall; + + case 'think': + case 'thinking': + return ThinkToolCall; + + default: + // Fallback to generic component + return GenericToolCall; + } +}; + +/** + * Main tool call component that routes to specialized implementations + */ +export const ToolCallRouter: React.FC< + BaseToolCallProps & { isFirst?: boolean; isLast?: boolean } +> = ({ toolCall, isFirst, isLast }) => { + // Check if we should show this tool call (hide internal ones) + if (!shouldShowToolCall(toolCall.kind)) { + return null; + } + + // Get the appropriate component for this kind + const Component = getToolCallComponent(toolCall.kind); + + // Render the specialized component + return ; +}; + +// Re-export types for convenience +export type { BaseToolCallProps, ToolCallData } from './shared/types.js'; diff --git a/packages/vscode-ide-companion/src/webview/components/messages/toolcalls/shared/LayoutComponents.css b/packages/vscode-ide-companion/src/webview/components/messages/toolcalls/shared/LayoutComponents.css new file mode 100644 index 000000000..e5b2cce9b --- /dev/null +++ b/packages/vscode-ide-companion/src/webview/components/messages/toolcalls/shared/LayoutComponents.css @@ -0,0 +1,205 @@ +/** + * @license + * Copyright 2025 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + * + * LayoutComponents.css - Tool call layout styles with timeline support + */ + +/* ToolCallContainer with timeline support */ +.toolcall-container { + position: relative; + padding-left: 30px; + padding-top: 8px; + padding-bottom: 8px; + user-select: text; + align-items: flex-start; +} + +/* Default timeline connector line */ +.toolcall-container::after { + content: ''; + position: absolute; + left: 12px; + top: 0; + bottom: 0; + width: 1px; + background-color: var(--app-primary-border-color); +} + +/* First item: connector starts from status point position */ +.toolcall-container:first-child::after { + top: 24px; +} + +/* Last item: connector shows only upper part */ +.toolcall-container:last-child::after { + height: calc(100% - 24px); + top: 0; + bottom: auto; +} + +/* Status-specific styles using ::before pseudo-element for bullet points */ +.toolcall-container.toolcall-status-default::before { + content: '\25cf'; + position: absolute; + left: 8px; + padding-top: 2px; + font-size: 10px; + color: var(--app-secondary-foreground); + z-index: 1; +} + +.toolcall-container.toolcall-status-success::before { + content: '\25cf'; + position: absolute; + left: 8px; + font-size: 10px; + color: #74c991; + z-index: 1; +} + +.toolcall-container.toolcall-status-error::before { + content: '\25cf'; + position: absolute; + left: 8px; + font-size: 10px; + color: #c74e39; + z-index: 1; +} + +.toolcall-container.toolcall-status-warning::before { + content: '\25cf'; + position: absolute; + left: 8px; + padding-top: 2px; + font-size: 10px; + color: #e1c08d; + z-index: 1; +} + +.toolcall-container.toolcall-status-loading::before { + content: '\25cf'; + position: absolute; + left: 8px; + padding-top: 2px; + font-size: 10px; + color: var(--app-secondary-foreground); + background-color: var(--app-secondary-background); + animation: toolcallPulse 1s linear infinite; + z-index: 1; +} + +/* Loading animation */ +@keyframes toolcallPulse { + 0%, + 100% { + opacity: 1; + } + 50% { + opacity: 0.5; + } +} + +/* Content wrapper */ +.toolcall-content-wrapper { + flex: 1; + display: flex; + flex-direction: column; + gap: 6px; + min-width: 0; + max-width: 100%; +} + +/* Legacy card styles */ +.toolcall-card { + grid-template-columns: auto 1fr; + gap: var(--spacing-medium); + background: var(--app-input-background); + border: 1px solid var(--app-input-border); + border-radius: var(--border-radius-medium); + padding: var(--spacing-large); + margin: var(--spacing-medium) 0; + align-items: start; + animation: fadeIn 0.2s ease-in; +} + +/* Legacy row styles */ +.toolcall-row { + grid-template-columns: 80px 1fr; + gap: var(--spacing-medium); + min-width: 0; +} + +.toolcall-row-label { + font-size: var(--font-size-xs); + color: var(--app-secondary-foreground); + font-weight: 500; + padding-top: 2px; +} + +.toolcall-row-content { + color: var(--app-primary-foreground); + min-width: 0; + word-break: break-word; +} + +/* Locations list */ +.toolcall-locations-list { + display: flex; + flex-direction: column; + gap: 4px; + max-width: 100%; +} + +/* ToolCall header with loading indicator */ +.toolcall-header { + position: relative; +} + +.toolcall-header::before { + content: '\25cf'; + position: absolute; + left: -22px; + top: 50%; + transform: translateY(-50%); + font-size: 10px; + line-height: 1; + z-index: 1; + color: #e1c08d; + animation: toolcallHeaderPulse 1.5s ease-in-out infinite; +} + +/* Loading animation for toolcall header */ +@keyframes toolcallHeaderPulse { + 0%, + 100% { + opacity: 1; + } + 50% { + opacity: 0.5; + } +} + +/* In-progress toolcall specific styles */ +.in-progress-toolcall .toolcall-content-wrapper { + display: flex; + flex-direction: column; + gap: 1; + min-width: 0; + max-width: 100%; +} + +.in-progress-toolcall .toolcall-header { + display: flex; + align-items: center; + gap: 2; + position: relative; + min-width: 0; +} + +.in-progress-toolcall .toolcall-content-text { + word-break: break-word; + white-space: pre-wrap; + width: 100%; +} diff --git a/packages/vscode-ide-companion/src/webview/components/messages/toolcalls/shared/LayoutComponents.tsx b/packages/vscode-ide-companion/src/webview/components/messages/toolcalls/shared/LayoutComponents.tsx new file mode 100644 index 000000000..89a0b14cc --- /dev/null +++ b/packages/vscode-ide-companion/src/webview/components/messages/toolcalls/shared/LayoutComponents.tsx @@ -0,0 +1,174 @@ +/** + * @license + * Copyright 2025 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + * + * Shared layout components for tool call UI + */ + +import type React from 'react'; +import { FileLink } from '../../../layout/FileLink.js'; +import './LayoutComponents.css'; + +/** + * Props for ToolCallContainer + */ +export interface ToolCallContainerProps { + /** Operation label (e.g., "Read", "Write", "Search") */ + label: string; + /** Status for bullet color: 'success' | 'error' | 'warning' | 'loading' | 'default' */ + status?: 'success' | 'error' | 'warning' | 'loading' | 'default'; + /** Main content to display */ + children: React.ReactNode; + /** Tool call ID for debugging */ + toolCallId?: string; + /** Optional trailing content rendered next to label (e.g., clickable filename) */ + labelSuffix?: React.ReactNode; + /** Optional custom class name */ + className?: string; +} + +export const ToolCallContainer: React.FC = ({ + label, + status = 'success', + children, + toolCallId: _toolCallId, + labelSuffix, + className: _className, +}) => ( +
+
+
+ + {label} + + + {labelSuffix} + +
+ {children && ( +
+ {children} +
+ )} +
+
+); + +interface ToolCallCardProps { + icon: string; + children: React.ReactNode; +} + +/** + * Legacy card wrapper - kept for backward compatibility with complex layouts like diffs + */ +export const ToolCallCard: React.FC = ({ + icon: _icon, + children, +}) => ( +
+
{children}
+
+); + +interface ToolCallRowProps { + label: string; + children: React.ReactNode; +} + +/** + * A single row in the tool call grid (legacy - for complex layouts) + */ +export const ToolCallRow: React.FC = ({ + label, + children, +}) => ( +
+
+ {label} +
+
+ {children} +
+
+); + +/** + * Props for StatusIndicator + */ +interface StatusIndicatorProps { + status: 'pending' | 'in_progress' | 'completed' | 'failed'; + text: string; +} + +/** + * Get status color class + */ +const getStatusColorClass = ( + status: 'pending' | 'in_progress' | 'completed' | 'failed', +): string => { + switch (status) { + case 'pending': + return 'bg-[#ffc107]'; + case 'in_progress': + return 'bg-[#2196f3]'; + case 'completed': + return 'bg-[#4caf50]'; + case 'failed': + return 'bg-[#f44336]'; + default: + return 'bg-gray-500'; + } +}; + +/** + * Status indicator with colored dot + */ +export const StatusIndicator: React.FC = ({ + status, + text, +}) => ( +
+ + {text} +
+); + +interface CodeBlockProps { + children: string; +} + +/** + * Code block for displaying formatted code or output + */ +export const CodeBlock: React.FC = ({ children }) => ( +
+    {children}
+  
+); + +/** + * Props for LocationsList + */ +interface LocationsListProps { + locations: Array<{ + path: string; + line?: number | null; + }>; +} + +/** + * List of file locations with clickable links + */ +export const LocationsList: React.FC = ({ locations }) => ( +
+ {locations.map((loc, idx) => ( + + ))} +
+); diff --git a/packages/vscode-ide-companion/src/webview/components/messages/toolcalls/shared/types.ts b/packages/vscode-ide-companion/src/webview/components/messages/toolcalls/shared/types.ts new file mode 100644 index 000000000..0fccb1865 --- /dev/null +++ b/packages/vscode-ide-companion/src/webview/components/messages/toolcalls/shared/types.ts @@ -0,0 +1,72 @@ +/** + * @license + * Copyright 2025 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + * + * Shared types for tool call components + */ + +/** + * Tool call content types + */ +export interface ToolCallContent { + type: 'content' | 'diff'; + // For content type + content?: { + type: string; + text?: string; + error?: unknown; + [key: string]: unknown; + }; + // For diff type + path?: string; + oldText?: string | null; + newText?: string; +} + +/** + * Tool call location type + */ +export interface ToolCallLocation { + path: string; + line?: number | null; +} + +/** + * Tool call status type + */ +export type ToolCallStatus = 'pending' | 'in_progress' | 'completed' | 'failed'; + +/** + * Base tool call data interface + */ +export interface ToolCallData { + toolCallId: string; + kind: string; + title: string | object; + status: ToolCallStatus; + rawInput?: string | object; + content?: ToolCallContent[]; + locations?: ToolCallLocation[]; + timestamp?: number; // Add a timestamp field for message sorting +} + +/** + * Base props for all tool call components + */ +export interface BaseToolCallProps { + toolCall: ToolCallData; + // Optional timeline flags for rendering connector line cropping + isFirst?: boolean; + isLast?: boolean; +} + +/** + * Grouped content structure for rendering + */ +export interface GroupedContent { + textOutputs: string[]; + errors: string[]; + diffs: ToolCallContent[]; + otherData: unknown[]; +} diff --git a/packages/vscode-ide-companion/src/webview/components/messages/toolcalls/shared/utils.ts b/packages/vscode-ide-companion/src/webview/components/messages/toolcalls/shared/utils.ts new file mode 100644 index 000000000..ceb2cb2b1 --- /dev/null +++ b/packages/vscode-ide-companion/src/webview/components/messages/toolcalls/shared/utils.ts @@ -0,0 +1,210 @@ +/** + * @license + * Copyright 2025 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + * + * Shared utility functions for tool call components + */ + +import type { + ToolCallContent, + GroupedContent, + ToolCallStatus, +} from './types.js'; + +/** + * Format any value to a string for display + */ +export const formatValue = (value: unknown): string => { + if (value === null || value === undefined) { + return ''; + } + if (typeof value === 'string') { + // TODO: Trying to take out the Output part from the string + try { + value = (JSON.parse(value) as { output?: unknown }).output ?? value; + } catch (_error) { + // ignore JSON parse errors + } + return value as string; + } + // Handle Error objects specially + if (value instanceof Error) { + return value.message || value.toString(); + } + // Handle error-like objects with message property + if (typeof value === 'object' && value !== null && 'message' in value) { + const errorObj = value as { message?: string; stack?: string }; + return errorObj.message || String(value); + } + if (typeof value === 'object') { + try { + return JSON.stringify(value, null, 2); + } catch (_e) { + return String(value); + } + } + return String(value); +}; + +/** + * Safely convert title to string, handling object types + * Returns empty string if no meaningful title + */ +export const safeTitle = (title: unknown): string => { + if (typeof title === 'string' && title.trim()) { + return title; + } + if (title && typeof title === 'object') { + return JSON.stringify(title); + } + return ''; +}; + +/** + * Check if a tool call should be displayed + * Hides internal tool calls + */ +export const shouldShowToolCall = (kind: string): boolean => + !kind.includes('internal'); + +/** + * Check if a tool call has actual output to display + * Returns false for tool calls that completed successfully but have no visible output + */ +export const hasToolCallOutput = ( + toolCall: import('./types.js').ToolCallData, +): boolean => { + // Always show failed tool calls (even without content) + if (toolCall.status === 'failed') { + return true; + } + + // Always show execute/bash/command tool calls (they show the command in title) + const kind = toolCall.kind.toLowerCase(); + if (kind === 'execute' || kind === 'bash' || kind === 'command') { + // But only if they have a title + if ( + toolCall.title && + typeof toolCall.title === 'string' && + toolCall.title.trim() + ) { + return true; + } + } + + // Show if there are locations (file paths) + if (toolCall.locations && toolCall.locations.length > 0) { + return true; + } + + // Show if there is content + if (toolCall.content && toolCall.content.length > 0) { + const grouped = groupContent(toolCall.content); + // Has any meaningful content? + if ( + grouped.textOutputs.length > 0 || + grouped.errors.length > 0 || + grouped.diffs.length > 0 || + grouped.otherData.length > 0 + ) { + return true; + } + } + + // Show if there's a meaningful title for generic tool calls + if ( + toolCall.title && + typeof toolCall.title === 'string' && + toolCall.title.trim() + ) { + return true; + } + + // No output, don't show + return false; +}; + +/** + * Group tool call content by type to avoid duplicate labels + */ +export const groupContent = (content?: ToolCallContent[]): GroupedContent => { + const textOutputs: string[] = []; + const errors: string[] = []; + const diffs: ToolCallContent[] = []; + const otherData: unknown[] = []; + + content?.forEach((item) => { + if (item.type === 'diff') { + diffs.push(item); + } else if (item.content) { + const contentObj = item.content; + + // Handle error content + if (contentObj.type === 'error' || 'error' in contentObj) { + // Try to extract meaningful error message + let errorMsg = ''; + + // Check if error is a string + if (typeof contentObj.error === 'string') { + errorMsg = contentObj.error; + } + // Check if error has a message property + else if ( + contentObj.error && + typeof contentObj.error === 'object' && + 'message' in contentObj.error + ) { + errorMsg = (contentObj.error as { message: string }).message; + } + // Try text field + else if (contentObj.text) { + errorMsg = formatValue(contentObj.text); + } + // Format the error object itself + else if (contentObj.error) { + errorMsg = formatValue(contentObj.error); + } + // Fallback + else { + errorMsg = 'An error occurred'; + } + + errors.push(errorMsg); + } + // Handle text content + else if (contentObj.text) { + textOutputs.push(formatValue(contentObj.text)); + } + // Handle other content + else { + otherData.push(contentObj); + } + } + }); + + return { textOutputs, errors, diffs, otherData }; +}; + +/** + * Map a tool call status to a ToolCallContainer status (bullet color) + * - pending/in_progress -> loading + * - completed -> success + * - failed -> error + * - default fallback + */ +export const mapToolStatusToContainerStatus = ( + status: ToolCallStatus, +): 'success' | 'error' | 'warning' | 'loading' | 'default' => { + switch (status) { + case 'pending': + case 'in_progress': + return 'loading'; + case 'failed': + return 'error'; + case 'completed': + return 'success'; + default: + return 'default'; + } +}; diff --git a/packages/vscode-ide-companion/src/webview/handlers/AuthMessageHandler.ts b/packages/vscode-ide-companion/src/webview/handlers/AuthMessageHandler.ts new file mode 100644 index 000000000..ab4b70b2e --- /dev/null +++ b/packages/vscode-ide-companion/src/webview/handlers/AuthMessageHandler.ts @@ -0,0 +1,83 @@ +/** + * @license + * Copyright 2025 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + */ + +import * as vscode from 'vscode'; +import { BaseMessageHandler } from './BaseMessageHandler.js'; + +/** + * Auth message handler + * Handles all authentication-related messages + */ +export class AuthMessageHandler extends BaseMessageHandler { + private loginHandler: (() => Promise) | null = null; + + canHandle(messageType: string): boolean { + return ['login'].includes(messageType); + } + + async handle(message: { type: string; data?: unknown }): Promise { + switch (message.type) { + case 'login': + await this.handleLogin(); + break; + + default: + console.warn( + '[AuthMessageHandler] Unknown message type:', + message.type, + ); + break; + } + } + + /** + * Set login handler + */ + setLoginHandler(handler: () => Promise): void { + this.loginHandler = handler; + } + + /** + * Handle login request + */ + private async handleLogin(): Promise { + try { + console.log('[AuthMessageHandler] Login requested'); + console.log( + '[AuthMessageHandler] Login handler available:', + !!this.loginHandler, + ); + + // Direct login without additional confirmation + if (this.loginHandler) { + console.log('[AuthMessageHandler] Calling login handler'); + await this.loginHandler(); + console.log( + '[AuthMessageHandler] Login handler completed successfully', + ); + } else { + console.log('[AuthMessageHandler] Using fallback login method'); + // Fallback: show message and use command + vscode.window.showInformationMessage( + 'Please wait while we connect to Qwen Code...', + ); + await vscode.commands.executeCommand('qwen-code.login'); + } + } catch (error) { + console.error('[AuthMessageHandler] Login failed:', error); + console.error( + '[AuthMessageHandler] Error stack:', + error instanceof Error ? error.stack : 'N/A', + ); + this.sendToWebView({ + type: 'loginError', + data: { + message: `Login failed: ${error instanceof Error ? error.message : String(error)}`, + }, + }); + } + } +} diff --git a/packages/vscode-ide-companion/src/webview/handlers/BaseMessageHandler.ts b/packages/vscode-ide-companion/src/webview/handlers/BaseMessageHandler.ts new file mode 100644 index 000000000..4d01fd021 --- /dev/null +++ b/packages/vscode-ide-companion/src/webview/handlers/BaseMessageHandler.ts @@ -0,0 +1,58 @@ +/** + * @license + * Copyright 2025 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + */ + +import type { QwenAgentManager } from '../../services/qwenAgentManager.js'; +import type { ConversationStore } from '../../services/conversationStore.js'; + +/** + * Base message handler interface + * All sub-handlers should implement this interface + */ +export interface IMessageHandler { + /** + * Handle message + * @param message - Message object + * @returns Promise + */ + handle(message: { type: string; data?: unknown }): Promise; + + /** + * Check if this handler can handle the message type + * @param messageType - Message type + * @returns boolean + */ + canHandle(messageType: string): boolean; +} + +/** + * Base message handler class + * Provides common dependency injection and helper methods + */ +export abstract class BaseMessageHandler implements IMessageHandler { + constructor( + protected agentManager: QwenAgentManager, + protected conversationStore: ConversationStore, + protected currentConversationId: string | null, + protected sendToWebView: (message: unknown) => void, + ) {} + + abstract handle(message: { type: string; data?: unknown }): Promise; + abstract canHandle(messageType: string): boolean; + + /** + * Update current conversation ID + */ + setCurrentConversationId(id: string | null): void { + this.currentConversationId = id; + } + + /** + * Get current conversation ID + */ + getCurrentConversationId(): string | null { + return this.currentConversationId; + } +} diff --git a/packages/vscode-ide-companion/src/webview/handlers/EditorMessageHandler.ts b/packages/vscode-ide-companion/src/webview/handlers/EditorMessageHandler.ts new file mode 100644 index 000000000..7d82315dc --- /dev/null +++ b/packages/vscode-ide-companion/src/webview/handlers/EditorMessageHandler.ts @@ -0,0 +1,111 @@ +/** + * @license + * Copyright 2025 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + */ + +import * as vscode from 'vscode'; +import { BaseMessageHandler } from './BaseMessageHandler.js'; +import { getFileName } from '../utils/webviewUtils.js'; + +/** + * Editor message handler + * Handles all editor state-related messages + */ +export class EditorMessageHandler extends BaseMessageHandler { + canHandle(messageType: string): boolean { + return ['getActiveEditor', 'focusActiveEditor'].includes(messageType); + } + + async handle(message: { type: string; data?: unknown }): Promise { + switch (message.type) { + case 'getActiveEditor': + await this.handleGetActiveEditor(); + break; + + case 'focusActiveEditor': + await this.handleFocusActiveEditor(); + break; + + default: + console.warn( + '[EditorMessageHandler] Unknown message type:', + message.type, + ); + break; + } + } + + /** + * Get current active editor info + */ + private async handleGetActiveEditor(): Promise { + try { + const activeEditor = vscode.window.activeTextEditor; + + if (activeEditor) { + const filePath = activeEditor.document.uri.fsPath; + const fileName = getFileName(filePath); + + let selectionInfo = null; + if (!activeEditor.selection.isEmpty) { + const selection = activeEditor.selection; + selectionInfo = { + startLine: selection.start.line + 1, + endLine: selection.end.line + 1, + }; + } + + this.sendToWebView({ + type: 'activeEditorChanged', + data: { fileName, filePath, selection: selectionInfo }, + }); + } else { + this.sendToWebView({ + type: 'activeEditorChanged', + data: { fileName: null, filePath: null, selection: null }, + }); + } + } catch (error) { + console.error( + '[EditorMessageHandler] Failed to get active editor:', + error, + ); + } + } + + /** + * Focus on active editor + */ + private async handleFocusActiveEditor(): Promise { + try { + const activeEditor = vscode.window.activeTextEditor; + + if (activeEditor) { + await vscode.window.showTextDocument(activeEditor.document, { + viewColumn: activeEditor.viewColumn, + preserveFocus: false, + }); + } else { + // If no active editor, show file picker + const uri = await vscode.window.showOpenDialog({ + defaultUri: vscode.workspace.workspaceFolders?.[0]?.uri, + canSelectMany: false, + canSelectFiles: true, + canSelectFolders: false, + openLabel: 'Open', + }); + + if (uri && uri.length > 0) { + await vscode.window.showTextDocument(uri[0]); + } + } + } catch (error) { + console.error( + '[EditorMessageHandler] Failed to focus active editor:', + error, + ); + vscode.window.showErrorMessage(`Failed to focus editor: ${error}`); + } + } +} diff --git a/packages/vscode-ide-companion/src/webview/handlers/FileMessageHandler.ts b/packages/vscode-ide-companion/src/webview/handlers/FileMessageHandler.ts new file mode 100644 index 000000000..28ecbbd3a --- /dev/null +++ b/packages/vscode-ide-companion/src/webview/handlers/FileMessageHandler.ts @@ -0,0 +1,445 @@ +/** + * @license + * Copyright 2025 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + */ + +import * as vscode from 'vscode'; +import * as fs from 'fs'; +import * as os from 'os'; +import * as path from 'path'; +import { BaseMessageHandler } from './BaseMessageHandler.js'; +import { getFileName } from '../utils/webviewUtils.js'; +import { showDiffCommand } from '../../commands/index.js'; + +/** + * File message handler + * Handles all file-related messages + */ +export class FileMessageHandler extends BaseMessageHandler { + canHandle(messageType: string): boolean { + return [ + 'attachFile', + 'showContextPicker', + 'getWorkspaceFiles', + 'openFile', + 'openDiff', + 'createAndOpenTempFile', + ].includes(messageType); + } + + async handle(message: { type: string; data?: unknown }): Promise { + const data = message.data as Record | undefined; + + switch (message.type) { + case 'attachFile': + await this.handleAttachFile(); + break; + + case 'showContextPicker': + await this.handleShowContextPicker(); + break; + + case 'getWorkspaceFiles': + await this.handleGetWorkspaceFiles(data?.query as string | undefined); + break; + + case 'openFile': + await this.handleOpenFile(data?.path as string | undefined); + break; + + case 'openDiff': + await this.handleOpenDiff(data); + break; + + case 'createAndOpenTempFile': + await this.handleCreateAndOpenTempFile(data); + break; + + default: + console.warn( + '[FileMessageHandler] Unknown message type:', + message.type, + ); + break; + } + } + + /** + * Handle attach file request + */ + private async handleAttachFile(): Promise { + try { + const uris = await vscode.window.showOpenDialog({ + canSelectMany: false, + canSelectFiles: true, + canSelectFolders: false, + openLabel: 'Attach', + }); + + if (uris && uris.length > 0) { + const uri = uris[0]; + const fileName = getFileName(uri.fsPath); + + this.sendToWebView({ + type: 'fileAttached', + data: { + id: `file-${Date.now()}`, + type: 'file', + name: fileName, + value: uri.fsPath, + }, + }); + } + } catch (error) { + console.error('[FileMessageHandler] Failed to attach file:', error); + this.sendToWebView({ + type: 'error', + data: { message: `Failed to attach file: ${error}` }, + }); + } + } + + /** + * Handle show context picker request + */ + private async handleShowContextPicker(): Promise { + try { + const items: vscode.QuickPickItem[] = []; + + // Add current file + const activeEditor = vscode.window.activeTextEditor; + if (activeEditor) { + const fileName = getFileName(activeEditor.document.uri.fsPath); + items.push({ + label: `$(file) ${fileName}`, + description: 'Current file', + detail: activeEditor.document.uri.fsPath, + }); + } + + // Add file picker option + items.push({ + label: '$(file) File...', + description: 'Choose a file to attach', + }); + + // Add workspace files option + items.push({ + label: '$(search) Search files...', + description: 'Search workspace files', + }); + + const selected = await vscode.window.showQuickPick(items, { + placeHolder: 'Attach context', + matchOnDescription: true, + matchOnDetail: true, + }); + + if (selected) { + if (selected.label.includes('Current file') && activeEditor) { + const fileName = getFileName(activeEditor.document.uri.fsPath); + this.sendToWebView({ + type: 'fileAttached', + data: { + id: `file-${Date.now()}`, + type: 'file', + name: fileName, + value: activeEditor.document.uri.fsPath, + }, + }); + } else if (selected.label.includes('File...')) { + await this.handleAttachFile(); + } else if (selected.label.includes('Search files')) { + const uri = await vscode.window.showOpenDialog({ + defaultUri: vscode.workspace.workspaceFolders?.[0]?.uri, + canSelectMany: false, + canSelectFiles: true, + canSelectFolders: false, + openLabel: 'Attach', + }); + + if (uri && uri.length > 0) { + const fileName = getFileName(uri[0].fsPath); + this.sendToWebView({ + type: 'fileAttached', + data: { + id: `file-${Date.now()}`, + type: 'file', + name: fileName, + value: uri[0].fsPath, + }, + }); + } + } + } + } catch (error) { + console.error( + '[FileMessageHandler] Failed to show context picker:', + error, + ); + this.sendToWebView({ + type: 'error', + data: { message: `Failed to show context picker: ${error}` }, + }); + } + } + + /** + * Get workspace files + */ + private async handleGetWorkspaceFiles(query?: string): Promise { + try { + console.log('[FileMessageHandler] handleGetWorkspaceFiles start', { + query, + }); + const files: Array<{ + id: string; + label: string; + description: string; + path: string; + }> = []; + const addedPaths = new Set(); + + const addFile = (uri: vscode.Uri, isCurrentFile = false) => { + if (addedPaths.has(uri.fsPath)) { + return; + } + + const fileName = getFileName(uri.fsPath); + const workspaceFolder = vscode.workspace.getWorkspaceFolder(uri); + const relativePath = workspaceFolder + ? vscode.workspace.asRelativePath(uri, false) + : uri.fsPath; + + // Filter by query if provided + if ( + query && + !fileName.toLowerCase().includes(query.toLowerCase()) && + !relativePath.toLowerCase().includes(query.toLowerCase()) + ) { + return; + } + + files.push({ + id: isCurrentFile ? 'current-file' : uri.fsPath, + label: fileName, + description: relativePath, + path: uri.fsPath, + }); + addedPaths.add(uri.fsPath); + }; + + // Search or show recent files + if (query) { + // Query mode: perform filesystem search (may take longer on large workspaces) + console.log( + '[FileMessageHandler] Searching workspace files for query', + query, + ); + const uris = await vscode.workspace.findFiles( + `**/*${query}*`, + '**/node_modules/**', + 50, + ); + + for (const uri of uris) { + addFile(uri); + } + } else { + // Non-query mode: respond quickly with currently active and open files + // Add current active file first + const activeEditor = vscode.window.activeTextEditor; + if (activeEditor) { + addFile(activeEditor.document.uri, true); + } + + // Add all open tabs + const tabGroups = vscode.window.tabGroups.all; + for (const tabGroup of tabGroups) { + for (const tab of tabGroup.tabs) { + const input = tab.input as { uri?: vscode.Uri } | undefined; + if (input && input.uri instanceof vscode.Uri) { + addFile(input.uri); + } + } + } + + // Send an initial quick response so UI can render immediately + try { + this.sendToWebView({ type: 'workspaceFiles', data: { files } }); + console.log( + '[FileMessageHandler] Sent initial workspaceFiles (open tabs/active)', + files.length, + ); + } catch (e) { + console.warn( + '[FileMessageHandler] Failed sending initial response', + e, + ); + } + + // If not enough files, add some workspace files (bounded) + if (files.length < 10) { + const recentUris = await vscode.workspace.findFiles( + '**/*', + '**/node_modules/**', + 20, + ); + + for (const uri of recentUris) { + if (files.length >= 20) { + break; + } + addFile(uri); + } + } + } + + this.sendToWebView({ type: 'workspaceFiles', data: { files } }); + console.log( + '[FileMessageHandler] Sent final workspaceFiles', + files.length, + ); + } catch (error) { + console.error( + '[FileMessageHandler] Failed to get workspace files:', + error, + ); + this.sendToWebView({ + type: 'error', + data: { message: `Failed to get workspace files: ${error}` }, + }); + } + } + + /** + * Open file + */ + private async handleOpenFile(filePath?: string): Promise { + if (!filePath) { + console.warn('[FileMessageHandler] No path provided for openFile'); + return; + } + + try { + console.log('[FileOperations] Opening file:', filePath); + + // Parse file path, line number, and column number + // Formats: path/to/file.ts, path/to/file.ts:123, path/to/file.ts:123:45 + const match = filePath.match(/^(.+?)(?::(\d+))?(?::(\d+))?$/); + if (!match) { + console.warn('[FileOperations] Invalid file path format:', filePath); + return; + } + + const [, path, lineStr, columnStr] = match; + const lineNumber = lineStr ? parseInt(lineStr, 10) - 1 : 0; // VS Code uses 0-based line numbers + const columnNumber = columnStr ? parseInt(columnStr, 10) - 1 : 0; // VS Code uses 0-based column numbers + + // Convert to absolute path if relative + let absolutePath = path; + if (!path.startsWith('/') && !path.match(/^[a-zA-Z]:/)) { + // Relative path - resolve against workspace + const workspaceFolder = vscode.workspace.workspaceFolders?.[0]; + if (workspaceFolder) { + absolutePath = vscode.Uri.joinPath(workspaceFolder.uri, path).fsPath; + } + } + + // Open the document + const uri = vscode.Uri.file(absolutePath); + const document = await vscode.workspace.openTextDocument(uri); + const editor = await vscode.window.showTextDocument(document, { + preview: false, + preserveFocus: false, + }); + + // Navigate to line and column if specified + if (lineStr) { + const position = new vscode.Position(lineNumber, columnNumber); + editor.selection = new vscode.Selection(position, position); + editor.revealRange( + new vscode.Range(position, position), + vscode.TextEditorRevealType.InCenter, + ); + } + + console.log('[FileOperations] File opened successfully:', absolutePath); + } catch (error) { + console.error('[FileMessageHandler] Failed to open file:', error); + vscode.window.showErrorMessage(`Failed to open file: ${error}`); + } + } + + /** + * Open diff view + */ + private async handleOpenDiff( + data: Record | undefined, + ): Promise { + if (!data) { + console.warn('[FileMessageHandler] No data provided for openDiff'); + return; + } + + try { + await vscode.commands.executeCommand(showDiffCommand, { + path: (data.path as string) || '', + oldText: (data.oldText as string) || '', + newText: (data.newText as string) || '', + }); + } catch (error) { + console.error('[FileMessageHandler] Failed to open diff:', error); + vscode.window.showErrorMessage(`Failed to open diff: ${error}`); + } + } + + /** + * Create and open temporary file + */ + private async handleCreateAndOpenTempFile( + data: Record | undefined, + ): Promise { + if (!data) { + console.warn( + '[FileMessageHandler] No data provided for createAndOpenTempFile', + ); + return; + } + + try { + const content = (data.content as string) || ''; + const fileName = (data.fileName as string) || 'temp'; + const fileExtension = (data.fileExtension as string) || '.txt'; + + // Create temporary file path + const tempDir = os.tmpdir(); + const tempFileName = `${fileName}-${Date.now()}${fileExtension}`; + const tempFilePath = path.join(tempDir, tempFileName); + + // Write content to temporary file + await fs.promises.writeFile(tempFilePath, content, 'utf8'); + + // Open the temporary file in VS Code + const uri = vscode.Uri.file(tempFilePath); + await vscode.window.showTextDocument(uri, { + preview: false, + preserveFocus: false, + }); + + console.log( + '[FileMessageHandler] Created and opened temporary file:', + tempFilePath, + ); + } catch (error) { + console.error( + '[FileMessageHandler] Failed to create and open temporary file:', + error, + ); + vscode.window.showErrorMessage( + `Failed to create and open temporary file: ${error}`, + ); + } + } +} diff --git a/packages/vscode-ide-companion/src/webview/handlers/MessageRouter.ts b/packages/vscode-ide-companion/src/webview/handlers/MessageRouter.ts new file mode 100644 index 000000000..353dbaaf7 --- /dev/null +++ b/packages/vscode-ide-companion/src/webview/handlers/MessageRouter.ts @@ -0,0 +1,153 @@ +/** + * @license + * Copyright 2025 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + */ + +import type { IMessageHandler } from './BaseMessageHandler.js'; +import type { QwenAgentManager } from '../../services/qwenAgentManager.js'; +import type { ConversationStore } from '../../services/conversationStore.js'; +import { SessionMessageHandler } from './SessionMessageHandler.js'; +import { FileMessageHandler } from './FileMessageHandler.js'; +import { EditorMessageHandler } from './EditorMessageHandler.js'; +import { AuthMessageHandler } from './AuthMessageHandler.js'; + +/** + * Message Router + * Routes messages to appropriate handlers + */ +export class MessageRouter { + private handlers: IMessageHandler[] = []; + private sessionHandler: SessionMessageHandler; + private authHandler: AuthMessageHandler; + private currentConversationId: string | null = null; + private permissionHandler: + | ((message: { type: string; data: { optionId: string } }) => void) + | null = null; + + constructor( + agentManager: QwenAgentManager, + conversationStore: ConversationStore, + currentConversationId: string | null, + sendToWebView: (message: unknown) => void, + ) { + this.currentConversationId = currentConversationId; + + // Initialize all handlers + this.sessionHandler = new SessionMessageHandler( + agentManager, + conversationStore, + currentConversationId, + sendToWebView, + ); + + const fileHandler = new FileMessageHandler( + agentManager, + conversationStore, + currentConversationId, + sendToWebView, + ); + + const editorHandler = new EditorMessageHandler( + agentManager, + conversationStore, + currentConversationId, + sendToWebView, + ); + + this.authHandler = new AuthMessageHandler( + agentManager, + conversationStore, + currentConversationId, + sendToWebView, + ); + + // Register handlers in order of priority + this.handlers = [ + this.sessionHandler, + fileHandler, + editorHandler, + this.authHandler, + ]; + } + + /** + * Route message to appropriate handler + */ + async route(message: { type: string; data?: unknown }): Promise { + console.log('[MessageRouter] Routing message:', message.type); + + // Handle permission response specially + if (message.type === 'permissionResponse') { + if (this.permissionHandler) { + this.permissionHandler( + message as { type: string; data: { optionId: string } }, + ); + } + return; + } + + // Find appropriate handler + const handler = this.handlers.find((h) => h.canHandle(message.type)); + + if (handler) { + try { + await handler.handle(message); + } catch (error) { + console.error('[MessageRouter] Handler error:', error); + throw error; + } + } else { + console.warn( + '[MessageRouter] No handler found for message type:', + message.type, + ); + } + } + + /** + * Set current conversation ID + */ + setCurrentConversationId(id: string | null): void { + this.currentConversationId = id; + // Update all handlers + this.handlers.forEach((handler) => { + if ('setCurrentConversationId' in handler) { + ( + handler as { setCurrentConversationId: (id: string | null) => void } + ).setCurrentConversationId(id); + } + }); + } + + /** + * Get current conversation ID + */ + getCurrentConversationId(): string | null { + return this.currentConversationId; + } + + /** + * Set permission handler + */ + setPermissionHandler( + handler: (message: { type: string; data: { optionId: string } }) => void, + ): void { + this.permissionHandler = handler; + } + + /** + * Set login handler + */ + setLoginHandler(handler: () => Promise): void { + this.authHandler.setLoginHandler(handler); + this.sessionHandler?.setLoginHandler?.(handler); + } + + /** + * Append stream content + */ + appendStreamContent(chunk: string): void { + this.sessionHandler.appendStreamContent(chunk); + } +} diff --git a/packages/vscode-ide-companion/src/webview/handlers/SessionMessageHandler.ts b/packages/vscode-ide-companion/src/webview/handlers/SessionMessageHandler.ts new file mode 100644 index 000000000..d8861b95a --- /dev/null +++ b/packages/vscode-ide-companion/src/webview/handlers/SessionMessageHandler.ts @@ -0,0 +1,1034 @@ +/** + * @license + * Copyright 2025 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + */ + +import * as vscode from 'vscode'; +import { BaseMessageHandler } from './BaseMessageHandler.js'; +import type { ChatMessage } from '../../services/qwenAgentManager.js'; +import type { ApprovalModeValue } from '../../types/approvalModeValueTypes.js'; + +/** + * Session message handler + * Handles all session-related messages + */ +export class SessionMessageHandler extends BaseMessageHandler { + private currentStreamContent = ''; + private loginHandler: (() => Promise) | null = null; + private isTitleSet = false; // Flag to track if title has been set + + canHandle(messageType: string): boolean { + return [ + 'sendMessage', + 'newQwenSession', + 'switchQwenSession', + 'getQwenSessions', + 'saveSession', + 'resumeSession', + 'cancelStreaming', + // UI action: open a new chat tab (new WebviewPanel) + 'openNewChatTab', + // Settings-related messages + 'setApprovalMode', + ].includes(messageType); + } + + /** + * Set login handler + */ + setLoginHandler(handler: () => Promise): void { + this.loginHandler = handler; + } + + async handle(message: { type: string; data?: unknown }): Promise { + const data = message.data as Record | undefined; + + switch (message.type) { + case 'sendMessage': + await this.handleSendMessage( + (data?.text as string) || '', + data?.context as + | Array<{ + type: string; + name: string; + value: string; + startLine?: number; + endLine?: number; + }> + | undefined, + data?.fileContext as + | { + fileName: string; + filePath: string; + startLine?: number; + endLine?: number; + } + | undefined, + ); + break; + + case 'newQwenSession': + await this.handleNewQwenSession(); + break; + + case 'switchQwenSession': + await this.handleSwitchQwenSession((data?.sessionId as string) || ''); + break; + + case 'getQwenSessions': + await this.handleGetQwenSessions( + (data?.cursor as number | undefined) ?? undefined, + (data?.size as number | undefined) ?? undefined, + ); + break; + + case 'saveSession': + await this.handleSaveSession((data?.tag as string) || ''); + break; + + case 'resumeSession': + await this.handleResumeSession((data?.sessionId as string) || ''); + break; + + case 'openNewChatTab': + // Open a brand new chat tab (WebviewPanel) via the extension command + // This does not alter the current conversation in this tab; the new tab + // will initialize its own state and (optionally) create a new session. + try { + await vscode.commands.executeCommand('qwenCode.openNewChatTab'); + } catch (error) { + console.error( + '[SessionMessageHandler] Failed to open new chat tab:', + error, + ); + this.sendToWebView({ + type: 'error', + data: { message: `Failed to open new chat tab: ${error}` }, + }); + } + break; + + case 'cancelStreaming': + // Handle cancel streaming request from webview + await this.handleCancelStreaming(); + break; + + case 'setApprovalMode': + await this.handleSetApprovalMode( + message.data as { + modeId?: ApprovalModeValue; + }, + ); + break; + + default: + console.warn( + '[SessionMessageHandler] Unknown message type:', + message.type, + ); + break; + } + } + + /** + * Get current stream content + */ + getCurrentStreamContent(): string { + return this.currentStreamContent; + } + + /** + * Append stream content + */ + appendStreamContent(chunk: string): void { + this.currentStreamContent += chunk; + } + + /** + * Reset stream content + */ + resetStreamContent(): void { + this.currentStreamContent = ''; + } + + /** + * Notify the webview that streaming has finished. + */ + private sendStreamEnd(reason?: string): void { + const data: { timestamp: number; reason?: string } = { + timestamp: Date.now(), + }; + + if (reason) { + data.reason = reason; + } + + this.sendToWebView({ + type: 'streamEnd', + data, + }); + } + + /** + * Prompt user to login and invoke the registered login handler/command. + * Returns true if a login was initiated. + */ + private async promptLogin(message: string): Promise { + const result = await vscode.window.showWarningMessage(message, 'Login Now'); + if (result === 'Login Now') { + if (this.loginHandler) { + await this.loginHandler(); + } else { + await vscode.commands.executeCommand('qwen-code.login'); + } + return true; + } + return false; + } + + /** + * Prompt user to login or view offline. Returns 'login', 'offline', or 'dismiss'. + * When login is chosen, it triggers the login handler/command. + */ + private async promptLoginOrOffline( + message: string, + ): Promise<'login' | 'offline' | 'dismiss'> { + const selection = await vscode.window.showWarningMessage( + message, + 'Login Now', + 'View Offline', + ); + + if (selection === 'Login Now') { + if (this.loginHandler) { + await this.loginHandler(); + } else { + await vscode.commands.executeCommand('qwen-code.login'); + } + return 'login'; + } + if (selection === 'View Offline') { + return 'offline'; + } + return 'dismiss'; + } + + /** + * Handle send message request + */ + private async handleSendMessage( + text: string, + context?: Array<{ + type: string; + name: string; + value: string; + startLine?: number; + endLine?: number; + }>, + fileContext?: { + fileName: string; + filePath: string; + startLine?: number; + endLine?: number; + }, + ): Promise { + console.log('[SessionMessageHandler] handleSendMessage called with:', text); + + // Format message with file context if present + let formattedText = text; + if (context && context.length > 0) { + const contextParts = context + .map((ctx) => { + if (ctx.startLine && ctx.endLine) { + return `${ctx.value}#${ctx.startLine}${ctx.startLine !== ctx.endLine ? `-${ctx.endLine}` : ''}`; + } + return ctx.value; + }) + .join('\n'); + + formattedText = `${contextParts}\n\n${text}`; + } + + // Ensure we have an active conversation + if (!this.currentConversationId) { + console.log( + '[SessionMessageHandler] No active conversation, creating one...', + ); + try { + const newConv = await this.conversationStore.createConversation(); + this.currentConversationId = newConv.id; + this.sendToWebView({ + type: 'conversationLoaded', + data: newConv, + }); + } catch (error) { + const errorMsg = `Failed to create conversation: ${error}`; + console.error('[SessionMessageHandler]', errorMsg); + vscode.window.showErrorMessage(errorMsg); + this.sendToWebView({ + type: 'error', + data: { message: errorMsg }, + }); + return; + } + } + + if (!this.currentConversationId) { + const errorMsg = + 'Failed to create conversation. Please restart the extension.'; + console.error('[SessionMessageHandler]', errorMsg); + vscode.window.showErrorMessage(errorMsg); + this.sendToWebView({ + type: 'error', + data: { message: errorMsg }, + }); + return; + } + + // Check if this is the first message + let isFirstMessage = false; + try { + const conversation = await this.conversationStore.getConversation( + this.currentConversationId, + ); + isFirstMessage = !conversation || conversation.messages.length === 0; + } catch (error) { + console.error( + '[SessionMessageHandler] Failed to check conversation:', + error, + ); + } + + // Generate title for first message, but only if it hasn't been set yet + if (isFirstMessage && !this.isTitleSet) { + const title = text.substring(0, 50) + (text.length > 50 ? '...' : ''); + this.sendToWebView({ + type: 'sessionTitleUpdated', + data: { + sessionId: this.currentConversationId, + title, + }, + }); + this.isTitleSet = true; // Mark title as set + } + + // Save user message + const userMessage: ChatMessage = { + role: 'user', + content: text, + timestamp: Date.now(), + }; + + await this.conversationStore.addMessage( + this.currentConversationId, + userMessage, + ); + + // Send to WebView + this.sendToWebView({ + type: 'message', + data: { ...userMessage, fileContext }, + }); + + // Check if agent is connected + if (!this.agentManager.isConnected) { + console.warn('[SessionMessageHandler] Agent not connected'); + + // Show non-modal notification with Login button + await this.promptLogin('You need to login first to use Qwen Code.'); + return; + } + + // Ensure an ACP session exists before sending prompt + if (!this.agentManager.currentSessionId) { + try { + const workspaceFolder = vscode.workspace.workspaceFolders?.[0]; + const workingDir = workspaceFolder?.uri.fsPath || process.cwd(); + await this.agentManager.createNewSession(workingDir); + } catch (createErr) { + console.error( + '[SessionMessageHandler] Failed to create session before sending message:', + createErr, + ); + const errorMsg = + createErr instanceof Error ? createErr.message : String(createErr); + if ( + errorMsg.includes('Authentication required') || + errorMsg.includes('(code: -32000)') + ) { + await this.promptLogin( + 'Your login session has expired or is invalid. Please login again to continue using Qwen Code.', + ); + return; + } + vscode.window.showErrorMessage(`Failed to create session: ${errorMsg}`); + return; + } + } + + // Send to agent + try { + this.resetStreamContent(); + + this.sendToWebView({ + type: 'streamStart', + data: { timestamp: Date.now() }, + }); + + await this.agentManager.sendMessage(formattedText); + + // Save assistant message + if (this.currentStreamContent && this.currentConversationId) { + const assistantMessage: ChatMessage = { + role: 'assistant', + content: this.currentStreamContent, + timestamp: Date.now(), + }; + await this.conversationStore.addMessage( + this.currentConversationId, + assistantMessage, + ); + } + + this.sendStreamEnd(); + } catch (error) { + console.error('[SessionMessageHandler] Error sending message:', error); + + const err = error as unknown as Error; + // Safely convert error to string + const errorMsg = error ? String(error) : 'Unknown error'; + const lower = errorMsg.toLowerCase(); + + // Suppress user-cancelled/aborted errors (ESC/Stop button) + const isAbortLike = + (err && (err as Error).name === 'AbortError') || + lower.includes('abort') || + lower.includes('aborted') || + lower.includes('request was aborted') || + lower.includes('canceled') || + lower.includes('cancelled') || + lower.includes('user_cancelled'); + + if (isAbortLike) { + // Do not show VS Code error popup for intentional cancellations. + // Ensure the webview knows the stream ended due to user action. + this.sendStreamEnd('user_cancelled'); + return; + } + // Check for session not found error and handle it appropriately + if ( + errorMsg.includes('Session not found') || + errorMsg.includes('No active ACP session') || + errorMsg.includes('Authentication required') || + errorMsg.includes('(code: -32000)') || + errorMsg.includes('Unauthorized') || + errorMsg.includes('Invalid token') + ) { + // Show a more user-friendly error message for expired sessions + await this.promptLogin( + 'Your login session has expired or is invalid. Please login again to continue using Qwen Code.', + ); + + // Send a specific error to the webview for better UI handling + this.sendToWebView({ + type: 'sessionExpired', + data: { message: 'Session expired. Please login again.' }, + }); + this.sendStreamEnd('session_expired'); + } else { + const isTimeoutError = + lower.includes('timeout') || lower.includes('timed out'); + if (isTimeoutError) { + // Note: session_prompt no longer has a timeout, so this should rarely occur + // This path may still be hit for other methods (initialize, etc.) or network-level timeouts + console.warn( + '[SessionMessageHandler] Request timed out; suppressing popup', + ); + + const timeoutMessage: ChatMessage = { + role: 'assistant', + content: + 'Request timed out. This may be due to a network issue. Please try again.', + timestamp: Date.now(), + }; + + // Send a timeout message to the WebView + this.sendToWebView({ + type: 'message', + data: timeoutMessage, + }); + this.sendStreamEnd('timeout'); + } else { + // Handling of Non-Timeout Errors + vscode.window.showErrorMessage(`Error sending message: ${error}`); + this.sendToWebView({ + type: 'error', + data: { message: errorMsg }, + }); + this.sendStreamEnd('error'); + } + } + } + } + + /** + * Handle new Qwen session request + */ + private async handleNewQwenSession(): Promise { + try { + console.log('[SessionMessageHandler] Creating new Qwen session...'); + + // Ensure connection (login) before creating a new session + if (!this.agentManager.isConnected) { + const proceeded = await this.promptLogin( + 'You need to login before creating a new session.', + ); + if (!proceeded) { + return; + } + } + + const workspaceFolder = vscode.workspace.workspaceFolders?.[0]; + const workingDir = workspaceFolder?.uri.fsPath || process.cwd(); + + await this.agentManager.createNewSession(workingDir); + + this.sendToWebView({ + type: 'conversationCleared', + data: {}, + }); + + // Reset title flag when creating a new session + this.isTitleSet = false; + } catch (error) { + console.error( + '[SessionMessageHandler] Failed to create new session:', + error, + ); + + // Safely convert error to string + const errorMsg = error ? String(error) : 'Unknown error'; + // Check for authentication/session expiration errors + if ( + errorMsg.includes('Authentication required') || + errorMsg.includes('(code: -32000)') || + errorMsg.includes('Unauthorized') || + errorMsg.includes('Invalid token') || + errorMsg.includes('No active ACP session') + ) { + // Show a more user-friendly error message for expired sessions + await this.promptLogin( + 'Your login session has expired or is invalid. Please login again to create a new session.', + ); + + // Send a specific error to the webview for better UI handling + this.sendToWebView({ + type: 'sessionExpired', + data: { message: 'Session expired. Please login again.' }, + }); + } else { + this.sendToWebView({ + type: 'error', + data: { message: `Failed to create new session: ${error}` }, + }); + } + } + } + + /** + * Handle switch Qwen session request + */ + private async handleSwitchQwenSession(sessionId: string): Promise { + try { + console.log('[SessionMessageHandler] Switching to session:', sessionId); + + // If not connected yet, offer to login or view offline + if (!this.agentManager.isConnected) { + const choice = await this.promptLoginOrOffline( + 'You are not logged in. Login now to fully restore this session, or view it offline.', + ); + + if (choice === 'offline') { + // Show messages from local cache only + const messages = + await this.agentManager.getSessionMessages(sessionId); + this.currentConversationId = sessionId; + this.sendToWebView({ + type: 'qwenSessionSwitched', + data: { sessionId, messages }, + }); + vscode.window.showInformationMessage( + 'Showing cached session content. Login to interact with the AI.', + ); + return; + } else if (choice !== 'login') { + // User dismissed; do nothing + return; + } + } + + // Get session details (includes cwd and filePath when using ACP) + let sessionDetails: Record | null = null; + try { + const allSessions = await this.agentManager.getSessionList(); + sessionDetails = + allSessions.find( + (s: { id?: string; sessionId?: string }) => + s.id === sessionId || s.sessionId === sessionId, + ) || null; + } catch (err) { + console.log( + '[SessionMessageHandler] Could not get session details:', + err, + ); + } + + const workspaceFolder = vscode.workspace.workspaceFolders?.[0]; + const workingDir = workspaceFolder?.uri.fsPath || process.cwd(); + + // Try to load session via ACP (now we should be connected) + try { + // Set current id and clear UI first so replayed updates append afterwards + this.currentConversationId = sessionId; + this.sendToWebView({ + type: 'qwenSessionSwitched', + data: { sessionId, messages: [], session: sessionDetails }, + }); + + const loadResponse = await this.agentManager.loadSessionViaAcp( + sessionId, + (sessionDetails?.cwd as string | undefined) || undefined, + ); + console.log( + '[SessionMessageHandler] session/load succeeded (per ACP spec result is null; actual history comes via session/update):', + loadResponse, + ); + + // Reset title flag when switching sessions + this.isTitleSet = false; + + // Successfully loaded session, return early to avoid fallback logic + return; + } catch (loadError) { + console.warn( + '[SessionMessageHandler] session/load failed, using fallback:', + loadError, + ); + + // Safely convert error to string + const errorMsg = loadError ? String(loadError) : 'Unknown error'; + + // Check for authentication/session expiration errors + if ( + errorMsg.includes('Authentication required') || + errorMsg.includes('(code: -32000)') || + errorMsg.includes('Unauthorized') || + errorMsg.includes('Invalid token') || + errorMsg.includes('No active ACP session') + ) { + // Show a more user-friendly error message for expired sessions + await this.promptLogin( + 'Your login session has expired or is invalid. Please login again to switch sessions.', + ); + + // Send a specific error to the webview for better UI handling + this.sendToWebView({ + type: 'sessionExpired', + data: { message: 'Session expired. Please login again.' }, + }); + return; + } + + // Fallback: create new session + const messages = await this.agentManager.getSessionMessages(sessionId); + + // If we are connected, try to create a fresh ACP session so user can interact + if (this.agentManager.isConnected) { + try { + const newAcpSessionId = + await this.agentManager.createNewSession(workingDir); + + this.currentConversationId = newAcpSessionId; + + this.sendToWebView({ + type: 'qwenSessionSwitched', + data: { sessionId, messages, session: sessionDetails }, + }); + + // Only show the cache warning if we actually fell back to local cache + // and didn't successfully load via ACP + // Check if we truly fell back by checking if loadError is not null/undefined + // and if it's not a successful response that looks like an error + if ( + loadError && + typeof loadError === 'object' && + !('result' in loadError) + ) { + vscode.window.showWarningMessage( + 'Session restored from local cache. Some context may be incomplete.', + ); + } + } catch (createError) { + console.error( + '[SessionMessageHandler] Failed to create session:', + createError, + ); + + // Safely convert error to string + const createErrorMsg = createError + ? String(createError) + : 'Unknown error'; + // Check for authentication/session expiration errors in session creation + if ( + createErrorMsg.includes('Authentication required') || + createErrorMsg.includes('(code: -32000)') || + createErrorMsg.includes('Unauthorized') || + createErrorMsg.includes('Invalid token') || + createErrorMsg.includes('No active ACP session') + ) { + // Show a more user-friendly error message for expired sessions + await this.promptLogin( + 'Your login session has expired or is invalid. Please login again to switch sessions.', + ); + + // Send a specific error to the webview for better UI handling + this.sendToWebView({ + type: 'sessionExpired', + data: { message: 'Session expired. Please login again.' }, + }); + return; + } + + throw createError; + } + } else { + // Offline view only + this.currentConversationId = sessionId; + this.sendToWebView({ + type: 'qwenSessionSwitched', + data: { sessionId, messages, session: sessionDetails }, + }); + vscode.window.showWarningMessage( + 'Showing cached session content. Login to interact with the AI.', + ); + } + } + } catch (error) { + console.error('[SessionMessageHandler] Failed to switch session:', error); + + // Safely convert error to string + const errorMsg = error ? String(error) : 'Unknown error'; + // Check for authentication/session expiration errors + if ( + errorMsg.includes('Authentication required') || + errorMsg.includes('(code: -32000)') || + errorMsg.includes('Unauthorized') || + errorMsg.includes('Invalid token') || + errorMsg.includes('No active ACP session') + ) { + // Show a more user-friendly error message for expired sessions + await this.promptLogin( + 'Your login session has expired or is invalid. Please login again to switch sessions.', + ); + + // Send a specific error to the webview for better UI handling + this.sendToWebView({ + type: 'sessionExpired', + data: { message: 'Session expired. Please login again.' }, + }); + } else { + this.sendToWebView({ + type: 'error', + data: { message: `Failed to switch session: ${error}` }, + }); + } + } + } + + /** + * Handle get Qwen sessions request + */ + private async handleGetQwenSessions( + cursor?: number, + size?: number, + ): Promise { + try { + // Paged when possible; falls back to full list if ACP not supported + const page = await this.agentManager.getSessionListPaged({ + cursor, + size, + }); + const append = typeof cursor === 'number'; + this.sendToWebView({ + type: 'qwenSessionList', + data: { + sessions: page.sessions, + nextCursor: page.nextCursor, + hasMore: page.hasMore, + append, + }, + }); + } catch (error) { + console.error('[SessionMessageHandler] Failed to get sessions:', error); + + // Safely convert error to string + const errorMsg = error ? String(error) : 'Unknown error'; + // Check for authentication/session expiration errors + if ( + errorMsg.includes('Authentication required') || + errorMsg.includes('(code: -32000)') || + errorMsg.includes('Unauthorized') || + errorMsg.includes('Invalid token') || + errorMsg.includes('No active ACP session') + ) { + // Show a more user-friendly error message for expired sessions + await this.promptLogin( + 'Your login session has expired or is invalid. Please login again to view sessions.', + ); + + // Send a specific error to the webview for better UI handling + this.sendToWebView({ + type: 'sessionExpired', + data: { message: 'Session expired. Please login again.' }, + }); + } else { + this.sendToWebView({ + type: 'error', + data: { message: `Failed to get sessions: ${error}` }, + }); + } + } + } + + /** + * Handle save session request + */ + private async handleSaveSession(tag: string): Promise { + try { + if (!this.currentConversationId) { + throw new Error('No active conversation to save'); + } + + // Try ACP save first + try { + const response = await this.agentManager.saveSessionViaAcp( + this.currentConversationId, + tag, + ); + + this.sendToWebView({ + type: 'saveSessionResponse', + data: response, + }); + } catch (acpError) { + // Safely convert error to string + const errorMsg = acpError ? String(acpError) : 'Unknown error'; + // Check for authentication/session expiration errors + if ( + errorMsg.includes('Authentication required') || + errorMsg.includes('(code: -32000)') || + errorMsg.includes('Unauthorized') || + errorMsg.includes('Invalid token') || + errorMsg.includes('No active ACP session') + ) { + // Show a more user-friendly error message for expired sessions + await this.promptLogin( + 'Your login session has expired or is invalid. Please login again to save sessions.', + ); + + // Send a specific error to the webview for better UI handling + this.sendToWebView({ + type: 'sessionExpired', + data: { message: 'Session expired. Please login again.' }, + }); + return; + } + } + + await this.handleGetQwenSessions(); + } catch (error) { + console.error('[SessionMessageHandler] Failed to save session:', error); + + // Safely convert error to string + const errorMsg = error ? String(error) : 'Unknown error'; + // Check for authentication/session expiration errors + if ( + errorMsg.includes('Authentication required') || + errorMsg.includes('(code: -32000)') || + errorMsg.includes('Unauthorized') || + errorMsg.includes('Invalid token') || + errorMsg.includes('No active ACP session') + ) { + // Show a more user-friendly error message for expired sessions + await this.promptLogin( + 'Your login session has expired or is invalid. Please login again to save sessions.', + ); + + // Send a specific error to the webview for better UI handling + this.sendToWebView({ + type: 'sessionExpired', + data: { message: 'Session expired. Please login again.' }, + }); + } else { + this.sendToWebView({ + type: 'saveSessionResponse', + data: { + success: false, + message: `Failed to save session: ${error}`, + }, + }); + } + } + } + + /** + * Handle cancel streaming request + */ + private async handleCancelStreaming(): Promise { + try { + console.log('[SessionMessageHandler] Canceling streaming...'); + + // Cancel the current streaming operation in the agent manager + await this.agentManager.cancelCurrentPrompt(); + + // Send streamEnd message to WebView to update UI + this.sendToWebView({ + type: 'streamEnd', + data: { timestamp: Date.now(), reason: 'user_cancelled' }, + }); + + console.log('[SessionMessageHandler] Streaming cancelled successfully'); + } catch (_error) { + console.log('[SessionMessageHandler] Streaming cancelled (interrupted)'); + + // Always send streamEnd to update UI, regardless of errors + this.sendToWebView({ + type: 'streamEnd', + data: { timestamp: Date.now(), reason: 'user_cancelled' }, + }); + } + } + + /** + * Handle resume session request + */ + private async handleResumeSession(sessionId: string): Promise { + try { + // If not connected, offer to login or view offline + if (!this.agentManager.isConnected) { + const choice = await this.promptLoginOrOffline( + 'You are not logged in. Login now to fully restore this session, or view it offline.', + ); + + if (choice === 'offline') { + const messages = + await this.agentManager.getSessionMessages(sessionId); + this.currentConversationId = sessionId; + this.sendToWebView({ + type: 'qwenSessionSwitched', + data: { sessionId, messages }, + }); + vscode.window.showInformationMessage( + 'Showing cached session content. Login to interact with the AI.', + ); + return; + } else if (choice !== 'login') { + return; + } + } + + // Try ACP load first + try { + // Pre-clear UI so replayed updates append afterwards + this.currentConversationId = sessionId; + this.sendToWebView({ + type: 'qwenSessionSwitched', + data: { sessionId, messages: [] }, + }); + + await this.agentManager.loadSessionViaAcp(sessionId); + + // Reset title flag when resuming sessions + this.isTitleSet = false; + + // Successfully loaded session, return early to avoid fallback logic + await this.handleGetQwenSessions(); + return; + } catch (acpError) { + // Safely convert error to string + const errorMsg = acpError ? String(acpError) : 'Unknown error'; + // Check for authentication/session expiration errors + if ( + errorMsg.includes('Authentication required') || + errorMsg.includes('(code: -32000)') || + errorMsg.includes('Unauthorized') || + errorMsg.includes('Invalid token') || + errorMsg.includes('No active ACP session') + ) { + // Show a more user-friendly error message for expired sessions + await this.promptLogin( + 'Your login session has expired or is invalid. Please login again to resume sessions.', + ); + + // Send a specific error to the webview for better UI handling + this.sendToWebView({ + type: 'sessionExpired', + data: { message: 'Session expired. Please login again.' }, + }); + return; + } + } + + await this.handleGetQwenSessions(); + } catch (error) { + console.error('[SessionMessageHandler] Failed to resume session:', error); + + // Safely convert error to string + const errorMsg = error ? String(error) : 'Unknown error'; + // Check for authentication/session expiration errors + if ( + errorMsg.includes('Authentication required') || + errorMsg.includes('(code: -32000)') || + errorMsg.includes('Unauthorized') || + errorMsg.includes('Invalid token') || + errorMsg.includes('No active ACP session') + ) { + // Show a more user-friendly error message for expired sessions + await this.promptLogin( + 'Your login session has expired or is invalid. Please login again to resume sessions.', + ); + + // Send a specific error to the webview for better UI handling + this.sendToWebView({ + type: 'sessionExpired', + data: { message: 'Session expired. Please login again.' }, + }); + } else { + this.sendToWebView({ + type: 'error', + data: { message: `Failed to resume session: ${error}` }, + }); + } + } + } + + /** + * Set approval mode via agent (ACP session/set_mode) + */ + private async handleSetApprovalMode(data?: { + modeId?: ApprovalModeValue; + }): Promise { + try { + const modeId = data?.modeId || 'default'; + await this.agentManager.setApprovalModeFromUi(modeId); + // No explicit response needed; WebView listens for modeChanged + } catch (error) { + console.error('[SessionMessageHandler] Failed to set mode:', error); + this.sendToWebView({ + type: 'error', + data: { message: `Failed to set mode: ${error}` }, + }); + } + } +} diff --git a/packages/vscode-ide-companion/src/webview/hooks/file/useFileContext.ts b/packages/vscode-ide-companion/src/webview/hooks/file/useFileContext.ts new file mode 100644 index 000000000..8bccc658e --- /dev/null +++ b/packages/vscode-ide-companion/src/webview/hooks/file/useFileContext.ts @@ -0,0 +1,144 @@ +/** + * @license + * Copyright 2025 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + */ + +import { useState, useCallback, useRef } from 'react'; +import type { VSCodeAPI } from '../../hooks/useVSCode.js'; + +/** + * File context management Hook + * Manages active file, selection content, and workspace file list + */ +export const useFileContext = (vscode: VSCodeAPI) => { + const [activeFileName, setActiveFileName] = useState(null); + const [activeFilePath, setActiveFilePath] = useState(null); + const [activeSelection, setActiveSelection] = useState<{ + startLine: number; + endLine: number; + } | null>(null); + + const [workspaceFiles, setWorkspaceFiles] = useState< + Array<{ + id: string; + label: string; + description: string; + path: string; + }> + >([]); + + // File reference mapping: @filename -> full path + const fileReferenceMap = useRef>(new Map()); + + // Whether workspace files have been requested + const hasRequestedFilesRef = useRef(false); + + // Last non-empty query to decide when to refetch full list + const lastQueryRef = useRef(undefined); + + // Search debounce timer + const searchTimerRef = useRef(null); + + /** + * Request workspace files + */ + const requestWorkspaceFiles = useCallback( + (query?: string) => { + const normalizedQuery = query?.trim(); + + // If there's a query, clear previous timer and set up debounce + if (normalizedQuery && normalizedQuery.length >= 1) { + if (searchTimerRef.current) { + clearTimeout(searchTimerRef.current); + } + + searchTimerRef.current = setTimeout(() => { + vscode.postMessage({ + type: 'getWorkspaceFiles', + data: { query: normalizedQuery }, + }); + }, 300); + lastQueryRef.current = normalizedQuery; + } else { + // For empty query, request once initially and whenever we are returning from a search + const shouldRequestFullList = + !hasRequestedFilesRef.current || lastQueryRef.current !== undefined; + + if (shouldRequestFullList) { + lastQueryRef.current = undefined; + hasRequestedFilesRef.current = true; + vscode.postMessage({ + type: 'getWorkspaceFiles', + data: {}, + }); + } + } + }, + [vscode], + ); + + /** + * Add file reference + */ + const addFileReference = useCallback((fileName: string, filePath: string) => { + fileReferenceMap.current.set(fileName, filePath); + }, []); + + /** + * Get file reference + */ + const getFileReference = useCallback( + (fileName: string) => fileReferenceMap.current.get(fileName), + [], + ); + + /** + * Clear file references + */ + const clearFileReferences = useCallback(() => { + fileReferenceMap.current.clear(); + }, []); + + /** + * Request active editor info + */ + const requestActiveEditor = useCallback(() => { + vscode.postMessage({ type: 'getActiveEditor', data: {} }); + }, [vscode]); + + /** + * Focus on active editor + */ + const focusActiveEditor = useCallback(() => { + vscode.postMessage({ + type: 'focusActiveEditor', + data: {}, + }); + }, [vscode]); + + return { + // State + activeFileName, + activeFilePath, + activeSelection, + workspaceFiles, + hasRequestedFiles: hasRequestedFilesRef.current, + + // State setters + setActiveFileName, + setActiveFilePath, + setActiveSelection, + setWorkspaceFiles, + + // File reference operations + addFileReference, + getFileReference, + clearFileReferences, + + // Operations + requestWorkspaceFiles, + requestActiveEditor, + focusActiveEditor, + }; +}; diff --git a/packages/vscode-ide-companion/src/webview/hooks/message/useMessageHandling.ts b/packages/vscode-ide-companion/src/webview/hooks/message/useMessageHandling.ts new file mode 100644 index 000000000..17fde331f --- /dev/null +++ b/packages/vscode-ide-companion/src/webview/hooks/message/useMessageHandling.ts @@ -0,0 +1,197 @@ +/** + * @license + * Copyright 2025 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + */ + +import { useState, useRef, useCallback } from 'react'; + +export interface TextMessage { + role: 'user' | 'assistant' | 'thinking'; + content: string; + timestamp: number; + fileContext?: { + fileName: string; + filePath: string; + startLine?: number; + endLine?: number; + }; +} + +/** + * Message handling Hook + * Manages message list, streaming responses, and loading state + */ +export const useMessageHandling = () => { + const [messages, setMessages] = useState([]); + const [isStreaming, setIsStreaming] = useState(false); + const [isWaitingForResponse, setIsWaitingForResponse] = useState(false); + const [loadingMessage, setLoadingMessage] = useState(''); + // Track the index of the assistant placeholder message during streaming + const streamingMessageIndexRef = useRef(null); + // Track the index of the current aggregated thinking message + const thinkingMessageIndexRef = useRef(null); + + /** + * Add message + */ + const addMessage = useCallback((message: TextMessage) => { + setMessages((prev) => [...prev, message]); + }, []); + + /** + * Clear messages + */ + const clearMessages = useCallback(() => { + setMessages([]); + }, []); + + /** + * Start streaming response + */ + const startStreaming = useCallback((timestamp?: number) => { + // Create an assistant placeholder message immediately so tool calls won't jump before it + setMessages((prev) => { + // Record index of the placeholder to update on chunks + streamingMessageIndexRef.current = prev.length; + return [ + ...prev, + { + role: 'assistant', + content: '', + // Use provided timestamp (from extension) to keep ordering stable + timestamp: typeof timestamp === 'number' ? timestamp : Date.now(), + }, + ]; + }); + setIsStreaming(true); + }, []); + + /** + * Add stream chunk + */ + const appendStreamChunk = useCallback( + (chunk: string) => { + // Ignore late chunks after user cancelled streaming (until next streamStart) + if (!isStreaming) { + return; + } + + setMessages((prev) => { + let idx = streamingMessageIndexRef.current; + const next = prev.slice(); + + // If there is no active placeholder (e.g., after a tool call), start a new one + if (idx === null) { + idx = next.length; + streamingMessageIndexRef.current = idx; + next.push({ role: 'assistant', content: '', timestamp: Date.now() }); + } + + if (idx < 0 || idx >= next.length) { + return prev; + } + const target = next[idx]; + next[idx] = { ...target, content: (target.content || '') + chunk }; + return next; + }); + }, + [isStreaming], + ); + + /** + * Break current assistant stream segment (e.g., when a tool call starts/updates) + * Next incoming chunk will create a new assistant placeholder + */ + const breakAssistantSegment = useCallback(() => { + streamingMessageIndexRef.current = null; + }, []); + + /** + * End streaming response + */ + const endStreaming = useCallback(() => { + // Finalize streaming; content already lives in the placeholder message + setIsStreaming(false); + streamingMessageIndexRef.current = null; + // Remove the thinking message if it exists (collapse thoughts) + setMessages((prev) => { + const idx = thinkingMessageIndexRef.current; + thinkingMessageIndexRef.current = null; + if (idx === null || idx < 0 || idx >= prev.length) { + return prev; + } + const next = prev.slice(); + next.splice(idx, 1); + return next; + }); + }, []); + + /** + * Set waiting for response state + */ + const setWaitingForResponse = useCallback((message: string) => { + setIsWaitingForResponse(true); + setLoadingMessage(message); + }, []); + + /** + * Clear waiting for response state + */ + const clearWaitingForResponse = useCallback(() => { + setIsWaitingForResponse(false); + setLoadingMessage(''); + }, []); + + return { + // State + messages, + isStreaming, + isWaitingForResponse, + loadingMessage, + + // Operations + addMessage, + clearMessages, + startStreaming, + appendStreamChunk, + endStreaming, + // Thought handling + appendThinkingChunk: (chunk: string) => { + // Ignore late thoughts after user cancelled streaming + if (!isStreaming) { + return; + } + setMessages((prev) => { + let idx = thinkingMessageIndexRef.current; + const next = prev.slice(); + if (idx === null) { + idx = next.length; + thinkingMessageIndexRef.current = idx; + next.push({ role: 'thinking', content: '', timestamp: Date.now() }); + } + if (idx >= 0 && idx < next.length) { + const target = next[idx]; + next[idx] = { ...target, content: (target.content || '') + chunk }; + } + return next; + }); + }, + clearThinking: () => { + setMessages((prev) => { + const idx = thinkingMessageIndexRef.current; + thinkingMessageIndexRef.current = null; + if (idx === null || idx < 0 || idx >= prev.length) { + return prev; + } + const next = prev.slice(); + next.splice(idx, 1); + return next; + }); + }, + breakAssistantSegment, + setWaitingForResponse, + clearWaitingForResponse, + setMessages, + }; +}; diff --git a/packages/vscode-ide-companion/src/webview/hooks/session/useSessionManagement.ts b/packages/vscode-ide-companion/src/webview/hooks/session/useSessionManagement.ts new file mode 100644 index 000000000..9fba4a803 --- /dev/null +++ b/packages/vscode-ide-companion/src/webview/hooks/session/useSessionManagement.ts @@ -0,0 +1,164 @@ +/** + * @license + * Copyright 2025 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + */ + +import { useState, useCallback, useMemo } from 'react'; +import type { VSCodeAPI } from '../../hooks/useVSCode.js'; + +/** + * Session management Hook + * Manages session list, current session, session switching, and search + */ +export const useSessionManagement = (vscode: VSCodeAPI) => { + const [qwenSessions, setQwenSessions] = useState< + Array> + >([]); + const [currentSessionId, setCurrentSessionId] = useState(null); + const [currentSessionTitle, setCurrentSessionTitle] = + useState('Past Conversations'); + const [showSessionSelector, setShowSessionSelector] = useState(false); + const [sessionSearchQuery, setSessionSearchQuery] = useState(''); + const [savedSessionTags, setSavedSessionTags] = useState([]); + const [nextCursor, setNextCursor] = useState(undefined); + const [hasMore, setHasMore] = useState(true); + const [isLoading, setIsLoading] = useState(false); + + const PAGE_SIZE = 20; + + /** + * Filter session list + */ + const filteredSessions = useMemo(() => { + if (!sessionSearchQuery.trim()) { + return qwenSessions; + } + const query = sessionSearchQuery.toLowerCase(); + return qwenSessions.filter((session) => { + const title = ( + (session.title as string) || + (session.name as string) || + '' + ).toLowerCase(); + return title.includes(query); + }); + }, [qwenSessions, sessionSearchQuery]); + + /** + * Load session list + */ + const handleLoadQwenSessions = useCallback(() => { + // Reset pagination state and load first page + setQwenSessions([]); + setNextCursor(undefined); + setHasMore(true); + setIsLoading(true); + vscode.postMessage({ type: 'getQwenSessions', data: { size: PAGE_SIZE } }); + setShowSessionSelector(true); + }, [vscode]); + + const handleLoadMoreSessions = useCallback(() => { + if (!hasMore || isLoading || nextCursor === undefined) { + return; + } + setIsLoading(true); + vscode.postMessage({ + type: 'getQwenSessions', + data: { cursor: nextCursor, size: PAGE_SIZE }, + }); + }, [hasMore, isLoading, nextCursor, vscode]); + + /** + * Create new session + */ + const handleNewQwenSession = useCallback(() => { + vscode.postMessage({ type: 'openNewChatTab', data: {} }); + setShowSessionSelector(false); + }, [vscode]); + + /** + * Switch session + */ + const handleSwitchSession = useCallback( + (sessionId: string) => { + if (sessionId === currentSessionId) { + console.log('[useSessionManagement] Already on this session, ignoring'); + setShowSessionSelector(false); + return; + } + + console.log('[useSessionManagement] Switching to session:', sessionId); + vscode.postMessage({ + type: 'switchQwenSession', + data: { sessionId }, + }); + }, + [currentSessionId, vscode], + ); + + /** + * Save session + */ + const handleSaveSession = useCallback( + (tag: string) => { + vscode.postMessage({ + type: 'saveSession', + data: { tag }, + }); + }, + [vscode], + ); + + /** + * Handle Save session response + */ + const handleSaveSessionResponse = useCallback( + (response: { success: boolean; message?: string }) => { + if (response.success) { + if (response.message) { + const tagMatch = response.message.match(/tag: (.+)$/); + if (tagMatch) { + setSavedSessionTags((prev) => [...prev, tagMatch[1]]); + } + } + } else { + console.error('Failed to save session:', response.message); + } + }, + [], + ); + + return { + // State + qwenSessions, + currentSessionId, + currentSessionTitle, + showSessionSelector, + sessionSearchQuery, + filteredSessions, + savedSessionTags, + nextCursor, + hasMore, + isLoading, + + // State setters + setQwenSessions, + setCurrentSessionId, + setCurrentSessionTitle, + setShowSessionSelector, + setSessionSearchQuery, + setSavedSessionTags, + setNextCursor, + setHasMore, + setIsLoading, + + // Operations + handleLoadQwenSessions, + handleNewQwenSession, + handleSwitchSession, + handleSaveSession, + handleSaveSessionResponse, + handleLoadMoreSessions, + }; +}; diff --git a/packages/vscode-ide-companion/src/webview/hooks/useCompletionTrigger.ts b/packages/vscode-ide-companion/src/webview/hooks/useCompletionTrigger.ts new file mode 100644 index 000000000..b18843ef5 --- /dev/null +++ b/packages/vscode-ide-companion/src/webview/hooks/useCompletionTrigger.ts @@ -0,0 +1,341 @@ +/** + * @license + * Copyright 2025 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + */ + +import type { RefObject } from 'react'; +import { useState, useEffect, useCallback, useRef, useMemo } from 'react'; +import type { CompletionItem } from '../../types/completionItemTypes.js'; + +interface CompletionTriggerState { + isOpen: boolean; + triggerChar: '@' | '/' | null; + query: string; + position: { top: number; left: number }; + items: CompletionItem[]; +} + +/** + * Hook to handle @ and / completion triggers in contentEditable + * Based on vscode-copilot-chat's AttachContextAction + */ +export function useCompletionTrigger( + inputRef: RefObject, + getCompletionItems: ( + trigger: '@' | '/', + query: string, + ) => Promise, +) { + // Show immediate loading and provide a timeout fallback for slow sources + const LOADING_ITEM = useMemo( + () => ({ + id: 'loading', + label: 'Loading…', + type: 'info', + }), + [], + ); + + const TIMEOUT_ITEM = useMemo( + () => ({ + id: 'timeout', + label: 'Timeout', + type: 'info', + }), + [], + ); + const TIMEOUT_MS = 5000; + + const [state, setState] = useState({ + isOpen: false, + triggerChar: null, + query: '', + position: { top: 0, left: 0 }, + items: [], + }); + + // Timer for loading timeout + const timeoutRef = useRef | null>(null); + + const closeCompletion = useCallback(() => { + // Clear pending timeout + if (timeoutRef.current) { + clearTimeout(timeoutRef.current); + timeoutRef.current = null; + } + setState({ + isOpen: false, + triggerChar: null, + query: '', + position: { top: 0, left: 0 }, + items: [], + }); + }, []); + + const openCompletion = useCallback( + async ( + trigger: '@' | '/', + query: string, + position: { top: number; left: number }, + ) => { + // Clear previous timeout if any + if (timeoutRef.current) { + clearTimeout(timeoutRef.current); + timeoutRef.current = null; + } + + // Open immediately with a loading placeholder + setState({ + isOpen: true, + triggerChar: trigger, + query, + position, + items: [LOADING_ITEM], + }); + + // Schedule a timeout fallback if loading takes too long + timeoutRef.current = setTimeout(() => { + setState((prev) => { + // Only show timeout if still open and still for the same request + if ( + prev.isOpen && + prev.triggerChar === trigger && + prev.query === query && + prev.items.length > 0 && + prev.items[0]?.id === 'loading' + ) { + return { ...prev, items: [TIMEOUT_ITEM] }; + } + return prev; + }); + }, TIMEOUT_MS); + + const items = await getCompletionItems(trigger, query); + + // Clear timeout on success + if (timeoutRef.current) { + clearTimeout(timeoutRef.current); + timeoutRef.current = null; + } + + setState((prev) => ({ + ...prev, + isOpen: true, + triggerChar: trigger, + query, + position, + items, + })); + }, + [getCompletionItems, LOADING_ITEM, TIMEOUT_ITEM], + ); + + // Helper function to compare completion items arrays + const areItemsEqual = ( + items1: CompletionItem[], + items2: CompletionItem[], + ): boolean => { + if (items1.length !== items2.length) { + return false; + } + + // Compare each item by stable fields (ignore non-deterministic props like icons) + for (let i = 0; i < items1.length; i++) { + const a = items1[i]; + const b = items2[i]; + if (a.id !== b.id) { + return false; + } + if (a.label !== b.label) { + return false; + } + if ((a.description ?? '') !== (b.description ?? '')) { + return false; + } + if (a.type !== b.type) { + return false; + } + if ((a.value ?? '') !== (b.value ?? '')) { + return false; + } + if ((a.path ?? '') !== (b.path ?? '')) { + return false; + } + } + + return true; + }; + + const refreshCompletion = useCallback(async () => { + if (!state.isOpen || !state.triggerChar) { + return; + } + const items = await getCompletionItems(state.triggerChar, state.query); + + // Only update state if items have actually changed + setState((prev) => { + if (areItemsEqual(prev.items, items)) { + return prev; + } + return { ...prev, items }; + }); + }, [state.isOpen, state.triggerChar, state.query, getCompletionItems]); + + useEffect(() => { + const inputElement = inputRef.current; + if (!inputElement) { + return; + } + + const getCursorPosition = (): { top: number; left: number } | null => { + const selection = window.getSelection(); + if (!selection || selection.rangeCount === 0) { + return null; + } + + try { + const range = selection.getRangeAt(0); + const rect = range.getBoundingClientRect(); + + // If the range has a valid position, use it + if (rect.top > 0 && rect.left > 0) { + return { + top: rect.top, + left: rect.left, + }; + } + + // Fallback: use input element's position + const inputRect = inputElement.getBoundingClientRect(); + return { + top: inputRect.top, + left: inputRect.left, + }; + } catch (error) { + console.error( + '[useCompletionTrigger] Error getting cursor position:', + error, + ); + const inputRect = inputElement.getBoundingClientRect(); + return { + top: inputRect.top, + left: inputRect.left, + }; + } + }; + + const handleInput = async () => { + const text = inputElement.textContent || ''; + const selection = window.getSelection(); + if (!selection || selection.rangeCount === 0) { + console.log('[useCompletionTrigger] No selection or rangeCount === 0'); + return; + } + + const range = selection.getRangeAt(0); + + // Get cursor position more reliably + // For contentEditable, we need to calculate the actual text offset + let cursorPosition = text.length; // Default to end of text + + if (range.startContainer === inputElement) { + // Cursor is directly in the container (e.g., empty or at boundary) + // Use childNodes to determine position + const childIndex = range.startOffset; + let offset = 0; + for ( + let i = 0; + i < childIndex && i < inputElement.childNodes.length; + i++ + ) { + offset += inputElement.childNodes[i].textContent?.length || 0; + } + cursorPosition = offset || text.length; + } else if (range.startContainer.nodeType === Node.TEXT_NODE) { + // Cursor is in a text node - calculate offset from start of input + const walker = document.createTreeWalker( + inputElement, + NodeFilter.SHOW_TEXT, + null, + ); + + let offset = 0; + let found = false; + let node: Node | null = walker.nextNode(); + while (node) { + if (node === range.startContainer) { + offset += range.startOffset; + found = true; + break; + } + offset += node.textContent?.length || 0; + node = walker.nextNode(); + } + // If we found the node, use the calculated offset; otherwise use text length + cursorPosition = found ? offset : text.length; + } + + // Find trigger character before cursor + // Use text length if cursorPosition is 0 but we have text (edge case for first character) + const effectiveCursorPosition = + cursorPosition === 0 && text.length > 0 ? text.length : cursorPosition; + + const textBeforeCursor = text.substring(0, effectiveCursorPosition); + const lastAtMatch = textBeforeCursor.lastIndexOf('@'); + const lastSlashMatch = textBeforeCursor.lastIndexOf('/'); + + // Check if we're in a trigger context + let triggerPos = -1; + let triggerChar: '@' | '/' | null = null; + + if (lastAtMatch > lastSlashMatch) { + triggerPos = lastAtMatch; + triggerChar = '@'; + } else if (lastSlashMatch > lastAtMatch) { + triggerPos = lastSlashMatch; + triggerChar = '/'; + } + + // Check if trigger is at word boundary (start of line or after space) + if (triggerPos >= 0 && triggerChar) { + const charBefore = triggerPos > 0 ? text[triggerPos - 1] : ' '; + const isValidTrigger = + charBefore === ' ' || charBefore === '\n' || triggerPos === 0; + + if (isValidTrigger) { + const query = text.substring(triggerPos + 1, effectiveCursorPosition); + + // Only show if query doesn't contain spaces (still typing the reference) + if (!query.includes(' ') && !query.includes('\n')) { + // Get precise cursor position for menu + const cursorPos = getCursorPosition(); + if (cursorPos) { + await openCompletion(triggerChar, query, cursorPos); + return; + } + } + } + } + + // Close if no valid trigger + if (state.isOpen) { + closeCompletion(); + } + }; + + inputElement.addEventListener('input', handleInput); + return () => inputElement.removeEventListener('input', handleInput); + }, [inputRef, state.isOpen, openCompletion, closeCompletion]); + + return { + isOpen: state.isOpen, + triggerChar: state.triggerChar, + query: state.query, + position: state.position, + items: state.items, + closeCompletion, + openCompletion, + refreshCompletion, + }; +} diff --git a/packages/vscode-ide-companion/src/webview/hooks/useMessageSubmit.ts b/packages/vscode-ide-companion/src/webview/hooks/useMessageSubmit.ts new file mode 100644 index 000000000..a91594c05 --- /dev/null +++ b/packages/vscode-ide-companion/src/webview/hooks/useMessageSubmit.ts @@ -0,0 +1,171 @@ +/** + * @license + * Copyright 2025 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + */ + +import { useCallback } from 'react'; +import type { VSCodeAPI } from './useVSCode.js'; +import { getRandomLoadingMessage } from '../../constants/loadingMessages.js'; + +interface UseMessageSubmitProps { + vscode: VSCodeAPI; + inputText: string; + setInputText: (text: string) => void; + inputFieldRef: React.RefObject; + isStreaming: boolean; + isWaitingForResponse: boolean; + // When true, do NOT auto-attach the active editor file/selection to context + skipAutoActiveContext?: boolean; + + fileContext: { + getFileReference: (fileName: string) => string | undefined; + activeFilePath: string | null; + activeFileName: string | null; + activeSelection: { startLine: number; endLine: number } | null; + clearFileReferences: () => void; + }; + + messageHandling: { + setWaitingForResponse: (message: string) => void; + }; +} + +/** + * Message submit Hook + * Handles message submission logic and context parsing + */ +export const useMessageSubmit = ({ + vscode, + inputText, + setInputText, + inputFieldRef, + isStreaming, + isWaitingForResponse, + skipAutoActiveContext = false, + fileContext, + messageHandling, +}: UseMessageSubmitProps) => { + const handleSubmit = useCallback( + (e: React.FormEvent) => { + e.preventDefault(); + + if (!inputText.trim() || isStreaming || isWaitingForResponse) { + return; + } + + // Handle /login command - show inline loading while extension authenticates + if (inputText.trim() === '/login') { + setInputText(''); + if (inputFieldRef.current) { + // Use a zero-width space to maintain the height of the contentEditable element + inputFieldRef.current.textContent = '\u200B'; + // Set the data-empty attribute to show the placeholder + inputFieldRef.current.setAttribute('data-empty', 'true'); + } + vscode.postMessage({ + type: 'login', + data: {}, + }); + // Show a friendly loading message in the chat while logging in + try { + messageHandling.setWaitingForResponse('Logging in to Qwen Code...'); + } catch (_err) { + // Best-effort UI hint; ignore if hook not available + } + return; + } + + messageHandling.setWaitingForResponse(getRandomLoadingMessage()); + + // Parse @file references from input text + const context: Array<{ + type: string; + name: string; + value: string; + startLine?: number; + endLine?: number; + }> = []; + const fileRefPattern = /@([^\s]+)/g; + let match; + + while ((match = fileRefPattern.exec(inputText)) !== null) { + const fileName = match[1]; + const filePath = fileContext.getFileReference(fileName); + + if (filePath) { + context.push({ + type: 'file', + name: fileName, + value: filePath, + }); + } + } + + // Add active file selection context if present and not skipped + if (fileContext.activeFilePath && !skipAutoActiveContext) { + const fileName = fileContext.activeFileName || 'current file'; + context.push({ + type: 'file', + name: fileName, + value: fileContext.activeFilePath, + startLine: fileContext.activeSelection?.startLine, + endLine: fileContext.activeSelection?.endLine, + }); + } + + let fileContextForMessage: + | { + fileName: string; + filePath: string; + startLine?: number; + endLine?: number; + } + | undefined; + + if ( + fileContext.activeFilePath && + fileContext.activeFileName && + !skipAutoActiveContext + ) { + fileContextForMessage = { + fileName: fileContext.activeFileName, + filePath: fileContext.activeFilePath, + startLine: fileContext.activeSelection?.startLine, + endLine: fileContext.activeSelection?.endLine, + }; + } + + vscode.postMessage({ + type: 'sendMessage', + data: { + text: inputText, + context: context.length > 0 ? context : undefined, + fileContext: fileContextForMessage, + }, + }); + + setInputText(''); + if (inputFieldRef.current) { + // Use a zero-width space to maintain the height of the contentEditable element + inputFieldRef.current.textContent = '\u200B'; + // Set the data-empty attribute to show the placeholder + inputFieldRef.current.setAttribute('data-empty', 'true'); + } + fileContext.clearFileReferences(); + }, + [ + inputText, + isStreaming, + setInputText, + inputFieldRef, + vscode, + fileContext, + skipAutoActiveContext, + isWaitingForResponse, + messageHandling, + ], + ); + + return { handleSubmit }; +}; diff --git a/packages/vscode-ide-companion/src/webview/hooks/useToolCalls.ts b/packages/vscode-ide-companion/src/webview/hooks/useToolCalls.ts new file mode 100644 index 000000000..1b994afda --- /dev/null +++ b/packages/vscode-ide-companion/src/webview/hooks/useToolCalls.ts @@ -0,0 +1,253 @@ +/** + * @license + * Copyright 2025 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + */ + +import { useState, useCallback } from 'react'; +import type { ToolCallData } from '../components/messages/toolcalls/ToolCall.js'; +import type { ToolCallUpdate } from '../../types/chatTypes.js'; + +/** + * Tool call management Hook + * Manages tool call states and updates + */ +export const useToolCalls = () => { + const [toolCalls, setToolCalls] = useState>( + new Map(), + ); + + /** + * Handle tool call update + */ + const handleToolCallUpdate = useCallback((update: ToolCallUpdate) => { + setToolCalls((prevToolCalls) => { + const newMap = new Map(prevToolCalls); + const existing = newMap.get(update.toolCallId); + + // Helpers for todo/todos plan merging & content replacement + const isTodoWrite = (kind?: string) => + (kind || '').toLowerCase() === 'todo_write' || + (kind || '').toLowerCase() === 'todowrite' || + (kind || '').toLowerCase() === 'update_todos'; + + const normTitle = (t: unknown) => + typeof t === 'string' ? t.trim().toLowerCase() : ''; + + const isTodoTitleMergeable = (t?: unknown) => { + const nt = normTitle(t); + return nt === 'updated plan' || nt === 'update todos'; + }; + + const extractText = ( + content?: Array<{ + type: 'content' | 'diff'; + content?: { text?: string }; + }>, + ): string => { + if (!content || content.length === 0) { + return ''; + } + const parts: string[] = []; + for (const item of content) { + if (item.type === 'content' && item.content?.text) { + parts.push(String(item.content.text)); + } + } + return parts.join('\n'); + }; + + const normalizeTodoLines = (text: string): string[] => { + if (!text) { + return []; + } + const lines = text + .split(/\r?\n/) + .map((l) => l.trim()) + .filter(Boolean); + return lines.map((line) => { + const idx = line.indexOf('] '); + return idx >= 0 ? line.slice(idx + 2).trim() : line; + }); + }; + + const isSameOrSupplement = ( + prevText: string, + nextText: string, + ): { same: boolean; supplement: boolean } => { + const prev = normalizeTodoLines(prevText); + const next = normalizeTodoLines(nextText); + if (prev.length === next.length) { + const same = prev.every((l, i) => l === next[i]); + if (same) { + return { same: true, supplement: false }; + } + } + // supplement = prev set is subset of next set + const setNext = new Set(next); + const subset = prev.every((l) => setNext.has(l)); + return { same: false, supplement: subset }; + }; + + const safeTitle = (title: unknown): string => { + if (typeof title === 'string') { + return title; + } + if (title && typeof title === 'object') { + return JSON.stringify(title); + } + return 'Tool Call'; + }; + + if (update.type === 'tool_call') { + const content = update.content?.map((item) => ({ + type: item.type as 'content' | 'diff', + content: item.content, + path: item.path, + oldText: item.oldText, + newText: item.newText, + })); + + // Merge strategy: For todo_write + mergeable titles (Updated Plan/Update Todos), + // if it is the same as or a supplement to the most recent similar card, merge the update instead of adding new. + if (isTodoWrite(update.kind) && isTodoTitleMergeable(update.title)) { + const nextText = extractText(content); + // Find the most recent card with todo_write + mergeable title + let lastId: string | null = null; + let lastText = ''; + let lastTimestamp = 0; + for (const tc of newMap.values()) { + if ( + isTodoWrite(tc.kind) && + isTodoTitleMergeable(tc.title) && + typeof tc.timestamp === 'number' && + tc.timestamp >= lastTimestamp + ) { + lastId = tc.toolCallId; + lastText = extractText(tc.content); + lastTimestamp = tc.timestamp || 0; + } + } + + if (lastId) { + const cmp = isSameOrSupplement(lastText, nextText); + if (cmp.same) { + // Completely identical: Ignore this addition + return newMap; + } + if (cmp.supplement) { + // Supplement: Replace content to the previous item (using update semantics) + const prev = newMap.get(lastId); + if (prev) { + newMap.set(lastId, { + ...prev, + content, // Override (do not append) + status: update.status || prev.status, + timestamp: update.timestamp || Date.now(), + }); + return newMap; + } + } + } + } + + newMap.set(update.toolCallId, { + toolCallId: update.toolCallId, + kind: update.kind || 'other', + title: safeTitle(update.title), + status: update.status || 'pending', + rawInput: update.rawInput as string | object | undefined, + content, + locations: update.locations, + timestamp: update.timestamp || Date.now(), // Add timestamp + }); + } else if (update.type === 'tool_call_update') { + const updatedContent = update.content + ? update.content.map((item) => ({ + type: item.type as 'content' | 'diff', + content: item.content, + path: item.path, + oldText: item.oldText, + newText: item.newText, + })) + : undefined; + + if (existing) { + // Default behavior is to append; but for todo_write + mergeable titles, use replacement to avoid stacking duplicates + let mergedContent = existing.content; + if (updatedContent) { + if ( + isTodoWrite(update.kind || existing.kind) && + (isTodoTitleMergeable(update.title) || + isTodoTitleMergeable(existing.title)) + ) { + mergedContent = updatedContent; // Override + } else { + mergedContent = [...(existing.content || []), ...updatedContent]; + } + } + // If tool call has just completed/failed, bump timestamp to now for correct ordering + const isFinal = + update.status === 'completed' || update.status === 'failed'; + const nextTimestamp = isFinal + ? Date.now() + : update.timestamp || existing.timestamp || Date.now(); + + newMap.set(update.toolCallId, { + ...existing, + ...(update.kind && { kind: update.kind }), + ...(update.title && { title: safeTitle(update.title) }), + ...(update.status && { status: update.status }), + content: mergedContent, + ...(update.locations && { locations: update.locations }), + timestamp: nextTimestamp, // Update timestamp (use completion time when completed/failed) + }); + } else { + newMap.set(update.toolCallId, { + toolCallId: update.toolCallId, + kind: update.kind || 'other', + title: update.title ? safeTitle(update.title) : '', + status: update.status || 'pending', + rawInput: update.rawInput as string | object | undefined, + content: updatedContent, + locations: update.locations, + timestamp: update.timestamp || Date.now(), // Add timestamp + }); + } + } + + return newMap; + }); + }, []); + + /** + * Clear all tool calls + */ + const clearToolCalls = useCallback(() => { + setToolCalls(new Map()); + }, []); + + /** + * Get in-progress tool calls + */ + const inProgressToolCalls = Array.from(toolCalls.values()).filter( + (toolCall) => + toolCall.status === 'pending' || toolCall.status === 'in_progress', + ); + + /** + * Get completed tool calls + */ + const completedToolCalls = Array.from(toolCalls.values()).filter( + (toolCall) => + toolCall.status === 'completed' || toolCall.status === 'failed', + ); + + return { + toolCalls, + inProgressToolCalls, + completedToolCalls, + handleToolCallUpdate, + clearToolCalls, + }; +}; diff --git a/packages/vscode-ide-companion/src/webview/hooks/useVSCode.ts b/packages/vscode-ide-companion/src/webview/hooks/useVSCode.ts new file mode 100644 index 000000000..1a161346a --- /dev/null +++ b/packages/vscode-ide-companion/src/webview/hooks/useVSCode.ts @@ -0,0 +1,54 @@ +/** + * @license + * Copyright 2025 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + */ + +export interface VSCodeAPI { + postMessage: (message: unknown) => void; + getState: () => unknown; + setState: (state: unknown) => void; +} + +declare const acquireVsCodeApi: () => VSCodeAPI; + +/** + * Module-level VS Code API instance cache + * acquireVsCodeApi() can only be called once, must be cached at module level + */ +let vscodeApiInstance: VSCodeAPI | null = null; + +/** + * Get VS Code API instance + * Uses module-level cache to ensure acquireVsCodeApi() is only called once + */ +function getVSCodeAPI(): VSCodeAPI { + if (vscodeApiInstance) { + return vscodeApiInstance; + } + + if (typeof acquireVsCodeApi !== 'undefined') { + vscodeApiInstance = acquireVsCodeApi(); + return vscodeApiInstance; + } + + // Fallback for development/testing + vscodeApiInstance = { + postMessage: (message: unknown) => { + console.log('Mock postMessage:', message); + }, + getState: () => ({}), + setState: (state: unknown) => { + console.log('Mock setState:', state); + }, + }; + return vscodeApiInstance; +} + +/** + * Hook to get VS Code API + * Multiple components can safely call this hook, API instance will be reused + */ +export function useVSCode(): VSCodeAPI { + return getVSCodeAPI(); +} diff --git a/packages/vscode-ide-companion/src/webview/hooks/useWebViewMessages.ts b/packages/vscode-ide-companion/src/webview/hooks/useWebViewMessages.ts new file mode 100644 index 000000000..8336825ce --- /dev/null +++ b/packages/vscode-ide-companion/src/webview/hooks/useWebViewMessages.ts @@ -0,0 +1,843 @@ +/** + * @license + * Copyright 2025 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + */ + +import { useEffect, useRef, useCallback } from 'react'; +import { useVSCode } from './useVSCode.js'; +import type { Conversation } from '../../services/conversationStore.js'; +import type { + PermissionOption, + ToolCall as PermissionToolCall, +} from '../components/PermissionDrawer/PermissionRequest.js'; +import type { ToolCallUpdate } from '../../types/chatTypes.js'; +import type { ApprovalModeValue } from '../../types/approvalModeValueTypes.js'; +import type { PlanEntry } from '../../types/chatTypes.js'; + +const FORCE_CLEAR_STREAM_END_REASONS = new Set([ + 'user_cancelled', + 'cancelled', + 'timeout', + 'error', + 'session_expired', +]); + +interface UseWebViewMessagesProps { + // Session management + sessionManagement: { + currentSessionId: string | null; + setQwenSessions: ( + sessions: + | Array> + | (( + prev: Array>, + ) => Array>), + ) => void; + setCurrentSessionId: (id: string | null) => void; + setCurrentSessionTitle: (title: string) => void; + setShowSessionSelector: (show: boolean) => void; + setNextCursor: (cursor: number | undefined) => void; + setHasMore: (hasMore: boolean) => void; + setIsLoading: (loading: boolean) => void; + handleSaveSessionResponse: (response: { + success: boolean; + message?: string; + }) => void; + }; + + // File context + fileContext: { + setActiveFileName: (name: string | null) => void; + setActiveFilePath: (path: string | null) => void; + setActiveSelection: ( + selection: { startLine: number; endLine: number } | null, + ) => void; + setWorkspaceFiles: ( + files: Array<{ + id: string; + label: string; + description: string; + path: string; + }>, + ) => void; + addFileReference: (name: string, path: string) => void; + }; + + // Message handling + messageHandling: { + setMessages: ( + messages: Array<{ + role: 'user' | 'assistant' | 'thinking'; + content: string; + timestamp: number; + fileContext?: { + fileName: string; + filePath: string; + startLine?: number; + endLine?: number; + }; + }>, + ) => void; + addMessage: (message: { + role: 'user' | 'assistant' | 'thinking'; + content: string; + timestamp: number; + }) => void; + clearMessages: () => void; + startStreaming: (timestamp?: number) => void; + appendStreamChunk: (chunk: string) => void; + endStreaming: () => void; + breakAssistantSegment: () => void; + appendThinkingChunk: (chunk: string) => void; + clearThinking: () => void; + setWaitingForResponse: (message: string) => void; + clearWaitingForResponse: () => void; + }; + + // Tool calls + handleToolCallUpdate: (update: ToolCallUpdate) => void; + clearToolCalls: () => void; + + // Plan + setPlanEntries: (entries: PlanEntry[]) => void; + + // Permission + // When request is non-null, open/update the permission drawer. + // When null, close the drawer (used when extension simulates a choice). + handlePermissionRequest: ( + request: { + options: PermissionOption[]; + toolCall: PermissionToolCall; + } | null, + ) => void; + + // Input + inputFieldRef: React.RefObject; + setInputText: (text: string) => void; + // Edit mode setter (maps ACP modes to UI modes) + setEditMode?: (mode: ApprovalModeValue) => void; + // Authentication state setter + setIsAuthenticated?: (authenticated: boolean | null) => void; +} + +/** + * WebView message handling Hook + * Handles all messages from VSCode Extension uniformly + */ +export const useWebViewMessages = ({ + sessionManagement, + fileContext, + messageHandling, + handleToolCallUpdate, + clearToolCalls, + setPlanEntries, + handlePermissionRequest, + inputFieldRef, + setInputText, + setEditMode, + setIsAuthenticated, +}: UseWebViewMessagesProps) => { + // VS Code API for posting messages back to the extension host + const vscode = useVSCode(); + // Track active long-running tool calls (execute/bash/command) so we can + // keep the bottom "waiting" message visible until all of them complete. + const activeExecToolCallsRef = useRef>(new Set()); + // Use ref to store callbacks to avoid useEffect dependency issues + const handlersRef = useRef({ + sessionManagement, + fileContext, + messageHandling, + handleToolCallUpdate, + clearToolCalls, + setPlanEntries, + handlePermissionRequest, + setIsAuthenticated, + }); + + // Track last "Updated Plan" snapshot toolcall to support merge/dedupe + const lastPlanSnapshotRef = useRef<{ + id: string; + text: string; // joined lines + lines: string[]; + } | null>(null); + + const buildPlanLines = (entries: PlanEntry[]): string[] => + entries.map((e) => { + const mark = + e.status === 'completed' ? 'x' : e.status === 'in_progress' ? '-' : ' '; + return `- [${mark}] ${e.content}`.trim(); + }); + + const isSupplementOf = ( + prevLines: string[], + nextLines: string[], + ): boolean => { + // Consider "supplement" = old content text collection (ignoring status) is contained in new content + const key = (line: string) => { + const idx = line.indexOf('] '); + return idx >= 0 ? line.slice(idx + 2).trim() : line.trim(); + }; + const nextSet = new Set(nextLines.map(key)); + for (const pl of prevLines) { + if (!nextSet.has(key(pl))) { + return false; + } + } + return true; + }; + + // Update refs + useEffect(() => { + handlersRef.current = { + sessionManagement, + fileContext, + messageHandling, + handleToolCallUpdate, + clearToolCalls, + setPlanEntries, + handlePermissionRequest, + setIsAuthenticated, + }; + }); + + const handleMessage = useCallback( + (event: MessageEvent) => { + const message = event.data; + const handlers = handlersRef.current; + + switch (message.type) { + case 'modeInfo': { + // Initialize UI mode from ACP initialize + try { + const current = (message.data?.currentModeId || + 'default') as ApprovalModeValue; + setEditMode?.(current); + } catch (_error) { + // best effort + } + break; + } + + case 'modeChanged': { + try { + const modeId = (message.data?.modeId || + 'default') as ApprovalModeValue; + setEditMode?.(modeId); + } catch (_error) { + // Ignore error when setting mode + } + break; + } + + case 'loginSuccess': { + // Clear loading state and show a short assistant notice + handlers.messageHandling.clearWaitingForResponse(); + handlers.messageHandling.addMessage({ + role: 'assistant', + content: 'Successfully logged in. You can continue chatting.', + timestamp: Date.now(), + }); + // Set authentication state to true + handlers.setIsAuthenticated?.(true); + break; + } + + case 'agentConnected': { + // Agent connected successfully; clear any pending spinner + handlers.messageHandling.clearWaitingForResponse(); + // Set authentication state to true + handlers.setIsAuthenticated?.(true); + break; + } + + case 'agentConnectionError': { + // Agent connection failed; surface the error and unblock the UI + handlers.messageHandling.clearWaitingForResponse(); + const errorMsg = + (message?.data?.message as string) || + 'Failed to connect to Qwen agent.'; + + handlers.messageHandling.addMessage({ + role: 'assistant', + content: `Failed to connect to Qwen agent: ${errorMsg}\nYou can still use the chat UI, but messages won't be sent to AI.`, + timestamp: Date.now(), + }); + // Set authentication state to false + handlers.setIsAuthenticated?.(false); + break; + } + + case 'loginError': { + // Clear loading state and show error notice + handlers.messageHandling.clearWaitingForResponse(); + const errorMsg = + (message?.data?.message as string) || + 'Login failed. Please try again.'; + handlers.messageHandling.addMessage({ + role: 'assistant', + content: errorMsg, + timestamp: Date.now(), + }); + // Set authentication state to false + handlers.setIsAuthenticated?.(false); + break; + } + + case 'authState': { + const state = ( + message?.data as { authenticated?: boolean | null } | undefined + )?.authenticated; + if (typeof state === 'boolean') { + handlers.setIsAuthenticated?.(state); + } else { + handlers.setIsAuthenticated?.(null); + } + break; + } + + case 'conversationLoaded': { + const conversation = message.data as Conversation; + handlers.messageHandling.setMessages(conversation.messages); + break; + } + + case 'message': { + const msg = message.data as { + role?: 'user' | 'assistant' | 'thinking'; + content?: string; + timestamp?: number; + }; + handlers.messageHandling.addMessage( + msg as unknown as Parameters< + typeof handlers.messageHandling.addMessage + >[0], + ); + // Robustness: if an assistant message arrives outside the normal stream + // pipeline (no explicit streamEnd), ensure we clear streaming/waiting states + if (msg.role === 'assistant') { + try { + handlers.messageHandling.endStreaming(); + } catch (_error) { + // no-op: stream might not have been started + console.warn('[PanelManager] Failed to end streaming:', _error); + } + // Important: Do NOT blindly clear the waiting message if there are + // still active tool calls running. We keep the waiting indicator + // tied to tool-call lifecycle instead. + if (activeExecToolCallsRef.current.size === 0) { + try { + handlers.messageHandling.clearWaitingForResponse(); + } catch (_error) { + // no-op: already cleared + console.warn( + '[PanelManager] Failed to clear waiting for response:', + _error, + ); + } + } + } + break; + } + + case 'streamStart': + handlers.messageHandling.startStreaming( + (message.data as { timestamp?: number } | undefined)?.timestamp, + ); + break; + + case 'streamChunk': { + handlers.messageHandling.appendStreamChunk(message.data.chunk); + break; + } + + case 'thoughtChunk': { + const chunk = message.data.content || message.data.chunk || ''; + handlers.messageHandling.appendThinkingChunk(chunk); + break; + } + + case 'streamEnd': { + // Always end local streaming state and clear thinking state + handlers.messageHandling.endStreaming(); + handlers.messageHandling.clearThinking(); + + // If stream ended due to explicit user cancellation, proactively clear + // waiting indicator and reset tracked execution calls. + // This avoids UI getting stuck with Stop button visible after + // rejecting a permission request. + try { + const reason = ( + (message.data as { reason?: string } | undefined)?.reason || '' + ).toLowerCase(); + + /** + * Handle different types of stream end reasons that require a full reset: + * - 'user_cancelled' / 'cancelled': user explicitly cancelled + * - 'timeout' / 'error' / 'session_expired': request failed unexpectedly + * For these cases, immediately clear all active states. + */ + if (FORCE_CLEAR_STREAM_END_REASONS.has(reason)) { + // Clear active execution tool call tracking, reset state + activeExecToolCallsRef.current.clear(); + // Clear waiting response state to ensure UI returns to normal + handlers.messageHandling.clearWaitingForResponse(); + break; + } + } catch (_error) { + // Best-effort handling, errors don't affect main flow + } + + /** + * For other types of stream end (non-user cancellation): + * Only clear generic waiting indicator when there are no active + * long-running tool calls. If there are still active execute/bash/command + * calls, keep the hint visible. + */ + if (activeExecToolCallsRef.current.size === 0) { + handlers.messageHandling.clearWaitingForResponse(); + } + break; + } + + case 'error': + handlers.messageHandling.endStreaming(); + handlers.messageHandling.clearThinking(); + activeExecToolCallsRef.current.clear(); + handlers.messageHandling.clearWaitingForResponse(); + break; + + case 'permissionRequest': { + handlers.handlePermissionRequest(message.data); + + const permToolCall = message.data?.toolCall as { + toolCallId?: string; + kind?: string; + title?: string; + status?: string; + content?: unknown[]; + locations?: Array<{ path: string; line?: number | null }>; + }; + + if (permToolCall?.toolCallId) { + // Infer kind more robustly for permission preview: + // - If content contains a diff entry, force 'edit' so the EditToolCall can handle it properly + // - Else try title-based hints; fall back to provided kind or 'execute' + let kind = permToolCall.kind || 'execute'; + const contentArr = (permToolCall.content as unknown[]) || []; + const hasDiff = Array.isArray(contentArr) + ? contentArr.some( + (c: unknown) => + !!c && + typeof c === 'object' && + (c as { type?: string }).type === 'diff', + ) + : false; + if (hasDiff) { + kind = 'edit'; + + // Auto-open diff view for edit operations with diff content + // This replaces the useEffect auto-trigger in EditToolCall component + const diffContent = contentArr.find( + (c: unknown) => + !!c && + typeof c === 'object' && + (c as { type?: string }).type === 'diff', + ) as + | { path?: string; oldText?: string; newText?: string } + | undefined; + + if ( + diffContent?.path && + diffContent?.oldText !== undefined && + diffContent?.newText !== undefined + ) { + vscode.postMessage({ + type: 'openDiff', + data: { + path: diffContent.path, + oldText: diffContent.oldText, + newText: diffContent.newText, + }, + }); + } + } else if (permToolCall.title) { + const title = permToolCall.title.toLowerCase(); + if (title.includes('touch') || title.includes('echo')) { + kind = 'execute'; + } else if (title.includes('read') || title.includes('cat')) { + kind = 'read'; + } else if (title.includes('write') || title.includes('edit')) { + kind = 'edit'; + } + } + + const normalizedStatus = ( + permToolCall.status === 'pending' || + permToolCall.status === 'in_progress' || + permToolCall.status === 'completed' || + permToolCall.status === 'failed' + ? permToolCall.status + : 'pending' + ) as ToolCallUpdate['status']; + + handlers.handleToolCallUpdate({ + type: 'tool_call', + toolCallId: permToolCall.toolCallId, + kind, + title: permToolCall.title, + status: normalizedStatus, + content: permToolCall.content as ToolCallUpdate['content'], + locations: permToolCall.locations, + }); + + // Split assistant stream so subsequent chunks start a new assistant message + handlers.messageHandling.breakAssistantSegment(); + } + break; + } + + case 'permissionResolved': { + // Extension proactively resolved a pending permission; close drawer. + try { + handlers.handlePermissionRequest(null); + } catch (_error) { + console.warn( + '[useWebViewMessages] failed to close permission UI:', + _error, + ); + } + break; + } + + case 'plan': + if (message.data.entries && Array.isArray(message.data.entries)) { + const entries = message.data.entries as PlanEntry[]; + handlers.setPlanEntries(entries); + + // Generate new snapshot text + const lines = buildPlanLines(entries); + const text = lines.join('\n'); + const prev = lastPlanSnapshotRef.current; + + // 1) Identical -> Skip + if (prev && prev.text === text) { + break; + } + + try { + const ts = Date.now(); + + // 2) Supplement or status update -> Merge to previous (use tool_call_update to override content) + if (prev && isSupplementOf(prev.lines, lines)) { + handlers.handleToolCallUpdate({ + type: 'tool_call_update', + toolCallId: prev.id, + kind: 'todo_write', + title: 'Updated Plan', + status: 'completed', + content: [ + { + type: 'content', + content: { type: 'text', text }, + }, + ], + timestamp: ts, + }); + lastPlanSnapshotRef.current = { id: prev.id, text, lines }; + } else { + // 3) Other cases -> Add a new history card + const toolCallId = `plan-snapshot-${ts}`; + handlers.handleToolCallUpdate({ + type: 'tool_call', + toolCallId, + kind: 'todo_write', + title: 'Updated Plan', + status: 'completed', + content: [ + { + type: 'content', + content: { type: 'text', text }, + }, + ], + timestamp: ts, + }); + lastPlanSnapshotRef.current = { id: toolCallId, text, lines }; + } + + // Split assistant message segments, keep rendering blocks independent + handlers.messageHandling.breakAssistantSegment?.(); + } catch (_error) { + console.warn( + '[useWebViewMessages] failed to push/merge plan snapshot toolcall:', + _error, + ); + } + } + break; + + case 'toolCall': + case 'toolCallUpdate': { + const toolCallData = message.data; + if (toolCallData.sessionUpdate && !toolCallData.type) { + toolCallData.type = toolCallData.sessionUpdate; + } + handlers.handleToolCallUpdate(toolCallData); + + // Split assistant stream + const status = (toolCallData.status || '').toString(); + const isStart = toolCallData.type === 'tool_call'; + const isFinalUpdate = + toolCallData.type === 'tool_call_update' && + (status === 'completed' || status === 'failed'); + if (isStart || isFinalUpdate) { + handlers.messageHandling.breakAssistantSegment(); + } + + // While long-running tools (e.g., execute/bash/command) are in progress, + // surface a lightweight loading indicator and expose the Stop button. + try { + const id = (toolCallData.toolCallId || '').toString(); + const kind = (toolCallData.kind || '').toString().toLowerCase(); + const isExecKind = + kind === 'execute' || kind === 'bash' || kind === 'command'; + // CLI sometimes omits kind in tool_call_update payloads; fall back to + // whether we've already tracked this ID as an exec tool. + const wasTrackedExec = activeExecToolCallsRef.current.has(id); + const isExec = isExecKind || wasTrackedExec; + + if (!isExec || !id) { + break; + } + + if (status === 'pending' || status === 'in_progress') { + if (isExecKind) { + activeExecToolCallsRef.current.add(id); + + // Build a helpful hint from rawInput + const rawInput = toolCallData.rawInput; + let cmd = ''; + if (typeof rawInput === 'string') { + cmd = rawInput; + } else if (rawInput && typeof rawInput === 'object') { + const maybe = rawInput as { command?: string }; + cmd = maybe.command || ''; + } + const hint = cmd ? `Running: ${cmd}` : 'Running command...'; + handlers.messageHandling.setWaitingForResponse(hint); + } + } else if (status === 'completed' || status === 'failed') { + activeExecToolCallsRef.current.delete(id); + } + + // If no active exec tool remains, clear the waiting message. + if (activeExecToolCallsRef.current.size === 0) { + handlers.messageHandling.clearWaitingForResponse(); + } + } catch (_error) { + // Best-effort UI hint; ignore errors + } + break; + } + + case 'qwenSessionList': { + const sessions = + (message.data.sessions as Array>) || []; + const append = Boolean(message.data.append); + const nextCursor = message.data.nextCursor as number | undefined; + const hasMore = Boolean(message.data.hasMore); + + handlers.sessionManagement.setQwenSessions( + (prev: Array>) => + append ? [...prev, ...sessions] : sessions, + ); + handlers.sessionManagement.setNextCursor(nextCursor); + handlers.sessionManagement.setHasMore(hasMore); + handlers.sessionManagement.setIsLoading(false); + if ( + handlers.sessionManagement.currentSessionId && + sessions.length > 0 + ) { + const currentSession = sessions.find( + (s: Record) => + (s.id as string) === + handlers.sessionManagement.currentSessionId || + (s.sessionId as string) === + handlers.sessionManagement.currentSessionId, + ); + if (currentSession) { + const title = + (currentSession.title as string) || + (currentSession.name as string) || + 'Past Conversations'; + handlers.sessionManagement.setCurrentSessionTitle(title); + } + } + break; + } + + case 'qwenSessionSwitched': + handlers.sessionManagement.setShowSessionSelector(false); + if (message.data.sessionId) { + handlers.sessionManagement.setCurrentSessionId( + message.data.sessionId as string, + ); + } + if (message.data.session) { + const session = message.data.session as Record; + const title = + (session.title as string) || + (session.name as string) || + 'Past Conversations'; + handlers.sessionManagement.setCurrentSessionTitle(title); + // Update the VS Code webview tab title as well + vscode.postMessage({ type: 'updatePanelTitle', data: { title } }); + } + if (message.data.messages) { + handlers.messageHandling.setMessages(message.data.messages); + } else { + handlers.messageHandling.clearMessages(); + } + + // Clear any waiting message that might be displayed from previous session + handlers.messageHandling.clearWaitingForResponse(); + + // Clear active tool calls tracking + activeExecToolCallsRef.current.clear(); + + // Clear and restore tool calls if provided in session data + handlers.clearToolCalls(); + if (message.data.toolCalls && Array.isArray(message.data.toolCalls)) { + message.data.toolCalls.forEach((toolCall: unknown) => { + if (toolCall && typeof toolCall === 'object') { + handlers.handleToolCallUpdate(toolCall as ToolCallUpdate); + } + }); + } + + // Restore plan entries if provided + if ( + message.data.planEntries && + Array.isArray(message.data.planEntries) + ) { + handlers.setPlanEntries(message.data.planEntries); + } else { + handlers.setPlanEntries([]); + } + lastPlanSnapshotRef.current = null; + break; + + case 'conversationCleared': + handlers.messageHandling.clearMessages(); + handlers.clearToolCalls(); + handlers.sessionManagement.setCurrentSessionId(null); + handlers.sessionManagement.setCurrentSessionTitle( + 'Past Conversations', + ); + // Reset the VS Code tab title to default label + vscode.postMessage({ + type: 'updatePanelTitle', + data: { title: 'Qwen Code' }, + }); + lastPlanSnapshotRef.current = null; + break; + + case 'sessionTitleUpdated': { + const sessionId = message.data?.sessionId as string; + const title = message.data?.title as string; + if (sessionId && title) { + handlers.sessionManagement.setCurrentSessionId(sessionId); + handlers.sessionManagement.setCurrentSessionTitle(title); + // Ask extension host to reflect this title in the tab label + vscode.postMessage({ type: 'updatePanelTitle', data: { title } }); + } + break; + } + + case 'activeEditorChanged': { + const fileName = message.data?.fileName as string | null; + const filePath = message.data?.filePath as string | null; + const selection = message.data?.selection as { + startLine: number; + endLine: number; + } | null; + handlers.fileContext.setActiveFileName(fileName); + handlers.fileContext.setActiveFilePath(filePath); + handlers.fileContext.setActiveSelection(selection); + break; + } + + case 'fileAttached': { + const attachment = message.data as { + id: string; + type: string; + name: string; + value: string; + }; + + handlers.fileContext.addFileReference( + attachment.name, + attachment.value, + ); + + if (inputFieldRef.current) { + const currentText = inputFieldRef.current.textContent || ''; + const newText = currentText + ? `${currentText} @${attachment.name} ` + : `@${attachment.name} `; + inputFieldRef.current.textContent = newText; + setInputText(newText); + + const range = document.createRange(); + const sel = window.getSelection(); + range.selectNodeContents(inputFieldRef.current); + range.collapse(false); + sel?.removeAllRanges(); + sel?.addRange(range); + } + break; + } + + case 'workspaceFiles': { + const files = message.data?.files as Array<{ + id: string; + label: string; + description: string; + path: string; + }>; + if (files) { + console.log('[WebView] Received workspaceFiles:', files.length); + handlers.fileContext.setWorkspaceFiles(files); + } + break; + } + + case 'saveSessionResponse': { + handlers.sessionManagement.handleSaveSessionResponse(message.data); + break; + } + + case 'cancelStreaming': + // Handle cancel streaming request from webview + handlers.messageHandling.endStreaming(); + handlers.messageHandling.clearWaitingForResponse(); + // Add interrupted message + handlers.messageHandling.addMessage({ + role: 'assistant', + content: 'Interrupted', + timestamp: Date.now(), + }); + break; + + default: + break; + } + }, + [inputFieldRef, setInputText, vscode, setEditMode], + ); + + useEffect(() => { + window.addEventListener('message', handleMessage); + return () => window.removeEventListener('message', handleMessage); + }, [handleMessage]); +}; diff --git a/packages/vscode-ide-companion/src/webview/index.tsx b/packages/vscode-ide-companion/src/webview/index.tsx new file mode 100644 index 000000000..547dc3fc4 --- /dev/null +++ b/packages/vscode-ide-companion/src/webview/index.tsx @@ -0,0 +1,21 @@ +/** + * @license + * Copyright 2025 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + */ + +import ReactDOM from 'react-dom/client'; +import { App } from './App.js'; + +// eslint-disable-next-line import/no-internal-modules +import './styles/tailwind.css'; +// eslint-disable-next-line import/no-internal-modules +import './styles/App.css'; +// eslint-disable-next-line import/no-internal-modules +import './styles/styles.css'; + +const container = document.getElementById('root'); +if (container) { + const root = ReactDOM.createRoot(container); + root.render(); +} diff --git a/packages/vscode-ide-companion/src/webview/styles/App.css b/packages/vscode-ide-companion/src/webview/styles/App.css new file mode 100644 index 000000000..e4ce12eaf --- /dev/null +++ b/packages/vscode-ide-companion/src/webview/styles/App.css @@ -0,0 +1,602 @@ +/** + * @license + * Copyright 2025 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + */ + +/* =========================== + CSS Variables (Root Level) + =========================== */ +:root { + /* Qwen Brand Colors */ + --app-qwen-theme: #615fff; + --app-qwen-clay-button-orange: #4f46e5; + --app-qwen-ivory: #f5f5ff; + --app-qwen-slate: #141420; + --app-qwen-green: #6bcf7f; + + /* Spacing */ + --app-spacing-small: 4px; + --app-spacing-medium: 8px; + --app-spacing-large: 12px; + --app-spacing-xlarge: 16px; + + /* Border Radius */ + --corner-radius-small: 4px; + --corner-radius-medium: 6px; + --corner-radius-large: 8px; + + /* Typography */ + --app-monospace-font-family: var(--vscode-editor-font-family, monospace); + --app-monospace-font-size: var(--vscode-editor-font-size, 12px); + + /* Foreground & Background */ + --app-primary-foreground: var(--vscode-foreground); + --app-primary-background: var(--vscode-sideBar-background); + --app-primary-border-color: var(--vscode-sideBarActivityBarTop-border); + --app-secondary-foreground: var(--vscode-descriptionForeground); + + /* Input Colors */ + --app-input-foreground: var(--vscode-input-foreground); + --app-input-background: var(--vscode-input-background); + --app-input-border: var(--vscode-inlineChatInput-border); + --app-input-active-border: var(--vscode-inputOption-activeBorder); + --app-input-placeholder-foreground: var(--vscode-input-placeholderForeground); + --app-input-secondary-background: var(--vscode-menu-background); + /* Input Highlight (focus ring/border) */ + --app-input-highlight: var(--app-qwen-theme); + + /* Code Highlighting */ + --app-code-background: var( + --vscode-textCodeBlock-background, + rgba(0, 0, 0, 0.05) + ); + --app-link-foreground: var(--vscode-textLink-foreground, #007acc); + --app-link-active-foreground: var( + --vscode-textLink-activeForeground, + #005a9e + ); + + /* List Styles */ + --app-list-hover-background: var(--vscode-list-hoverBackground); + --app-list-active-background: var(--vscode-list-activeSelectionBackground); + --app-list-active-foreground: var(--vscode-list-activeSelectionForeground); + + /* Buttons */ + --app-ghost-button-hover-background: var(--vscode-toolbar-hoverBackground); + --app-button-foreground: var(--vscode-button-foreground); + --app-button-background: var(--vscode-button-background); + --app-button-hover-background: var(--vscode-button-hoverBackground); + + /* Border Transparency */ + --app-transparent-inner-border: rgba(255, 255, 255, 0.1); + + /* Header */ + --app-header-background: var(--vscode-sideBar-background); + + /* List Styles*/ + --app-list-padding: 0px; + --app-list-item-padding: 4px 8px; + --app-list-border-color: transparent; + --app-list-border-radius: 4px; + --app-list-gap: 2px; + + /* Menu Colors*/ + --app-menu-background: var(--vscode-menu-background); + --app-menu-border: var(--vscode-menu-border); + --app-menu-foreground: var(--vscode-menu-foreground); + --app-menu-selection-background: var(--vscode-menu-selectionBackground); + --app-menu-selection-foreground: var(--vscode-menu-selectionForeground); + + /* Modal */ + --app-modal-background: rgba(0, 0, 0, 0.75); + + /* Widget */ + --app-widget-border: var(--vscode-editorWidget-border); + --app-widget-shadow: var(--vscode-widget-shadow); +} + +/* Light Theme Overrides */ +.vscode-light { + --app-transparent-inner-border: rgba(0, 0, 0, 0.07); + /* Slightly different brand shade in light theme for better contrast */ + --app-input-highlight: var(--app-qwen-clay-button-orange); +} + +/* Icon SVG styles */ +.icon-svg { + display: block; +} + +* { + margin: 0; + padding: 0; + box-sizing: border-box; +} + +body { + font-family: var(--vscode-chat-font-family, var(--vscode-font-family)); + background-color: var(--app-primary-background); + color: var(--app-primary-foreground); + overflow: hidden; + font-size: var(--vscode-chat-font-size, 13px); + padding: 0; +} + +/* Ensure tool call containers keep a consistent left indent even if Tailwind utilities are purged */ +.toolcall-container { + /* Consistent indent for tool call blocks */ + padding-left: 30px; +} + +.toolcall-card { + /* Consistent indent for card-style tool calls */ + padding-left: 30px; +} + +button { + color: var(--app-primary-foreground); + font-family: var(--vscode-chat-font-family); + font-size: var(--vscode-chat-font-size, 13px); +} + +/* =========================== + Main Chat Container + =========================== */ +.chat-container { + display: flex; + flex-direction: column; + height: 100vh; + width: 100%; + background-color: var(--app-primary-background); + color: var(--app-primary-foreground); +} + +/* Message list container: prevent browser scroll anchoring from fighting our manual pin-to-bottom logic */ +.chat-messages > * { + /* Disable overflow anchoring on individual items so the UA doesn't auto-adjust scroll */ + overflow-anchor: none; +} + +/* =========================== + Animations (used by message components) + =========================== */ +@keyframes fadeIn { + from { + opacity: 0; + transform: translateY(10px); + } + to { + opacity: 1; + transform: translateY(0); + } +} + +@keyframes pulse { + 0%, + 100% { + opacity: 1; + } + 50% { + opacity: 0.3; + } +} + +@keyframes typingPulse { + 0%, + 60%, + 100% { + transform: scale(0.7); + opacity: 0.6; + } + 30% { + transform: scale(1); + opacity: 1; + } +} + +/* =========================== + Input Form Styles + =========================== */ +.input-form { + display: flex; + background-color: var(--app-primary-background); + border-top: 1px solid var(--app-primary-border-color); +} + +.input-field { + flex: 1; + padding: 10px 12px; + background-color: var(--app-input-background); + color: var(--app-input-foreground); + border: 1px solid var(--app-input-border); + border-radius: var(--corner-radius-small); + font-size: var(--vscode-chat-font-size, 13px); + font-family: var(--vscode-chat-font-family); + outline: none; + line-height: 1.5; +} + +.input-field:focus { + border-color: var(--app-qwen-theme); +} + +.input-field:disabled { + opacity: 0.5; + cursor: not-allowed; +} + +.input-field::placeholder { + color: var(--app-input-placeholder-foreground); +} + +.send-button { + padding: 10px 20px; + background-color: var(--app-qwen-clay-button-orange); + color: var(--app-qwen-ivory); + border: none; + border-radius: var(--corner-radius-small); + font-size: var(--vscode-chat-font-size, 13px); + font-weight: 500; + cursor: pointer; + transition: filter 0.15s ease; + display: flex; + align-items: center; + justify-content: center; +} + +.send-button:hover:not(:disabled) { + filter: brightness(1.1); +} + +.send-button:active:not(:disabled) { + filter: brightness(0.9); +} + +.send-button:disabled { + opacity: 0.5; + cursor: not-allowed; +} + +/* Animation for in-progress status (used by pseudo bullets and spinners) */ +@keyframes pulse { + 0%, + 100% { + opacity: 1; + } + 50% { + opacity: 0.5; + } +} + +.code-block { + font-family: var(--app-monospace-font-family); + font-size: var(--app-monospace-font-size); + background: var(--app-primary-background); + border: 1px solid var(--app-input-border); + border-radius: var(--corner-radius-small); + padding: var(--app-spacing-medium); + overflow-x: auto; + margin: 4px 0 0 0; + white-space: pre-wrap; + word-break: break-word; + max-height: 300px; + overflow-y: auto; +} + +/* =========================== + Diff Display Styles + =========================== */ +.diff-display-container { + margin: 8px 0; + border: 1px solid var(--app-input-border); + border-radius: var(--corner-radius-medium); + overflow: hidden; +} + +.diff-header { + display: flex; + align-items: center; + justify-content: space-between; + padding: 8px 12px; + background: var(--app-input-secondary-background); + border-bottom: 1px solid var(--app-input-border); +} + +.diff-file-path { + font-family: var(--app-monospace-font-family); + font-size: 13px; + color: var(--app-primary-foreground); +} + +.open-diff-button { + display: flex; + align-items: center; + gap: 6px; + padding: 4px 8px; + background: transparent; + border: 1px solid var(--app-input-border); + border-radius: var(--corner-radius-small); + color: var(--app-primary-foreground); + cursor: pointer; + font-size: 12px; + transition: background-color 0.15s; +} + +.open-diff-button:hover { + background: var(--app-ghost-button-hover-background); +} + +.open-diff-button svg { + width: 16px; + height: 16px; +} + +.diff-section { + margin: 0; +} + +.diff-label { + padding: 8px 12px; + background: var(--app-primary-background); + border-bottom: 1px solid var(--app-input-border); + font-size: 11px; + font-weight: 600; + color: var(--app-secondary-foreground); + text-transform: uppercase; +} + +.diff-section .code-block { + border: none; + border-radius: 0; + margin: 0; + max-height: none; /* Remove height limit for diffs */ + overflow-y: visible; +} + +.diff-section .code-content { + display: block; +} + +/* =========================== + Permission Request Card Styles + =========================== */ +.permission-request-card { + background: var(--app-input-background); + border: 1px solid var(--app-qwen-theme); + border-radius: var(--corner-radius-medium); + margin: var(--app-spacing-medium) 0; + margin-bottom: var(--app-spacing-xlarge); + overflow: visible; + animation: fadeIn 0.2s ease-in; +} + +.permission-card-body { + padding: var(--app-spacing-large); + min-height: fit-content; + height: auto; +} + +.permission-header { + display: flex; + align-items: center; + gap: var(--app-spacing-large); + margin-bottom: var(--app-spacing-large); +} + +.permission-icon-wrapper { + width: 40px; + height: 40px; + display: flex; + align-items: center; + justify-content: center; + background: rgba(97, 95, 255, 0.1); + border-radius: var(--corner-radius-medium); + flex-shrink: 0; +} + +.permission-icon { + font-size: 20px; +} + +.permission-info { + flex: 1; + min-width: 0; +} + +.permission-title { + font-weight: 600; + color: var(--app-primary-foreground); + margin-bottom: 2px; +} + +.permission-subtitle { + font-size: 12px; + color: var(--app-secondary-foreground); +} + +.permission-command-section { + margin-bottom: var(--app-spacing-large); +} + +.permission-command-label { + font-size: 11px; + font-weight: 600; + color: var(--app-secondary-foreground); + margin-bottom: var(--app-spacing-small); + text-transform: uppercase; +} + +.permission-command-code { + display: block; + font-family: var(--app-monospace-font-family); + font-size: var(--app-monospace-font-size); + color: var(--app-primary-foreground); + background: var(--app-primary-background); + padding: var(--app-spacing-medium); + border-radius: var(--corner-radius-small); + overflow-x: auto; + white-space: pre-wrap; + word-break: break-word; +} + +.permission-locations-section { + margin-bottom: var(--app-spacing-large); +} + +.permission-locations-label { + font-size: 11px; + font-weight: 600; + color: var(--app-secondary-foreground); + margin-bottom: var(--app-spacing-small); + text-transform: uppercase; +} + +.permission-location-item { + display: flex; + align-items: center; + gap: var(--app-spacing-small); + padding: var(--app-spacing-small) 0; + font-size: 12px; +} + +.permission-location-icon { + flex-shrink: 0; +} + +.permission-location-path { + color: var(--app-primary-foreground); + font-family: var(--app-monospace-font-family); +} + +.permission-location-line { + color: var(--app-secondary-foreground); +} + +.permission-options-section { + margin-top: var(--app-spacing-large); +} + +.permission-options-label { + font-size: 12px; + font-weight: 500; + color: var(--app-primary-foreground); + margin-bottom: var(--app-spacing-medium); +} + +.permission-options-list { + display: flex; + flex-direction: column; + gap: var(--app-spacing-small); +} + +.permission-option { + display: flex; + align-items: center; + gap: var(--app-spacing-medium); + padding: var(--app-spacing-medium) var(--app-spacing-large); + background: var(--app-primary-background); + border: 1px solid var(--app-input-border); + border-radius: var(--corner-radius-small); + cursor: pointer; + transition: all 0.15s ease; +} + +.permission-option:hover { + background: var(--app-list-hover-background); + border-color: var(--app-input-active-border); +} + +.permission-option.selected { + border-color: var(--app-qwen-theme); + background: rgba(97, 95, 255, 0.1); +} + +.permission-radio { + flex-shrink: 0; +} + +.permission-option-content { + display: flex; + align-items: center; + gap: var(--app-spacing-small); + flex: 1; +} + +.permission-option-number { + display: inline-flex; + align-items: center; + justify-content: center; + min-width: 20px; + height: 20px; + padding: 0 6px; + font-size: 11px; + font-weight: 600; + color: var(--app-secondary-foreground); + background-color: var(--app-list-hover-background); + border-radius: 4px; + margin-right: 4px; +} + +.permission-option.selected .permission-option-number { + color: var(--app-qwen-ivory); + background-color: var(--app-qwen-theme); +} + +.permission-always-badge { + font-size: 12px; +} + +.permission-no-options { + text-align: center; + padding: var(--app-spacing-large); + color: var(--app-secondary-foreground); +} + +.permission-actions { + margin-top: var(--app-spacing-large); + display: flex; + justify-content: flex-end; +} + +.permission-confirm-button { + padding: var(--app-spacing-medium) var(--app-spacing-xlarge); + background: var(--app-qwen-clay-button-orange); + color: var(--app-qwen-ivory); + border: none; + border-radius: var(--corner-radius-small); + font-size: 13px; + font-weight: 500; + cursor: pointer; + transition: filter 0.15s ease; +} + +.permission-confirm-button:hover:not(:disabled) { + filter: brightness(1.1); +} + +.permission-confirm-button:disabled { + opacity: 0.5; + cursor: not-allowed; +} + +.permission-success { + display: flex; + align-items: center; + justify-content: center; + gap: var(--app-spacing-medium); + padding: var(--app-spacing-large); + background: rgba(76, 175, 80, 0.1); + border-radius: var(--corner-radius-small); + margin-top: var(--app-spacing-large); +} + +.permission-success-icon { + color: #4caf50; + font-weight: bold; +} + +.permission-success-text { + color: #4caf50; + font-size: 13px; +} diff --git a/packages/vscode-ide-companion/src/webview/styles/styles.css b/packages/vscode-ide-companion/src/webview/styles/styles.css new file mode 100644 index 000000000..956912cbc --- /dev/null +++ b/packages/vscode-ide-companion/src/webview/styles/styles.css @@ -0,0 +1,58 @@ +/** + * @license + * Copyright 2025 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + */ + +/* Import component styles */ +@import './timeline.css'; +@import '../components/messages/MarkdownRenderer/MarkdownRenderer.css'; + +/* =========================== + CSS Variables + =========================== */ +:root { + /* Colors */ + --app-primary-foreground: var(--vscode-foreground); + --app-secondary-foreground: var(--vscode-descriptionForeground); + --app-primary-border-color: var(--vscode-panel-border); + --app-input-placeholder-foreground: var(--vscode-input-placeholderForeground); + + /* Buttons */ + --app-ghost-button-hover-background: var(--vscode-toolbar-hoverBackground); + + /* Border Radius */ + --corner-radius-small: 6px; + + /* Header */ + --app-header-background: var(--vscode-sideBar-background); + + /* List Styles */ + --app-list-padding: 0px; + --app-list-item-padding: 4px 8px; + --app-list-border-color: transparent; + --app-list-border-radius: 4px; + --app-list-hover-background: var(--vscode-list-hoverBackground); + --app-list-active-background: var(--vscode-list-activeSelectionBackground); + --app-list-active-foreground: var(--vscode-list-activeSelectionForeground); + --app-list-gap: 2px; + + /* Menu Styles */ + --app-menu-background: var(--vscode-menu-background); + --app-menu-border: var(--vscode-menu-border); + --app-menu-foreground: var(--vscode-menu-foreground); + --app-menu-selection-background: var(--vscode-menu-selectionBackground); + --app-menu-selection-foreground: var(--vscode-menu-selectionForeground); + + /* Tool Call Styles */ + --app-tool-background: var(--vscode-editor-background); + --app-code-background: var(--vscode-textCodeBlock-background); + + /* Warning/Error Styles */ + --app-warning-background: var( + --vscode-editorWarning-background, + rgba(255, 204, 0, 0.1) + ); + --app-warning-border: var(--vscode-editorWarning-foreground, #ffcc00); + --app-warning-foreground: var(--vscode-editorWarning-foreground, #ffcc00); +} diff --git a/packages/vscode-ide-companion/src/webview/styles/tailwind.css b/packages/vscode-ide-companion/src/webview/styles/tailwind.css new file mode 100644 index 000000000..5c9955b39 --- /dev/null +++ b/packages/vscode-ide-companion/src/webview/styles/tailwind.css @@ -0,0 +1,195 @@ +/** + * @license + * Copyright 2025 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + */ + +@tailwind base; +@tailwind components; +@tailwind utilities; + +/* =========================== + Reusable Component Classes + =========================== */ +@layer components { + .btn-ghost { + @apply bg-transparent border border-transparent rounded cursor-pointer outline-none transition-colors duration-200; + color: var(--app-primary-foreground); + font-size: var(--vscode-chat-font-size, 13px); + border-radius: 4px; + } + + .btn-ghost:hover, + .btn-ghost:focus { + background: var(--app-ghost-button-hover-background); + } + + .btn-sm { + @apply p-small; + } + + .btn-md { + @apply py-small px-medium; + } + + .icon-sm { + @apply w-4 h-4; + } + + /* Composer: root container anchored to bottom*/ + .composer-root { + @apply absolute bottom-4 left-4 right-4 flex flex-col z-20; + } + + /* Composer: form wrapper */ + .composer-form { + @apply relative flex flex-col max-w-[680px] mx-auto rounded-large border shadow-sm transition-colors duration-200 z-[1]; + background: var(--app-input-secondary-background); + border-color: var(--app-input-border); + color: var(--app-input-foreground); + } + .composer-form:focus-within { + /* match existing highlight behavior */ + border-color: var(--app-input-highlight); + box-shadow: 0 1px 2px + color-mix(in srgb, var(--app-input-highlight), transparent 80%); + } + + /* Composer: input editable area */ + .composer-input { + /* Use plain CSS for font-family inheritance; Tailwind has no `font-inherit` utility */ + @apply flex-1 self-stretch py-2.5 px-3.5 outline-none overflow-y-auto relative select-text min-h-[1.5em] max-h-[200px] bg-transparent border-0 rounded-none overflow-x-hidden break-words whitespace-pre-wrap; + font-family: inherit; + font-size: var(--vscode-chat-font-size, 13px); + color: var(--app-input-foreground); + } + /* Show placeholder when truly empty OR when flagged as empty via data attribute. + The data attribute is needed because some browsers insert a
in + contentEditable, which breaks :empty matching. */ + .composer-input:empty:before, + .composer-input[data-empty='true']::before { + content: attr(data-placeholder); + color: var(--app-input-placeholder-foreground); + pointer-events: none; + position: absolute; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + max-width: calc(100% - 28px); + } + .composer-input:focus { + outline: none; + } + .composer-input:disabled, + .composer-input[contenteditable='false'] { + color: #999; + cursor: not-allowed; + } + + /* Composer: actions row (more compact) */ + .composer-actions { + @apply flex items-center gap-1 min-w-0 z-[1]; + padding: 5px; + color: var(--app-secondary-foreground); + border-top: 0.5px solid var(--app-input-border); + } + + /* Text button (icon + label) */ + .btn-text-compact { + @apply inline-flex items-center gap-1 px-1 py-0.5 rounded-[2px] cursor-pointer appearance-none bg-transparent border-0 min-w-0 shrink text-[0.85em] transition-colors duration-150; + color: var(--app-secondary-foreground); + } + .btn-text-compact--primary { + color: var(--app-secondary-foreground); + /* color: var(--app-primary-foreground); */ + } + .btn-text-compact:hover { + background-color: var(--app-ghost-button-hover-background); + } + .btn-text-compact:active:not(:disabled) { + filter: brightness(1.1); + } + .btn-text-compact > svg { + height: 1em; + width: 1em; + flex-shrink: 0; + } + .btn-text-compact > span { + display: inline-block; + min-width: 0; + max-width: 200px; + overflow: hidden; + white-space: nowrap; + text-overflow: ellipsis; + vertical-align: middle; + } + + @media screen and (max-width: 300px) { + .btn-text-compact > svg { + display: none; + } + } + + /* Icon-only button, compact square (26x26) */ + .btn-icon-compact { + @apply inline-flex items-center justify-center w-[26px] h-[26px] p-0 rounded-small bg-transparent border border-transparent cursor-pointer shrink-0 transition-all duration-150; + color: var(--app-secondary-foreground); + } + .btn-icon-compact:hover { + background-color: var(--app-ghost-button-hover-background); + } + .btn-icon-compact > svg { + @apply w-4 h-4; + } + /* Active/primary state for icon button (e.g., Thinking on) */ + .btn-icon-compact--active { + background-color: var(--app-qwen-clay-button-orange); + color: var(--app-qwen-ivory); + } + .btn-icon-compact--active > svg { + stroke: var(--app-qwen-ivory); + fill: var(--app-qwen-ivory); + } + + .composer-overlay { + @apply absolute inset-0 rounded-large z-0; + background: var(--app-input-background); + } + + /* Optional: send button variant */ + .btn-send-compact { + @apply btn-icon-compact ml-auto hover:brightness-110 disabled:opacity-40 disabled:cursor-not-allowed; + background-color: var(--app-qwen-clay-button-orange); + color: var(--app-qwen-ivory); + } + + /* + * File path styling inside tool call content + * Applies to: .toolcall-content-wrapper .file-link-path + * - Use monospace editor font + * - Slightly smaller size + * - Link color + * - Tighten top alignment and allow aggressive breaking for long paths + */ + .toolcall-content-wrapper .file-link-path { + /* Tailwind utilities where possible */ + @apply text-[0.85em] pt-px break-all min-w-0; + /* Not covered by Tailwind defaults: use CSS vars / properties */ + font-family: var(--app-monospace-font-family); + color: var(--app-link-color); + overflow-wrap: anywhere; + } +} + +/* =========================== + Utilities + =========================== */ +@layer utilities { + /* Multi-line clamp with ellipsis (Chromium-based webview supported) */ + .q-line-clamp-3 { + display: -webkit-box; + -webkit-box-orient: vertical; + -webkit-line-clamp: 3; + overflow: hidden; + } +} diff --git a/packages/vscode-ide-companion/src/webview/styles/timeline.css b/packages/vscode-ide-companion/src/webview/styles/timeline.css new file mode 100644 index 000000000..033e82d22 --- /dev/null +++ b/packages/vscode-ide-companion/src/webview/styles/timeline.css @@ -0,0 +1,142 @@ +/** + * @license + * Copyright 2025 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + * + * Unified timeline styles for tool calls and messages + */ + +/* ========================================== + ToolCallContainer timeline styles + ========================================== */ +.toolcall-container { + position: relative; + padding-left: 30px; + padding-top: 8px; + padding-bottom: 8px; +} + +/* ToolCallContainer timeline connector */ +.toolcall-container::after { + content: ''; + position: absolute; + left: 12px; + top: 0; + bottom: 0; + width: 1px; + background-color: var(--app-primary-border-color); +} + +/* First item: connector starts from status point position */ +.toolcall-container:first-child::after { + top: 24px; +} + +/* Last item: connector shows only upper part */ +.toolcall-container:last-child::after { + height: calc(100% - 24px); + top: 0; + bottom: auto; +} + +/* ========================================== + AssistantMessage timeline styles + ========================================== */ +.assistant-message-container { + position: relative; + padding-left: 30px; + padding-top: 8px; + padding-bottom: 8px; +} + +/* AssistantMessage timeline connector */ +.assistant-message-container::after { + content: ''; + position: absolute; + left: 12px; + top: 0; + bottom: 0; + width: 1px; + background-color: var(--app-primary-border-color); +} + +/* First item: connector starts from status point position */ +.assistant-message-container:first-child::after { + top: 24px; +} + +/* Last item: connector shows only upper part */ +.assistant-message-container:last-child::after { + height: calc(100% - 24px); + top: 0; + bottom: auto; +} + +/* ========================================== + Custom timeline styles for qwen-message message-item elements + ========================================== */ + +/* Default connector style - creates full-height connectors for all AI message items */ +.qwen-message.message-item:not(.user-message-container)::after { + content: ''; + position: absolute; + left: 12px; + top: 0; + bottom: 0; + width: 1px; + background-color: var(--app-primary-border-color); + z-index: 0; +} + +/* Single-item AI sequence (both a start and an end): hide the connector entirely */ +.qwen-message.message-item:not(.user-message-container):is( + :first-child, + .user-message-container + + .qwen-message.message-item:not(.user-message-container), + .chat-messages + > :not(.qwen-message.message-item) + + .qwen-message.message-item:not(.user-message-container) + ):is( + :has(+ .user-message-container), + :has(+ :not(.qwen-message.message-item)), + :last-child + )::after { + display: none; +} + +/* Handle the start of each AI message sequence - includes the first AI message in the entire message list and new AI messages interrupted by user messages */ +.qwen-message.message-item:not(.user-message-container):first-child::after, +.user-message-container + .qwen-message.message-item:not(.user-message-container)::after, +/* If the previous sibling is not .qwen-message.message-item (such as waiting prompts, sentinel elements, or card-style tool calls), also treat as a new group start */ +.chat-messages > :not(.qwen-message.message-item) + + .qwen-message.message-item:not(.user-message-container)::after { + top: 15px; +} + +/* Handle the end of each AI message sequence */ +/* When the next sibling is a user message */ +.qwen-message.message-item:not(.user-message-container):has(+ .user-message-container)::after, +/* Or when the next sibling is not .qwen-message.message-item (such as waiting prompts, sentinel elements, card-style tool calls, etc.) */ +.qwen-message.message-item:not(.user-message-container):has(+ :not(.qwen-message.message-item))::after, +/* When it's truly the last child element of the parent container */ +.qwen-message.message-item:not(.user-message-container):last-child::after { + /* Note: When setting both top and bottom, the height is (container height - top - bottom). + * Here we expect "15px spacing at the bottom", so bottom should be 15px (not calc(100% - 15px)). */ + top: 0; + bottom: calc(100% - 15px); +} + +.user-message-container:first-child { + margin-top: 0; +} + +.message-item { + padding: 8px 0; + width: 100%; + align-items: flex-start; + padding-left: 30px; + user-select: text; + position: relative; + padding-top: 8px; + padding-bottom: 8px; +} diff --git a/packages/vscode-ide-companion/src/webview/utils/diffStats.ts b/packages/vscode-ide-companion/src/webview/utils/diffStats.ts new file mode 100644 index 000000000..78918821b --- /dev/null +++ b/packages/vscode-ide-companion/src/webview/utils/diffStats.ts @@ -0,0 +1,160 @@ +/** + * @license + * Copyright 2025 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + * + * Diff statistics calculation tool + */ + +/** + * Diff statistics + */ +export interface DiffStats { + /** Number of added lines */ + added: number; + /** Number of removed lines */ + removed: number; + /** Number of changed lines (estimated value) */ + changed: number; + /** Total number of changed lines */ + total: number; +} + +/** + * Calculate diff statistics between two texts + * + * Using a simple line comparison algorithm (avoiding heavy-weight diff libraries) + * Algorithm explanation: + * 1. Split text by lines + * 2. Compare set differences of lines + * 3. Estimate changed lines (lines that appear in both added and removed) + * + * @param oldText Old text content + * @param newText New text content + * @returns Diff statistics + * + * @example + * ```typescript + * const stats = calculateDiffStats( + * "line1\nline2\nline3", + * "line1\nline2-modified\nline4" + * ); + * // { added: 2, removed: 2, changed: 1, total: 3 } + * ``` + */ +export function calculateDiffStats( + oldText: string | null | undefined, + newText: string | undefined, +): DiffStats { + // Handle null values + const oldContent = oldText || ''; + const newContent = newText || ''; + + // Split by lines + const oldLines = oldContent.split('\n').filter((line) => line.trim() !== ''); + const newLines = newContent.split('\n').filter((line) => line.trim() !== ''); + + // If one of them is empty, calculate directly + if (oldLines.length === 0) { + return { + added: newLines.length, + removed: 0, + changed: 0, + total: newLines.length, + }; + } + + if (newLines.length === 0) { + return { + added: 0, + removed: oldLines.length, + changed: 0, + total: oldLines.length, + }; + } + + // Use Set for fast lookup + const oldSet = new Set(oldLines); + const newSet = new Set(newLines); + + // Calculate added: lines in new but not in old + const addedLines = newLines.filter((line) => !oldSet.has(line)); + + // Calculate removed: lines in old but not in new + const removedLines = oldLines.filter((line) => !newSet.has(line)); + + // Estimate changes: take the minimum value (because changed lines are both deleted and added) + // This is a simplified estimation, actual diff algorithms would be more precise + const estimatedChanged = Math.min(addedLines.length, removedLines.length); + + const added = addedLines.length - estimatedChanged; + const removed = removedLines.length - estimatedChanged; + const changed = estimatedChanged; + + return { + added, + removed, + changed, + total: added + removed + changed, + }; +} + +/** + * Format diff statistics as human-readable text + * + * @param stats Diff statistics + * @returns Formatted text, e.g. "+5 -3 ~2" + * + * @example + * ```typescript + * formatDiffStats({ added: 5, removed: 3, changed: 2, total: 10 }); + * // "+5 -3 ~2" + * ``` + */ +export function formatDiffStats(stats: DiffStats): string { + const parts: string[] = []; + + if (stats.added > 0) { + parts.push(`+${stats.added}`); + } + + if (stats.removed > 0) { + parts.push(`-${stats.removed}`); + } + + if (stats.changed > 0) { + parts.push(`~${stats.changed}`); + } + + return parts.join(' ') || 'No changes'; +} + +/** + * Format detailed diff statistics + * + * @param stats Diff statistics + * @returns Detailed description text + * + * @example + * ```typescript + * formatDiffStatsDetailed({ added: 5, removed: 3, changed: 2, total: 10 }); + * // "+5 lines, -3 lines, ~2 lines" + * ``` + */ +export function formatDiffStatsDetailed(stats: DiffStats): string { + const parts: string[] = []; + + if (stats.added > 0) { + parts.push(`+${stats.added} ${stats.added === 1 ? 'line' : 'lines'}`); + } + + if (stats.removed > 0) { + parts.push(`-${stats.removed} ${stats.removed === 1 ? 'line' : 'lines'}`); + } + + if (stats.changed > 0) { + parts.push(`~${stats.changed} ${stats.changed === 1 ? 'line' : 'lines'}`); + } + + return parts.join(', ') || 'No changes'; +} diff --git a/packages/vscode-ide-companion/src/webview/utils/diffUtils.ts b/packages/vscode-ide-companion/src/webview/utils/diffUtils.ts new file mode 100644 index 000000000..dac37cf3f --- /dev/null +++ b/packages/vscode-ide-companion/src/webview/utils/diffUtils.ts @@ -0,0 +1,30 @@ +/** + * @license + * Copyright 2025 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + * + * Shared utilities for handling diff operations in the webview + */ + +import type { VSCodeAPI } from '../hooks/useVSCode.js'; + +/** + * Handle opening a diff view for a file + * @param vscode Webview API instance + * @param path File path + * @param oldText Original content (left side) + * @param newText New content (right side) + */ +export const handleOpenDiff = ( + vscode: VSCodeAPI, + path: string | undefined, + oldText: string | null | undefined, + newText: string | undefined, +): void => { + if (path) { + vscode.postMessage({ + type: 'openDiff', + data: { path, oldText: oldText || '', newText: newText || '' }, + }); + } +}; diff --git a/packages/vscode-ide-companion/src/webview/utils/resourceUrl.ts b/packages/vscode-ide-companion/src/webview/utils/resourceUrl.ts new file mode 100644 index 000000000..d55d4e14e --- /dev/null +++ b/packages/vscode-ide-companion/src/webview/utils/resourceUrl.ts @@ -0,0 +1,115 @@ +/** + * @license + * Copyright 2025 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + */ + +// Extend Window interface to include __EXTENSION_URI__ +declare global { + interface Window { + __EXTENSION_URI__?: string; + } +} + +/** + * Get the extension URI from the body data attribute or window global + * @returns Extension URI or undefined if not found + */ +function getExtensionUri(): string | undefined { + // First try to get from window (for backwards compatibility) + if (window.__EXTENSION_URI__) { + return window.__EXTENSION_URI__; + } + + // Then try to get from body data attribute (CSP-compliant method) + const bodyUri = document.body?.getAttribute('data-extension-uri'); + if (bodyUri) { + // Cache it in window for future use + window.__EXTENSION_URI__ = bodyUri; + return bodyUri; + } + + return undefined; +} + +/** + * Validate if URL is a secure VS Code webview resource URL + * Prevent XSS attacks + * + * @param url - URL to validate + * @returns Whether it is a secure URL + */ +function isValidWebviewUrl(url: string): boolean { + try { + // Valid protocols for VS Code webview resource URLs + const allowedProtocols = [ + 'vscode-webview-resource:', + 'https-vscode-webview-resource:', + 'vscode-file:', + 'https:', + ]; + + // Check if it starts with a valid protocol + return allowedProtocols.some((protocol) => url.startsWith(protocol)); + } catch { + return false; + } +} + +/** + * Generate a resource URL for webview access + * Similar to the pattern used in other VSCode extensions + * + * @param relativePath - Relative path from extension root (e.g., 'assets/icon.png') + * @returns Full webview-accessible URL (empty string if validation fails) + * + * @example + * ```tsx + * + * ``` + */ +export function generateResourceUrl(relativePath: string): string { + const extensionUri = getExtensionUri(); + + if (!extensionUri) { + console.warn('[resourceUrl] Extension URI not found in window or body'); + return ''; + } + + // Validate if extensionUri is a secure URL + if (!isValidWebviewUrl(extensionUri)) { + console.error( + '[resourceUrl] Invalid extension URI - possible security risk:', + extensionUri, + ); + return ''; + } + + // Remove leading slash if present + const cleanPath = relativePath.startsWith('/') + ? relativePath.slice(1) + : relativePath; + + // Ensure extension URI has trailing slash + const baseUri = extensionUri.endsWith('/') + ? extensionUri + : `${extensionUri}/`; + + const fullUrl = `${baseUri}${cleanPath}`; + + // Validate if the final generated URL is secure + if (!isValidWebviewUrl(fullUrl)) { + console.error('[resourceUrl] Generated URL failed validation:', fullUrl); + return ''; + } + + return fullUrl; +} + +/** + * Shorthand for generating icon URLs + * @param iconPath - Path relative to assets directory + */ +export function generateIconUrl(iconPath: string): string { + return generateResourceUrl(`assets/${iconPath}`); +} diff --git a/packages/vscode-ide-companion/src/webview/utils/sessionGrouping.ts b/packages/vscode-ide-companion/src/webview/utils/sessionGrouping.ts new file mode 100644 index 000000000..e11f4bcef --- /dev/null +++ b/packages/vscode-ide-companion/src/webview/utils/sessionGrouping.ts @@ -0,0 +1,99 @@ +/** + * @license + * Copyright 2025 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + */ + +export interface SessionGroup { + label: string; + sessions: Array>; +} + +/** + * Group sessions by date + * + * @param sessions - Array of session objects + * @returns Array of grouped sessions + */ +export const groupSessionsByDate = ( + sessions: Array>, +): SessionGroup[] => { + const now = new Date(); + const today = new Date(now.getFullYear(), now.getMonth(), now.getDate()); + const yesterday = new Date(today); + yesterday.setDate(yesterday.getDate() - 1); + + const groups: { + [key: string]: Array>; + } = { + Today: [], + Yesterday: [], + 'This Week': [], + Older: [], + }; + + sessions.forEach((session) => { + const timestamp = + (session.lastUpdated as string) || (session.startTime as string) || ''; + if (!timestamp) { + groups['Older'].push(session); + return; + } + + const sessionDate = new Date(timestamp); + const sessionDay = new Date( + sessionDate.getFullYear(), + sessionDate.getMonth(), + sessionDate.getDate(), + ); + + if (sessionDay.getTime() === today.getTime()) { + groups['Today'].push(session); + } else if (sessionDay.getTime() === yesterday.getTime()) { + groups['Yesterday'].push(session); + } else if (sessionDay.getTime() > today.getTime() - 7 * 86400000) { + groups['This Week'].push(session); + } else { + groups['Older'].push(session); + } + }); + + return Object.entries(groups) + .filter(([, sessions]) => sessions.length > 0) + .map(([label, sessions]) => ({ label, sessions })); +}; + +/** + * Time ago formatter + * + * @param timestamp - ISO timestamp string + * @returns Formatted time string + */ +export const getTimeAgo = (timestamp: string): string => { + if (!timestamp) { + return ''; + } + const now = new Date().getTime(); + const then = new Date(timestamp).getTime(); + const diffMs = now - then; + const diffMins = Math.floor(diffMs / 60000); + const diffHours = Math.floor(diffMs / 3600000); + const diffDays = Math.floor(diffMs / 86400000); + + if (diffMins < 1) { + return 'now'; + } + if (diffMins < 60) { + return `${diffMins}m`; + } + if (diffHours < 24) { + return `${diffHours}h`; + } + if (diffDays === 1) { + return 'Yesterday'; + } + if (diffDays < 7) { + return `${diffDays}d`; + } + return new Date(timestamp).toLocaleDateString(); +}; diff --git a/packages/vscode-ide-companion/src/webview/utils/tempFileManager.ts b/packages/vscode-ide-companion/src/webview/utils/tempFileManager.ts new file mode 100644 index 000000000..8ab17e301 --- /dev/null +++ b/packages/vscode-ide-companion/src/webview/utils/tempFileManager.ts @@ -0,0 +1,33 @@ +/** + * @license + * Copyright 2025 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + * + * Temporary file manager for creating and opening temporary files in webview + */ + +/** + * Creates a temporary file with the given content and opens it in VS Code + * @param content The content to write to the temporary file + * @param fileName Optional file name (without extension) + * @param fileExtension Optional file extension (defaults to .txt) + */ +export async function createAndOpenTempFile( + postMessage: (message: { + type: string; + data: Record; + }) => void, + content: string, + fileName: string = 'temp', + fileExtension: string = '.txt', +): Promise { + // Send message to VS Code extension to create and open temp file + postMessage({ + type: 'createAndOpenTempFile', + data: { + content, + fileName, + fileExtension, + }, + }); +} diff --git a/packages/vscode-ide-companion/src/webview/utils/webviewUtils.ts b/packages/vscode-ide-companion/src/webview/utils/webviewUtils.ts new file mode 100644 index 000000000..ed1b3135b --- /dev/null +++ b/packages/vscode-ide-companion/src/webview/utils/webviewUtils.ts @@ -0,0 +1,31 @@ +/** + * @license + * Copyright 2025 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + */ + +/** + * Extract filename from full path + * @param fsPath Full path of the file + * @returns Filename (without path) + */ +export function getFileName(fsPath: string): string { + // Use path.basename logic: find the part after the last path separator + const lastSlash = Math.max(fsPath.lastIndexOf('/'), fsPath.lastIndexOf('\\')); + return lastSlash >= 0 ? fsPath.substring(lastSlash + 1) : fsPath; +} + +/** + * HTML escape function to prevent XSS attacks + * Convert special characters to HTML entities + * @param text Text to escape + * @returns Escaped text + */ +export function escapeHtml(text: string): string { + return text + .replace(/&/g, '&') + .replace(//g, '>') + .replace(/"/g, '"') + .replace(/'/g, '''); +} diff --git a/packages/vscode-ide-companion/tailwind.config.js b/packages/vscode-ide-companion/tailwind.config.js new file mode 100644 index 000000000..956f785cf --- /dev/null +++ b/packages/vscode-ide-companion/tailwind.config.js @@ -0,0 +1,63 @@ +/** + * @license + * Copyright 2025 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + */ + +/* eslint-env node */ +/** @type {import('tailwindcss').Config} */ +export default { + content: ['./src/webview/**/**/*.{js,jsx,ts,tsx}'], + theme: { + extend: { + keyframes: { + // CompletionMenu mount animation: fade in + slight upward slide + 'completion-menu-enter': { + '0%': { opacity: '0', transform: 'translateY(4px)' }, + '100%': { opacity: '1', transform: 'translateY(0)' }, + }, + // Pulse animation for in-progress tool calls + 'pulse-slow': { + '0%, 100%': { opacity: '1' }, + '50%': { opacity: '0.5' }, + }, + // PermissionDrawer enter animation: slide up from bottom + 'slide-up': { + '0%': { transform: 'translateY(100%)', opacity: '0' }, + '100%': { transform: 'translateY(0)', opacity: '1' }, + }, + }, + animation: { + 'completion-menu-enter': 'completion-menu-enter 150ms ease-out both', + 'pulse-slow': 'pulse-slow 1.5s ease-in-out infinite', + 'slide-up': 'slide-up 200ms ease-out both', + }, + colors: { + qwen: { + orange: '#615fff', + 'clay-orange': '#4f46e5', + ivory: '#f5f5ff', + slate: '#141420', + green: '#6bcf7f', + // Status colors used by toolcall components + success: '#74c991', + error: '#c74e39', + warning: '#e1c08d', + loading: 'var(--app-secondary-foreground)', + }, + }, + borderRadius: { + small: '4px', + medium: '6px', + large: '8px', + }, + spacing: { + small: '4px', + medium: '8px', + large: '12px', + xlarge: '16px', + }, + }, + }, + plugins: [], +}; diff --git a/packages/vscode-ide-companion/tsconfig.json b/packages/vscode-ide-companion/tsconfig.json index 02a9b53f5..538ec461f 100644 --- a/packages/vscode-ide-companion/tsconfig.json +++ b/packages/vscode-ide-companion/tsconfig.json @@ -4,6 +4,8 @@ "moduleResolution": "NodeNext", "target": "ES2022", "lib": ["ES2022", "dom"], + "jsx": "react-jsx", + "jsxImportSource": "react", "sourceMap": true, "strict": true /* enable all strict type-checking options */ /* Additional Checks */ diff --git a/packages/vscode-ide-companion/vitest.config.ts b/packages/vscode-ide-companion/vitest.config.ts index 60f018c5a..50c8ea3cb 100644 --- a/packages/vscode-ide-companion/vitest.config.ts +++ b/packages/vscode-ide-companion/vitest.config.ts @@ -4,7 +4,7 @@ export default defineConfig({ test: { globals: true, environment: 'node', - include: ['src/**/*.test.ts'], + include: ['src/**/*.test.ts', 'src/**/*.test.tsx'], coverage: { provider: 'v8', reporter: ['text', 'json', 'html', 'clover'], diff --git a/scripts/start.js b/scripts/start.js index baa9fd981..49037b79e 100644 --- a/scripts/start.js +++ b/scripts/start.js @@ -67,7 +67,7 @@ const env = { if (process.env.DEBUG) { // If this is not set, the debugger will pause on the outer process rather // than the relaunched process making it harder to debug. - env.GEMINI_CLI_NO_RELAUNCH = 'true'; + env.QWEN_CODE_NO_RELAUNCH = 'true'; } // Use process.cwd() to inherit the working directory from launch.json cwd setting // This allows debugging from a specific directory (e.g., .todo)